Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkg/leantui/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ func RenderAssistantLines(text string, width int) []string {
return nil
}

rendered, err := markdown.NewRenderer(width).Render(text)
// No copy icon: leantui does not hit-test clicks on code blocks.
rendered, err := markdown.NewRendererWithoutCopyIcon(width).Render(text)
if err != nil {
return WrapANSI(text, width)
}
Expand Down
39 changes: 28 additions & 11 deletions pkg/tui/components/markdown/fast_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,25 @@ func getGlobalStyles() *cachedStyles {
// It directly parses and renders markdown without building an intermediate AST.
type FastRenderer struct {
width int

// hideCopyIcon suppresses the per-code-block copy affordance. Use it for
// contexts that render markdown but do not hit-test clicks on the icon
// (reasoning blocks, dialogs), so no dead copy button is ever shown.
hideCopyIcon bool
}

// NewFastRenderer creates a new fast markdown renderer with the given width.
func NewFastRenderer(width int) *FastRenderer {
return &FastRenderer{width: width}
}

// HideCopyIcon disables the per-code-block copy affordance and returns the
// renderer for chaining.
func (r *FastRenderer) HideCopyIcon() *FastRenderer {
r.hideCopyIcon = true
return r
}

var parserPool = sync.Pool{
New: func() any {
return &parser{
Expand Down Expand Up @@ -306,6 +318,7 @@ func (r *FastRenderer) RenderWithCodeBlocks(input string) (string, []CodeBlock,

p := parserPool.Get().(*parser)
p.reset(input, r.width)
p.hideCopyIcon = r.hideCopyIcon
result := p.parse()
var blocks []CodeBlock
if len(p.codeBlocks) > 0 {
Expand All @@ -318,13 +331,14 @@ func (r *FastRenderer) RenderWithCodeBlocks(input string) (string, []CodeBlock,

// parser holds the state for parsing markdown.
type parser struct {
input string
width int
styles *cachedStyles
out strings.Builder
lines []string
lineIdx int
codeBlocks []CodeBlock
input string
width int
styles *cachedStyles
out strings.Builder
lines []string
lineIdx int
codeBlocks []CodeBlock
hideCopyIcon bool
}

func (p *parser) reset(input string, width int) {
Expand All @@ -338,6 +352,7 @@ func (p *parser) reset(input string, width int) {
}
p.lineIdx = 0
p.codeBlocks = p.codeBlocks[:0]
p.hideCopyIcon = false
p.out.Reset()
p.out.Grow(len(input) * 2) // Pre-allocate for styled output
}
Expand Down Expand Up @@ -1954,23 +1969,25 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW

// Render empty line at the top with a copy affordance pushed to the right
// edge. Record the rendered line index so click handlers can map a click
// back to this block's raw content.
// back to this block's raw content. When the copy icon is hidden the row
// stays as plain top padding and no block is recorded: CodeBlock entries
// exist solely to hit-test clicks on the icon.
topLine := strings.Count(p.out.String(), "\n")
p.out.WriteString(indent)
iconWidth := runewidth.StringWidth(CodeBlockCopyIcon)
leftFill := max(availableWidth-paddingRight-iconWidth, 0)
if availableWidth >= iconWidth+paddingRight {
if !p.hideCopyIcon && availableWidth >= iconWidth+paddingRight {
bgStyle.renderTo(&p.out, spaces(leftFill))
p.styles.ansiCodeBlockCopyIcon.renderTo(&p.out, CodeBlockCopyIcon)
if paddingRight > 0 {
bgStyle.renderTo(&p.out, spaces(paddingRight))
}
p.codeBlocks = append(p.codeBlocks, CodeBlock{Content: code, Line: topLine})
} else {
// Too narrow for the icon; fall back to a plain top padding row.
// Too narrow for the icon (or icon hidden); plain top padding row.
bgStyle.renderTo(&p.out, fullWidthPad)
}
p.out.WriteByte('\n')
p.codeBlocks = append(p.codeBlocks, CodeBlock{Content: code, Line: topLine})

// Process tokens line by line for better performance
var lineBuilder strings.Builder
Expand Down
7 changes: 7 additions & 0 deletions pkg/tui/components/markdown/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ type Renderer interface {
func NewRenderer(width int) Renderer {
return NewFastRenderer(width)
}

// NewRendererWithoutCopyIcon creates a markdown renderer that does not draw
// the per-code-block copy affordance. Use it for surfaces that never
// hit-test clicks on the icon, so no dead copy button is shown.
func NewRendererWithoutCopyIcon(width int) Renderer {
return NewFastRenderer(width).HideCopyIcon()
}
17 changes: 15 additions & 2 deletions pkg/tui/components/message/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,13 @@ func (mv *messageModel) render(width int) string {

return prefix + messageStyle.Width(width).Render(topRow+"\n"+rendered)
case types.MessageTypeShellOutput:
if rendered, err := markdown.NewRenderer(width).Render(fmt.Sprintf("```console\n%s\n```", msg.Content)); err == nil {
if rendered, blocks, err := markdown.NewFastRenderer(width).RenderWithCodeBlocks(fmt.Sprintf("```console\n%s\n```", msg.Content)); err == nil {
// The view has no envelope, so block lines map 1:1 to View() lines,
// making the per-block copy affordance clickable.
mv.codeBlocks = blocks
return rendered
}
mv.codeBlocks = nil
return msg.Content
case types.MessageTypeCancelled:
return styles.WarningStyle.Render("⚠ stream cancelled ⚠")
Expand All @@ -448,9 +452,18 @@ func (mv *messageModel) render(width int) string {
// This preserves line breaks from YAML multiline syntax (|) while still
// allowing markdown formatting like **bold** and *italic*
content := preserveLineBreaks(msg.Content)
rendered, err := markdown.NewRenderer(width - messageStyle.GetHorizontalFrameSize()).Render(content)
rendered, blocks, err := markdown.NewFastRenderer(width - messageStyle.GetHorizontalFrameSize()).RenderWithCodeBlocks(content)
if err != nil {
rendered = msg.Content
blocks = nil
}
// Translate block lines into View() coordinates so the per-block copy
// affordance is clickable: the style envelope prepends its top border
// and padding rows before the rendered markdown.
lineOffset := messageStyle.GetBorderTopSize() + messageStyle.GetPaddingTop()
mv.codeBlocks = nil
for _, cb := range blocks {
mv.codeBlocks = append(mv.codeBlocks, markdown.CodeBlock{Content: cb.Content, Line: cb.Line + lineOffset})
}
return messageStyle.Width(width - 1).Render(strings.TrimRight(rendered, "\n\r\t "))
case types.MessageTypeError:
Expand Down
76 changes: 76 additions & 0 deletions pkg/tui/components/messages/dead_copy_icons_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package messages

import (
"strings"
"testing"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tui/components/markdown"
"github.com/docker/docker-agent/pkg/tui/service"
)

// Every rendered code-block copy icon must be clickable: views that cannot
// hit-test the click (reasoning blocks) must not render the icon at all,
// while message views (shell output, welcome) must map the click to a copy
// command. This guards against dead copy buttons that silently do nothing.
func TestCodeBlockCopyIconsAreNeverDead(t *testing.T) {
t.Parallel()

cases := map[string]struct {
setup func(m *model)
wantIcon bool
}{
"reasoning block renders no copy icon": {
setup: func(m *model) {
m.AppendReasoning("root", "Let me think.\n\n```go\nx := 1\n```\n\nDone thinking.")
},
wantIcon: false,
},
"shell output copy icon is clickable": {
setup: func(m *model) {
m.AddShellOutputMessage("total 0\ndrwxr-xr-x 2 x x 64 Jan 1 .")
},
wantIcon: true,
},
"welcome message copy icon is clickable": {
setup: func(m *model) {
m.AddWelcomeMessage("Hello\n\n```bash\nrun me\n```\n")
},
wantIcon: true,
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()

m := NewScrollableView(80, 40, &service.SessionState{}).(*model)
m.SetSize(80, 40)
tc.setup(m)
m.renderDirty = true
m.View()
m.ensureAllItemsRendered()

found := false
for line, rendered := range m.renderedLines {
plain := ansi.Strip(rendered)
before, _, ok := strings.Cut(plain, markdown.CodeBlockCopyIcon)
if !ok {
continue
}
found = true
start := ansi.StringWidth(before)
width := ansi.StringWidth(markdown.CodeBlockCopyIcon)
for col := start; col < start+width; col++ {
_, cmd := m.handleMouseClick(tea.MouseClickMsg{X: col, Y: line, Button: tea.MouseLeft})
assert.NotNil(t, cmd, "copy icon click at line=%d col=%d must copy (plain=%q)", line, col, plain)
}
}
require.Equal(t, tc.wantIcon, found, "copy icon presence mismatch")
})
}
}
6 changes: 4 additions & 2 deletions pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,8 @@ func (m *Model) ensureCache() *renderCache {
reasoning := m.Reasoning()
var lines []string
if reasoning != "" {
rendered, err := markdown.NewRenderer(contentWidth).Render(reasoning)
// No copy icon: reasoning is not hit-tested for code block clicks.
rendered, err := markdown.NewRendererWithoutCopyIcon(contentWidth).Render(reasoning)
if err != nil {
rendered = reasoning
}
Expand Down Expand Up @@ -703,7 +704,8 @@ func (m *Model) renderHeader(expanded bool) string {
// renderReasoningChunk renders a single reasoning chunk with styling.
func (m *Model) renderReasoningChunk(text string) string {
contentWidth := m.contentWidth()
rendered, err := markdown.NewRenderer(contentWidth).Render(text)
// No copy icon: reasoning is not hit-tested for code block clicks.
rendered, err := markdown.NewRendererWithoutCopyIcon(contentWidth).Render(text)
if err != nil {
rendered = text
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/tui/dialog/elicitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,8 @@ func (d *ElicitationDialog) createInput(field ElicitationField, idx int) textinp
// renderMarkdownMessage renders a message string as markdown for display in dialogs.
// Falls back to plain text rendering if the markdown renderer fails.
func renderMarkdownMessage(message string, contentWidth int) string {
rendered, err := markdown.NewRenderer(contentWidth).Render(message)
// No copy icon: dialogs do not hit-test clicks on code blocks.
rendered, err := markdown.NewRendererWithoutCopyIcon(contentWidth).Render(message)
if err != nil {
return styles.DialogContentStyle.Width(contentWidth).Render(message)
}
Expand Down
Loading