Skip to content

Commit 6a3a4bb

Browse files
committed
feat(tui): add per-code-block copy affordance
Each fenced code block now renders a copy glyph in its top-right corner. Clicking the glyph copies the block's raw content (preserving original whitespace and line breaks) instead of the whole assistant message. The renderer returns the list of code blocks alongside the styled output, and the message click handler maps a click on the glyph back to the corresponding raw code. Closes #2776
1 parent 80eb2e4 commit 6a3a4bb

6 files changed

Lines changed: 295 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+
// CodeBlockCopyLabel 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 CodeBlockCopyLabel = "\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+
// label 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+
ansiCodeBgMuted ansiStyle // muted foreground on code block background (for code-block chrome like copy label)
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+
ansiCodeBgMuted: 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+
labelWidth := runewidth.StringWidth(CodeBlockCopyLabel)
1940+
leftFill := max(availableWidth-paddingRight-labelWidth, 0)
1941+
if availableWidth >= labelWidth+paddingRight {
1942+
bgStyle.renderTo(&p.out, spaces(leftFill))
1943+
p.styles.ansiCodeBgMuted.renderTo(&p.out, CodeBlockCopyLabel)
1944+
if paddingRight > 0 {
1945+
bgStyle.renderTo(&p.out, spaces(paddingRight))
1946+
}
1947+
} else {
1948+
// Too narrow for the label; 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]), CodeBlockCopyLabel,
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]), CodeBlockCopyLabel)
2080+
}
2081+
}

pkg/tui/components/markdown/incremental.go

Lines changed: 76 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,41 @@ func (r *IncrementalRenderer) joinPrefixAndTail(prefix, tail string) string {
193217
return b.String()
194218
}
195219

220+
// mergeCodeBlocks returns the union of code blocks from a cached prefix output
221+
// and a freshly rendered tail. Tail block line indices are shifted by the
222+
// number of lines in the prefix plus one for the blank separator inserted by
223+
// joinPrefixAndTail.
224+
func (r *IncrementalRenderer) mergeCodeBlocks(prefixOut string, prefixBlocks, tailBlocks []CodeBlock) []CodeBlock {
225+
if len(prefixBlocks) == 0 && len(tailBlocks) == 0 {
226+
return nil
227+
}
228+
out := make([]CodeBlock, 0, len(prefixBlocks)+len(tailBlocks))
229+
out = append(out, prefixBlocks...)
230+
if len(tailBlocks) == 0 {
231+
return out
232+
}
233+
offset := 0
234+
if prefixOut != "" {
235+
// Number of lines in prefix (FastRenderer trims its trailing newline so
236+
// the count is newlines + 1) plus one for the blank separator inserted
237+
// by joinPrefixAndTail.
238+
offset = strings.Count(prefixOut, "\n") + 2
239+
}
240+
for _, b := range tailBlocks {
241+
out = append(out, CodeBlock{Content: b.Content, Line: b.Line + offset})
242+
}
243+
return out
244+
}
245+
246+
func cloneCodeBlocks(in []CodeBlock) []CodeBlock {
247+
if len(in) == 0 {
248+
return nil
249+
}
250+
out := make([]CodeBlock, len(in))
251+
copy(out, in)
252+
return out
253+
}
254+
196255
// stableBoundary returns the byte index just after the last "safe" block
197256
// boundary in input, or 0 if no safe boundary exists. A safe boundary is a
198257
// blank line that the FastRenderer treats as a hard break between top-level

0 commit comments

Comments
 (0)