Skip to content

Commit 9513dbd

Browse files
authored
Merge pull request #2778 from rumpl/feat-copy-code-blocks
feat(tui): add per-code-block copy affordance
2 parents d8c86bb + 53d325f commit 9513dbd

6 files changed

Lines changed: 297 additions & 42 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package markdown
2+
3+
// CodeBlockCopyIcon is the unicode glyph rendered at the top-right corner of
4+
// every fenced code block. Clicking on it copies the block's raw content to
5+
// the clipboard. It matches the glyph used for the message-level copy
6+
// affordance so the visual language stays consistent; the two are
7+
// disambiguated by line index, not by glyph.
8+
const CodeBlockCopyIcon = "\u2398"
9+
10+
// CodeBlock describes a fenced code block emitted by the renderer.
11+
//
12+
// Line is the 0-indexed line, within the renderer's output, where the copy
13+
// icon is rendered on the code block's top padding row. Content holds the
14+
// raw code (without ANSI styling) so callers can place it on the clipboard.
15+
type CodeBlock struct {
16+
Content string
17+
Line int
18+
}

pkg/tui/components/markdown/fast_renderer.go

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,19 @@ type cachedStyles struct {
117117
styleCodeBg lipgloss.Style // kept only because chroma styles inherit its bg color
118118

119119
// ANSI styles (for fast inline rendering)
120-
ansiBold ansiStyle
121-
ansiItalic ansiStyle
122-
ansiBoldItal ansiStyle
123-
ansiStrike ansiStyle
124-
ansiCode ansiStyle
125-
ansiLink ansiStyle
126-
ansiLinkText ansiStyle
127-
ansiText ansiStyle // base document text style
128-
ansiHeadings [6]ansiStyle // heading styles for inline restoration
129-
ansiBlockquote ansiStyle // blockquote style for inline restoration
130-
ansiFootnote ansiStyle // footnote reference style
131-
ansiCodeBg ansiStyle // code block background (cached to avoid repeated buildAnsiStyle)
120+
ansiBold ansiStyle
121+
ansiItalic ansiStyle
122+
ansiBoldItal ansiStyle
123+
ansiStrike ansiStyle
124+
ansiCode ansiStyle
125+
ansiLink ansiStyle
126+
ansiLinkText ansiStyle
127+
ansiText ansiStyle // base document text style
128+
ansiHeadings [6]ansiStyle // heading styles for inline restoration
129+
ansiBlockquote ansiStyle // blockquote style for inline restoration
130+
ansiFootnote ansiStyle // footnote reference style
131+
ansiCodeBg ansiStyle // code block background (cached to avoid repeated buildAnsiStyle)
132+
ansiCodeBlockCopyIcon ansiStyle // muted foreground on code block background, used for the per-block copy icon
132133

133134
// Pre-rendered chrome (computed once, reused across renders)
134135
headingPrefixes [6]string // raw prefix strings (e.g. "## ") for width math
@@ -242,6 +243,7 @@ func getGlobalStyles() *cachedStyles {
242243
ansiBlockquote: buildAnsiStyle(blockquoteLipStyle),
243244
ansiFootnote: buildAnsiStyle(lipgloss.NewStyle().Foreground(styles.TextSecondary).Italic(true)),
244245
ansiCodeBg: buildAnsiStyle(codeBg),
246+
ansiCodeBlockCopyIcon: buildAnsiStyle(codeBg.Foreground(styles.TextMutedGray)),
245247
headingPrefixes: headingPrefixes,
246248
styledHeadingPrefixes: styledPrefixes,
247249
styledHeadingContIndent: styledContIndents,
@@ -278,27 +280,42 @@ var parserPool = sync.Pool{
278280

279281
// Render parses and renders markdown content to styled terminal output.
280282
func (r *FastRenderer) Render(input string) (string, error) {
283+
out, _, err := r.RenderWithCodeBlocks(input)
284+
return out, err
285+
}
286+
287+
// RenderWithCodeBlocks renders markdown content and returns both the styled
288+
// terminal output and the list of fenced code blocks emitted, in document
289+
// order. Each entry's Line points at the rendered line that carries the
290+
// clickable copy label for that block.
291+
func (r *FastRenderer) RenderWithCodeBlocks(input string) (string, []CodeBlock, error) {
281292
if input == "" {
282-
return "", nil
293+
return "", nil, nil
283294
}
284295

285296
input = sanitizeForTerminal(input)
286297

287298
p := parserPool.Get().(*parser)
288299
p.reset(input, r.width)
289300
result := p.parse()
301+
var blocks []CodeBlock
302+
if len(p.codeBlocks) > 0 {
303+
blocks = make([]CodeBlock, len(p.codeBlocks))
304+
copy(blocks, p.codeBlocks)
305+
}
290306
parserPool.Put(p)
291-
return finalizeOutput(result, r.width), nil
307+
return finalizeOutput(result, r.width), blocks, nil
292308
}
293309

294310
// parser holds the state for parsing markdown.
295311
type parser struct {
296-
input string
297-
width int
298-
styles *cachedStyles
299-
out strings.Builder
300-
lines []string
301-
lineIdx int
312+
input string
313+
width int
314+
styles *cachedStyles
315+
out strings.Builder
316+
lines []string
317+
lineIdx int
318+
codeBlocks []CodeBlock
302319
}
303320

304321
func (p *parser) reset(input string, width int) {
@@ -311,6 +328,7 @@ func (p *parser) reset(input string, width int) {
311328
p.lines = append(p.lines, line)
312329
}
313330
p.lineIdx = 0
331+
p.codeBlocks = p.codeBlocks[:0]
314332
p.out.Reset()
315333
p.out.Grow(len(input) * 2) // Pre-allocate for styled output
316334
}
@@ -1913,10 +1931,25 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
19131931
// Use cached background style
19141932
bgStyle := p.styles.ansiCodeBg
19151933

1916-
// Render empty line at the top (use sequential writes instead of concat)
1934+
// Render empty line at the top with a copy affordance pushed to the right
1935+
// edge. Record the rendered line index so click handlers can map a click
1936+
// back to this block's raw content.
1937+
topLine := strings.Count(p.out.String(), "\n")
19171938
p.out.WriteString(indent)
1918-
bgStyle.renderTo(&p.out, fullWidthPad)
1939+
iconWidth := runewidth.StringWidth(CodeBlockCopyIcon)
1940+
leftFill := max(availableWidth-paddingRight-iconWidth, 0)
1941+
if availableWidth >= iconWidth+paddingRight {
1942+
bgStyle.renderTo(&p.out, spaces(leftFill))
1943+
p.styles.ansiCodeBlockCopyIcon.renderTo(&p.out, CodeBlockCopyIcon)
1944+
if paddingRight > 0 {
1945+
bgStyle.renderTo(&p.out, spaces(paddingRight))
1946+
}
1947+
} else {
1948+
// Too narrow for the icon; fall back to a plain top padding row.
1949+
bgStyle.renderTo(&p.out, fullWidthPad)
1950+
}
19191951
p.out.WriteByte('\n')
1952+
p.codeBlocks = append(p.codeBlocks, CodeBlock{Content: code, Line: topLine})
19201953

19211954
// Process tokens line by line for better performance
19221955
var lineBuilder strings.Builder

pkg/tui/components/markdown/fast_renderer_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,3 +2028,54 @@ func BenchmarkStreamingGlamourRenderer(b *testing.B) {
20282028
}
20292029
}
20302030
}
2031+
2032+
func TestFastRendererCodeBlocksReturnsRawContent(t *testing.T) {
2033+
t.Parallel()
2034+
2035+
input := "Some intro.\n\n```go\npackage main\n\nfunc main() {}\n```\n\nMid.\n\n```\nhello\nworld\n```\n"
2036+
r := NewFastRenderer(80)
2037+
out, blocks, err := r.RenderWithCodeBlocks(input)
2038+
require.NoError(t, err)
2039+
2040+
require.Len(t, blocks, 2)
2041+
assert.Equal(t, "package main\n\nfunc main() {}", blocks[0].Content)
2042+
assert.Equal(t, "hello\nworld", blocks[1].Content)
2043+
2044+
// Each block's recorded line must contain the copy-affordance glyph.
2045+
lines := strings.Split(out, "\n")
2046+
for _, b := range blocks {
2047+
require.Less(t, b.Line, len(lines), "code block line index out of range")
2048+
assert.Contains(t, stripANSI(lines[b.Line]), CodeBlockCopyIcon,
2049+
"line %d should contain the code block copy label", b.Line)
2050+
}
2051+
2052+
// The two code blocks must land on different lines.
2053+
assert.NotEqual(t, blocks[0].Line, blocks[1].Line)
2054+
}
2055+
2056+
func TestIncrementalRendererCodeBlocksAggregate(t *testing.T) {
2057+
t.Parallel()
2058+
2059+
// Render in two streamed chunks; the second extends the first.
2060+
chunk1 := "Intro paragraph.\n\n```go\nfunc a() {}\n```\n\n"
2061+
chunk2 := chunk1 + "Middle.\n\n```\nplain\ncode\n```\n"
2062+
2063+
r := NewIncrementalRenderer(80)
2064+
_, blocks1, err := r.RenderWithCodeBlocks(chunk1)
2065+
require.NoError(t, err)
2066+
require.Len(t, blocks1, 1)
2067+
assert.Equal(t, "func a() {}", blocks1[0].Content)
2068+
2069+
out, blocks2, err := r.RenderWithCodeBlocks(chunk2)
2070+
require.NoError(t, err)
2071+
require.Len(t, blocks2, 2)
2072+
assert.Equal(t, "func a() {}", blocks2[0].Content)
2073+
assert.Equal(t, "plain\ncode", blocks2[1].Content)
2074+
2075+
// Each block's reported line must carry the copy label in the final output.
2076+
lines := strings.Split(out, "\n")
2077+
for _, b := range blocks2 {
2078+
require.Less(t, b.Line, len(lines))
2079+
assert.Contains(t, stripANSI(lines[b.Line]), CodeBlockCopyIcon)
2080+
}
2081+
}

pkg/tui/components/markdown/incremental.go

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type IncrementalRenderer struct {
3030
inputPrefix string
3131
outputPrefix string
3232

33+
// codeBlocksPrefix is the list of code blocks emitted while rendering the
34+
// cached prefix, with Line indices relative to outputPrefix.
35+
codeBlocksPrefix []CodeBlock
36+
3337
// fallback is used for the actual rendering work; it is reused across calls
3438
// so its parser pool (and chroma caches) stay warm.
3539
fallback *FastRenderer
@@ -48,10 +52,20 @@ func NewIncrementalRenderer(width int) *IncrementalRenderer {
4852
// inputs that share a long common prefix (the streaming case), only the suffix
4953
// is parsed and rendered; the rest is served from the cached output.
5054
func (r *IncrementalRenderer) Render(input string) (string, error) {
55+
out, _, err := r.RenderWithCodeBlocks(input)
56+
return out, err
57+
}
58+
59+
// RenderWithCodeBlocks behaves like Render but additionally returns the list
60+
// of fenced code blocks in the rendered output. Each entry's Line is the
61+
// 0-indexed line within the returned string where the block's copy label is
62+
// drawn.
63+
func (r *IncrementalRenderer) RenderWithCodeBlocks(input string) (string, []CodeBlock, error) {
5164
if input == "" {
5265
r.inputPrefix = ""
5366
r.outputPrefix = ""
54-
return "", nil
67+
r.codeBlocksPrefix = nil
68+
return "", nil, nil
5569
}
5670

5771
// If the new input no longer starts with our cached prefix, the user (or a
@@ -70,33 +84,37 @@ func (r *IncrementalRenderer) Render(input string) (string, error) {
7084
if boundary <= 0 {
7185
// No new block boundary in the tail yet — render only the tail and
7286
// concatenate. Cached prefix is unchanged.
73-
renderedTail, err := r.fallback.Render(tail)
87+
renderedTail, tailBlocks, err := r.fallback.RenderWithCodeBlocks(tail)
7488
if err != nil {
7589
return r.fullRender(input)
7690
}
77-
return r.joinPrefixAndTail(r.outputPrefix, renderedTail), nil
91+
out := r.joinPrefixAndTail(r.outputPrefix, renderedTail)
92+
return out, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, tailBlocks), nil
7893
}
7994

8095
// We have a new boundary inside the tail. Render the new stable region
8196
// (inputPrefix + tail[:boundary]) once, append it to the cache, then render
8297
// the new tail.
8398
newStableTail := tail[:boundary]
84-
renderedStableTail, err := r.fallback.Render(newStableTail)
99+
renderedStableTail, stableBlocks, err := r.fallback.RenderWithCodeBlocks(newStableTail)
85100
if err != nil {
86101
return r.fullRender(input)
87102
}
103+
newBlocks := r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, stableBlocks)
88104
r.inputPrefix += newStableTail
89105
r.outputPrefix = r.joinPrefixAndTail(r.outputPrefix, renderedStableTail)
106+
r.codeBlocksPrefix = newBlocks
90107

91108
rest := tail[boundary:]
92109
if rest == "" {
93-
return r.outputPrefix, nil
110+
return r.outputPrefix, cloneCodeBlocks(r.codeBlocksPrefix), nil
94111
}
95-
renderedRest, err := r.fallback.Render(rest)
112+
renderedRest, restBlocks, err := r.fallback.RenderWithCodeBlocks(rest)
96113
if err != nil {
97114
return r.fullRender(input)
98115
}
99-
return r.joinPrefixAndTail(r.outputPrefix, renderedRest), nil
116+
out := r.joinPrefixAndTail(r.outputPrefix, renderedRest)
117+
return out, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, restBlocks), nil
100118
}
101119

102120
// SetWidth updates the renderer width. Width changes invalidate the cache
@@ -109,13 +127,15 @@ func (r *IncrementalRenderer) SetWidth(width int) {
109127
r.fallback = NewFastRenderer(width)
110128
r.inputPrefix = ""
111129
r.outputPrefix = ""
130+
r.codeBlocksPrefix = nil
112131
}
113132

114133
// Reset drops the cached prefix without changing the width. Use when the
115134
// underlying message is replaced by an unrelated new one.
116135
func (r *IncrementalRenderer) Reset() {
117136
r.inputPrefix = ""
118137
r.outputPrefix = ""
138+
r.codeBlocksPrefix = nil
119139
}
120140

121141
// fullRender renders input from scratch, refreshes the cache, and returns the
@@ -124,36 +144,40 @@ func (r *IncrementalRenderer) Reset() {
124144
// pieces separately, then join. The two render calls on smaller inputs are
125145
// faster than one big render plus a separate prefix render, and the prefix
126146
// piece can be reused as outputPrefix.
127-
func (r *IncrementalRenderer) fullRender(input string) (string, error) {
147+
func (r *IncrementalRenderer) fullRender(input string) (string, []CodeBlock, error) {
128148
boundary := stableBoundary(input)
129149
if boundary <= 0 {
130-
out, err := r.fallback.Render(input)
150+
out, blocks, err := r.fallback.RenderWithCodeBlocks(input)
131151
if err != nil {
132-
return "", err
152+
return "", nil, err
133153
}
134154
r.inputPrefix = ""
135155
r.outputPrefix = ""
136-
return out, nil
156+
r.codeBlocksPrefix = nil
157+
return out, blocks, nil
137158
}
138159

139160
prefix := input[:boundary]
140161
rest := input[boundary:]
141-
renderedPrefix, err := r.fallback.Render(prefix)
162+
renderedPrefix, prefixBlocks, err := r.fallback.RenderWithCodeBlocks(prefix)
142163
if err != nil {
143-
return "", err
164+
return "", nil, err
144165
}
145166
if rest == "" {
146167
r.inputPrefix = prefix
147168
r.outputPrefix = renderedPrefix
148-
return renderedPrefix, nil
169+
r.codeBlocksPrefix = prefixBlocks
170+
return renderedPrefix, cloneCodeBlocks(prefixBlocks), nil
149171
}
150-
renderedRest, err := r.fallback.Render(rest)
172+
renderedRest, restBlocks, err := r.fallback.RenderWithCodeBlocks(rest)
151173
if err != nil {
152-
return "", err
174+
return "", nil, err
153175
}
154176
r.inputPrefix = prefix
155177
r.outputPrefix = renderedPrefix
156-
return r.joinPrefixAndTail(renderedPrefix, renderedRest), nil
178+
r.codeBlocksPrefix = prefixBlocks
179+
out := r.joinPrefixAndTail(renderedPrefix, renderedRest)
180+
return out, r.mergeCodeBlocks(renderedPrefix, prefixBlocks, restBlocks), nil
157181
}
158182

159183
// joinPrefixAndTail concatenates a previously rendered prefix and a freshly
@@ -193,6 +217,43 @@ func (r *IncrementalRenderer) joinPrefixAndTail(prefix, tail string) string {
193217
return b.String()
194218
}
195219

220+
// joinSeparatorLines is the number of extra rendered lines that
221+
// joinPrefixAndTail inserts between the prefix output and the tail output:
222+
// one to terminate the prefix's final (untrailing-newlined) line, and one
223+
// blank-padded separator line. Keep this in sync with joinPrefixAndTail.
224+
const joinSeparatorLines = 2
225+
226+
// mergeCodeBlocks returns the union of code blocks from a cached prefix output
227+
// and a freshly rendered tail. Tail block line indices are shifted past the
228+
// prefix's lines and the separator that joinPrefixAndTail inserts.
229+
func (r *IncrementalRenderer) mergeCodeBlocks(prefixOut string, prefixBlocks, tailBlocks []CodeBlock) []CodeBlock {
230+
if len(prefixBlocks) == 0 && len(tailBlocks) == 0 {
231+
return nil
232+
}
233+
out := make([]CodeBlock, 0, len(prefixBlocks)+len(tailBlocks))
234+
out = append(out, prefixBlocks...)
235+
if len(tailBlocks) == 0 {
236+
return out
237+
}
238+
offset := 0
239+
if prefixOut != "" {
240+
offset = strings.Count(prefixOut, "\n") + joinSeparatorLines
241+
}
242+
for _, b := range tailBlocks {
243+
out = append(out, CodeBlock{Content: b.Content, Line: b.Line + offset})
244+
}
245+
return out
246+
}
247+
248+
func cloneCodeBlocks(in []CodeBlock) []CodeBlock {
249+
if len(in) == 0 {
250+
return nil
251+
}
252+
out := make([]CodeBlock, len(in))
253+
copy(out, in)
254+
return out
255+
}
256+
196257
// stableBoundary returns the byte index just after the last "safe" block
197258
// boundary in input, or 0 if no safe boundary exists. A safe boundary is a
198259
// blank line that the FastRenderer treats as a hard break between top-level

0 commit comments

Comments
 (0)