Skip to content

Commit 16583cd

Browse files
authored
Merge pull request #3721 from docker/worktree-board-570607f2c61e5e8e
fix(tui): address PR #3720 review feedback on markdown image rendering
2 parents 056e7ad + 5914cb7 commit 16583cd

4 files changed

Lines changed: 78 additions & 11 deletions

File tree

pkg/tui/components/message/message.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ type messageModel struct {
8787
}
8888

8989
type markdownImagesLoadedMsg struct {
90-
target *messageModel
91-
images map[string]tuiimage.Inline
90+
target *messageModel
91+
requested []tuiimage.MarkdownReference
92+
images map[string]tuiimage.Inline
9293
}
9394

9495
type markdownImageRenderedMsg struct{}
@@ -187,7 +188,7 @@ func (mv *messageModel) loadMarkdownImages(msg *types.Message) tea.Cmd {
187188
loaded[ref.Source] = image
188189
}
189190
}
190-
return markdownImagesLoadedMsg{target: mv, images: loaded}
191+
return markdownImagesLoadedMsg{target: mv, requested: pending, images: loaded}
191192
}
192193
}
193194

@@ -208,6 +209,12 @@ func (mv *messageModel) SetHovered(hovered bool) {
208209
// Update handles messages and updates the message view state
209210
func (mv *messageModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
210211
if loaded, ok := msg.(markdownImagesLoadedMsg); ok && loaded.target == mv {
212+
// Unmark failed URLs so a later SetMessage can retry them.
213+
for _, ref := range loaded.requested {
214+
if _, success := loaded.images[ref.Source]; !success {
215+
delete(mv.loadingImages, ref.Source)
216+
}
217+
}
211218
if len(loaded.images) > 0 {
212219
if mv.markdownImages == nil {
213220
mv.markdownImages = make(map[string]tuiimage.Inline)
@@ -532,8 +539,11 @@ func replaceMarkdownImagePlaceholders(rendered string, codeBlocks []markdown.Cod
532539
shifts = append(shifts, lineShift{line: lineIndex, delta: len(replacement) - 1})
533540
}
534541
for i := range codeBlocks {
542+
// Compare against the pre-replacement line so later shifts don't
543+
// re-match against already-adjusted positions.
544+
orig := codeBlocks[i].Line
535545
for _, shift := range shifts {
536-
if shift.line < codeBlocks[i].Line {
546+
if shift.line < orig {
537547
codeBlocks[i].Line += shift.delta
538548
}
539549
}

pkg/tui/components/message/message_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/stretchr/testify/assert"
1616
"github.com/stretchr/testify/require"
1717

18+
"github.com/docker/docker-agent/pkg/tui/components/markdown"
1819
"github.com/docker/docker-agent/pkg/tui/components/spinner"
1920
tuiimage "github.com/docker/docker-agent/pkg/tui/image"
2021
"github.com/docker/docker-agent/pkg/tui/types"
@@ -50,6 +51,42 @@ func TestAssistantMarkdownImageRendersInline(t *testing.T) {
5051
assert.Less(t, strings.Index(view, "chart"), strings.Index(view, "cagent-image"), "alt label must immediately precede the image")
5152
}
5253

54+
func TestFailedMarkdownImageLoadCanRetry(t *testing.T) {
55+
t.Parallel()
56+
57+
source := "https://example.com/missing.png"
58+
msg := types.Agent(types.MessageTypeAssistant, "assistant", "![img]("+source+")")
59+
mv := New(msg, nil)
60+
mv.loadingImages = map[string]bool{source: true}
61+
62+
_, _ = mv.Update(markdownImagesLoadedMsg{
63+
target: mv,
64+
requested: []tuiimage.MarkdownReference{{Source: source}},
65+
images: map[string]tuiimage.Inline{},
66+
})
67+
68+
assert.Empty(t, mv.loadingImages, "failed sources must be cleared so SetMessage can retry")
69+
assert.NotNil(t, mv.loadMarkdownImages(msg), "a retry fetch must be scheduled")
70+
}
71+
72+
func TestReplaceMarkdownImagePlaceholdersShiftsCodeBlocksByOriginalLine(t *testing.T) {
73+
t.Parallel()
74+
75+
lines := make([]string, 13)
76+
lines[3] = "TOKENA"
77+
lines[12] = "TOKENB"
78+
placeholders := []markdownImagePlaceholder{
79+
{token: "TOKENA", lines: []string{"a1", "a2", "a3", "a4", "a5", "a6"}}, // delta +5
80+
{token: "TOKENB", lines: []string{"b1", "b2", "b3"}}, // delta +2, after the code block
81+
}
82+
83+
_, adjusted := replaceMarkdownImagePlaceholders(strings.Join(lines, "\n"), []markdown.CodeBlock{{Line: 10}}, placeholders)
84+
85+
// Only the placeholder before the code block's original line shifts it.
86+
require.Len(t, adjusted, 1)
87+
assert.Equal(t, 15, adjusted[0].Line)
88+
}
89+
5390
func TestErrorMessageWrapping(t *testing.T) {
5491
t.Parallel()
5592

pkg/tui/image/image.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -224,15 +224,13 @@ func LoadMarkdownReference(ctx context.Context, ref MarkdownReference) (Inline,
224224
return Inline{}, false
225225
}
226226
} else {
227-
path := ref.Source
228-
switch {
229-
case parsed == nil || parsed.Scheme == "":
230-
case parsed.Scheme == "file", parsed.Scheme == "sandbox":
231-
path = parsed.Path
232-
default:
227+
// Sources come from LLM-generated markdown, so local schemes like
228+
// file:// and sandbox:// would let a prompt-injected response read
229+
// arbitrary local files (confused deputy). Only bare paths pass.
230+
if parsed != nil && parsed.Scheme != "" {
233231
return Inline{}, false
234232
}
235-
file, err := os.Open(path)
233+
file, err := os.Open(ref.Source)
236234
if err != nil {
237235
return Inline{}, false
238236
}

pkg/tui/image/image_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
stdimage "image"
88
"image/color"
99
"image/png"
10+
"os"
11+
"path/filepath"
1012
"testing"
1113

1214
"github.com/stretchr/testify/assert"
@@ -50,6 +52,26 @@ func TestLoadMarkdownReferenceDataURI(t *testing.T) {
5052
assert.NotEmpty(t, inline.PNGData)
5153
}
5254

55+
func TestLoadMarkdownReferenceRejectsLocalSchemes(t *testing.T) {
56+
t.Parallel()
57+
58+
img := stdimage.NewRGBA(stdimage.Rect(0, 0, 2, 1))
59+
var encoded bytes.Buffer
60+
require.NoError(t, png.Encode(&encoded, img))
61+
path := filepath.Join(t.TempDir(), "chart.png")
62+
require.NoError(t, os.WriteFile(path, encoded.Bytes(), 0o600))
63+
64+
for _, source := range []string{"file://" + path, "sandbox://" + path} {
65+
_, ok := LoadMarkdownReference(t.Context(), MarkdownReference{Alt: "chart", Source: source})
66+
assert.False(t, ok, source)
67+
}
68+
69+
// Bare paths remain supported for agent-generated local images.
70+
inline, ok := LoadMarkdownReference(t.Context(), MarkdownReference{Alt: "chart", Source: path})
71+
require.True(t, ok)
72+
assert.Equal(t, "chart", inline.Name)
73+
}
74+
5375
func TestInlineRegistryIsBounded(t *testing.T) {
5476
resetInlineRegistry()
5577
defer resetInlineRegistry()

0 commit comments

Comments
 (0)