From 39296de5606ddc48c11f0bcd8a3f255c706764f5 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 16 Jul 2026 23:54:01 +0200 Subject: [PATCH 1/3] feat(tui): render tool result images Signed-off-by: Djordje Lukic --- cmd/root/new.go | 6 +- pkg/leantui/image.go | 75 +---- pkg/leantui/ui/image.go | 59 +--- pkg/tui/components/messages/messages.go | 4 +- .../reasoningblock/reasoningblock.go | 4 +- pkg/tui/components/tool/factory.go | 13 +- pkg/tui/components/tool/factory_test.go | 19 ++ pkg/tui/components/tool/inline_images.go | 79 +++++ pkg/tui/image/image.go | 250 +++++++++++++++ pkg/tui/image/image_test.go | 70 ++++ pkg/tui/image/probe.go | 62 ++++ pkg/tui/image/probe_test.go | 64 ++++ pkg/tui/image/writer.go | 302 ++++++++++++++++++ pkg/tui/image/writer_test.go | 141 ++++++++ pkg/tui/tui.go | 24 +- pkg/tui/types/types.go | 2 + 16 files changed, 1038 insertions(+), 136 deletions(-) create mode 100644 pkg/tui/components/tool/inline_images.go create mode 100644 pkg/tui/image/image.go create mode 100644 pkg/tui/image/image_test.go create mode 100644 pkg/tui/image/probe.go create mode 100644 pkg/tui/image/probe_test.go create mode 100644 pkg/tui/image/writer.go create mode 100644 pkg/tui/image/writer_test.go diff --git a/cmd/root/new.go b/cmd/root/new.go index 882b2fa1f2..d014ce6e97 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/tui" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" tuiinput "github.com/docker/docker-agent/pkg/tui/input" "github.com/docker/docker-agent/pkg/tui/styles" ) @@ -131,12 +132,15 @@ func runTUIWrapped(ctx context.Context, rt runtime.Runtime, sess *session.Sessio if wd == "" { wd, _ = os.Getwd() } + imageWriter := tuiimage.NewWriter(os.Stdout) + imageWriter.SetSupported(tuiimage.SupportsKittyGraphics(os.Stdin, os.Stdout)) + tuiOpts = append(tuiOpts, tui.WithImageWriter(imageWriter)) model := tui.New(ctx, spawner, a, wd, cleanup, tuiOpts...) if wrap != nil { model = wrap(model) } - p := tea.NewProgram(model, tea.WithContext(ctx), tea.WithFilter(filter)) + p := tea.NewProgram(model, tea.WithContext(ctx), tea.WithFilter(filter), tea.WithOutput(imageWriter)) coalescer.SetSender(p.Send) if m, ok := model.(interface{ SetProgram(p *tea.Program) }); ok { diff --git a/pkg/leantui/image.go b/pkg/leantui/image.go index 9f2fa95cf8..4803abe2ea 100644 --- a/pkg/leantui/image.go +++ b/pkg/leantui/image.go @@ -1,82 +1,15 @@ package leantui import ( - "bytes" - "encoding/base64" - "fmt" - "image" - _ "image/gif" - "image/png" - "strings" - - "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/tools" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" ) func inlineImagesFromToolResult(result *tools.ToolCallResult) []ui.InlineImage { - if result == nil { - return nil - } - - images := make([]ui.InlineImage, 0, len(result.Images)+len(result.Documents)) - for i, img := range result.Images { - name := fmt.Sprintf("image-%d", i+1) - if inline, ok := inlineImageFromBase64(name, img.MimeType, img.Data); ok { - images = append(images, inline) - } - } - for _, doc := range result.Documents { - if !chat.IsImageMimeType(doc.MimeType) || doc.Data == "" { - continue - } - name := doc.Name - if name == "" { - name = "image" - } - if inline, ok := inlineImageFromBase64(name, doc.MimeType, doc.Data); ok { - images = append(images, inline) - } - } - return images + return tuiimage.FromToolResult(result) } -func inlineImageFromBase64(name, mimeType, b64 string) (ui.InlineImage, bool) { - if strings.TrimSpace(b64) == "" { - return ui.InlineImage{}, false - } - - data, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - return ui.InlineImage{}, false - } - if mimeType == "" { - mimeType = chat.DetectMimeTypeByContent(data) - } - if !chat.IsImageMimeType(mimeType) { - return ui.InlineImage{}, false - } - if resized, err := chat.ResizeImage(data, mimeType); err == nil { - data = resized.Data - mimeType = resized.MimeType - } - - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return ui.InlineImage{}, false - } - bounds := img.Bounds() - - var pngBuf bytes.Buffer - if err := png.Encode(&pngBuf, img); err != nil { - return ui.InlineImage{}, false - } - - return ui.InlineImage{ - Name: name, - MIME: mimeType, - PNGData: pngBuf.Bytes(), - Width: bounds.Dx(), - Height: bounds.Dy(), - }, true +func inlineImageFromBase64(name, mimeType, encoded string) (ui.InlineImage, bool) { + return tuiimage.FromBase64(name, mimeType, encoded) } diff --git a/pkg/leantui/ui/image.go b/pkg/leantui/ui/image.go index ec56dbaf5b..4220a4280a 100644 --- a/pkg/leantui/ui/image.go +++ b/pkg/leantui/ui/image.go @@ -1,67 +1,16 @@ package ui -import ( - "encoding/base64" - "fmt" - "strings" -) - -const ( - kittyMaxChunkSize = 4096 - kittyMaxImageCols = 80 - kittyMaxImageRows = 30 -) +import tuiimage "github.com/docker/docker-agent/pkg/tui/image" // InlineImage is a PNG image prepared for inline kitty-protocol rendering. -type InlineImage struct { - Name string - MIME string - PNGData []byte - Width int - Height int -} +type InlineImage = tuiimage.Inline // RenderInlineImage renders an inline image label and kitty image sequence. func RenderInlineImage(img InlineImage, width int) []string { - if len(img.PNGData) == 0 || img.Width <= 0 || img.Height <= 0 { - return nil - } - - cols := min(max(width-4, 1), kittyMaxImageCols) - rows := max(1, (img.Height*cols+img.Width-1)/img.Width/2) - rows = min(rows, kittyMaxImageRows) - - label := "image" - if img.Name != "" { - label = img.Name - } - if img.MIME != "" { - label += " (" + img.MIME + ")" - } - - out := []string{" " + StMuted().Render("🖼 "+label)} - out = append(out, " "+KittyImageSequence(img.PNGData, cols, rows)) - for range rows - 1 { - out = append(out, "") - } - return out + return tuiimage.Render(img, width) } // KittyImageSequence encodes PNG data as a kitty graphics escape sequence. func KittyImageSequence(pngData []byte, cols, rows int) string { - encoded := base64.StdEncoding.EncodeToString(pngData) - var b strings.Builder - for offset := 0; offset < len(encoded); offset += kittyMaxChunkSize { - end := min(offset+kittyMaxChunkSize, len(encoded)) - more := 0 - if end < len(encoded) { - more = 1 - } - if offset == 0 { - fmt.Fprintf(&b, "\x1b_Ga=T,t=d,f=100,q=2,C=1,c=%d,r=%d,m=%d;%s\x1b\\", cols, rows, more, encoded[offset:end]) - } else { - fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, encoded[offset:end]) - } - } - return b.String() + return tuiimage.KittySequence(pngData, cols, rows) } diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 52084ca5c6..dbc2d79f2f 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -29,6 +29,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/tool/editfile" "github.com/docker/docker-agent/pkg/tui/core" "github.com/docker/docker-agent/pkg/tui/core/layout" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/styles" @@ -1666,7 +1667,7 @@ func (m *model) AddToolResult(msg *runtime.ToolCallResponseEvent, status types.T if m.messages[i].Type == types.MessageTypeAssistantReasoningBlock { if block, ok := m.views[i].(*reasoningblock.Model); ok { if block.HasToolCall(msg.ToolCallID) { - cmd := block.UpdateToolResult(msg.ToolCallID, msg.Response, status, msg.Result.WithoutPayload()) + cmd := block.UpdateToolResult(msg.ToolCallID, msg.Response, status, msg.Result) m.invalidateItem(i) return cmd } @@ -1681,6 +1682,7 @@ func (m *model) AddToolResult(msg *runtime.ToolCallResponseEvent, status types.T toolMessage.Content = strings.ReplaceAll(msg.Response, "\t", " ") toolMessage.ToolStatus = status toolMessage.ToolResult = msg.Result.WithoutPayload() + toolMessage.Images = tuiimage.FromToolResult(msg.Result) m.invalidateItem(i) // The replaced view may still hold a running-spinner subscription. diff --git a/pkg/tui/components/reasoningblock/reasoningblock.go b/pkg/tui/components/reasoningblock/reasoningblock.go index 4b70f515b8..2a1dbd2e70 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock.go +++ b/pkg/tui/components/reasoningblock/reasoningblock.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/markdown" "github.com/docker/docker-agent/pkg/tui/components/tool" "github.com/docker/docker-agent/pkg/tui/core/layout" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/styles" @@ -275,7 +276,8 @@ func (m *Model) UpdateToolResult(toolCallID, content string, status types.ToolSt entry.msg.Content = strings.ReplaceAll(content, "\t", " ") entry.msg.ToolStatus = status - entry.msg.ToolResult = result + entry.msg.ToolResult = result.WithoutPayload() + entry.msg.Images = tuiimage.FromToolResult(result) // Set grace period if transitioning from in-progress to completed // Total visible time = completedToolVisibleDuration + completedToolFadeDuration diff --git a/pkg/tui/components/tool/factory.go b/pkg/tui/components/tool/factory.go index ecc863c662..071127f3ef 100644 --- a/pkg/tui/components/tool/factory.go +++ b/pkg/tui/components/tool/factory.go @@ -110,13 +110,16 @@ func resolve(key string) (Builder, bool) { // Lookup order: exact tool name, then "category:", then default. // At each tier a registered custom renderer wins over the built-in one. func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { + var view layout.Model if b, ok := resolve(msg.ToolCall.Function.Name); ok { - return b(msg, sessionState) - } - if cat := msg.ToolDefinition.Category; cat != "" { + view = b(msg, sessionState) + } else if cat := msg.ToolDefinition.Category; cat != "" { if b, ok := resolve("category:" + cat); ok { - return b(msg, sessionState) + view = b(msg, sessionState) } } - return defaulttool.New(msg, sessionState) + if view == nil { + view = defaulttool.New(msg, sessionState) + } + return withInlineImages(view, msg.Images, sessionState) } diff --git a/pkg/tui/components/tool/factory_test.go b/pkg/tui/components/tool/factory_test.go index e8c1aa2f52..ea6d5dfc7a 100644 --- a/pkg/tui/components/tool/factory_test.go +++ b/pkg/tui/components/tool/factory_test.go @@ -1,6 +1,7 @@ package tool import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +11,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/plan" shelltool "github.com/docker/docker-agent/pkg/tools/builtin/shell" "github.com/docker/docker-agent/pkg/tui/core/layout" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/types" ) @@ -131,6 +133,23 @@ func TestNew_Dispatch(t *testing.T) { // renderer: the single-plan write/status tools do, while read_plan (shows the // full body), list_plans (many plans) and delete_plan (no status) intentionally // fall through to the default renderer. +func TestNewRendersResultImagesWithinToolWidth(t *testing.T) { + msg := &types.Message{ + ToolCall: tools.ToolCall{Function: tools.FunctionCall{Name: "image_tool"}}, + Images: []tuiimage.Inline{{ + Name: "screenshot.png", MIME: "image/png", PNGData: []byte("png"), Width: 1600, Height: 900, + }}, + } + + view := New(msg, service.StaticSessionState{}) + view.SetSize(40, 0) + rendered := view.View() + + assert.Contains(t, rendered, "cagent-image") + assert.Contains(t, rendered, ";36;", "image must stay inside the tool's available width") + assert.LessOrEqual(t, len(strings.Split(rendered, "\n")), 22, "image height must remain bounded") +} + func TestPlanToolsRouting(t *testing.T) { withCleanToolRegistry(t) diff --git a/pkg/tui/components/tool/inline_images.go b/pkg/tui/components/tool/inline_images.go new file mode 100644 index 0000000000..96e537a96f --- /dev/null +++ b/pkg/tui/components/tool/inline_images.go @@ -0,0 +1,79 @@ +package tool + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/core/layout" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" + "github.com/docker/docker-agent/pkg/tui/service" +) + +type inlineImagesModel struct { + model layout.Model + images []tuiimage.Inline + sessionState service.SessionStateReader + width int +} + +func withInlineImages(model layout.Model, images []tuiimage.Inline, sessionState service.SessionStateReader) layout.Model { + if len(images) == 0 { + return model + } + return &inlineImagesModel{model: model, images: images, sessionState: sessionState} +} + +func (m *inlineImagesModel) Init() tea.Cmd { + return m.model.Init() +} + +func (m *inlineImagesModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + model, cmd := m.model.Update(msg) + m.model = model + return m, cmd +} + +func (m *inlineImagesModel) SetSize(width, height int) tea.Cmd { + m.width = width + return m.model.SetSize(width, height) +} + +func (m *inlineImagesModel) View() string { + return m.render(m.model.View()) +} + +func (m *inlineImagesModel) ExpandedView() string { + if expanded, ok := m.model.(interface{ ExpandedView() string }); ok { + return m.render(expanded.ExpandedView()) + } + return m.View() +} + +func (m *inlineImagesModel) CollapsedView() string { + if collapsed, ok := m.model.(layout.CollapsedViewer); ok { + return collapsed.CollapsedView() + } + return m.model.View() +} + +func (m *inlineImagesModel) StopAnimation() { + animation.StopView(m.model) +} + +func (m *inlineImagesModel) render(content string) string { + if m.sessionState != nil && m.sessionState.HideToolResults() { + return content + } + parts := make([]string, 0, len(m.images)+1) + if content = strings.TrimRight(content, "\n"); content != "" { + parts = append(parts, content) + } + for _, img := range m.images { + if lines := tuiimage.RenderMarkers(img, m.width); len(lines) > 0 { + parts = append(parts, strings.Join(lines, "\n")) + } + } + return strings.Join(parts, "\n") +} diff --git a/pkg/tui/image/image.go b/pkg/tui/image/image.go new file mode 100644 index 0000000000..8047319aa7 --- /dev/null +++ b/pkg/tui/image/image.go @@ -0,0 +1,250 @@ +// Package image prepares and renders images for terminal display. +package image + +import ( + "bytes" + "container/list" + "encoding/base64" + "fmt" + "hash/fnv" + stdimage "image" + _ "image/gif" + "image/png" + "strings" + "sync" + "sync/atomic" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +const ( + kittyMaxChunkSize = 4096 + maxImageCols = 60 + maxImageRows = 20 + maxInlineRegistryEntries = 128 + markerPrefix = "\x1b_cagent-image;" +) + +type registryEntry struct { + id uint32 + image Inline +} + +var ( + inlineRegistry = struct { + sync.Mutex + + entries map[uint32]*list.Element + order list.List + }{entries: make(map[uint32]*list.Element)} + renderingDisabled atomic.Bool +) + +// SetRenderingEnabled controls whether the full-screen TUI reserves image rows. +func SetRenderingEnabled(enabled bool) { + renderingDisabled.Store(!enabled) +} + +// Inline is a PNG image prepared for inline kitty-protocol rendering. +type Inline struct { + Name string + MIME string + PNGData []byte + Width int + Height int +} + +// FromToolResult extracts displayable images from a tool result. +func FromToolResult(result *tools.ToolCallResult) []Inline { + if result == nil { + return nil + } + + images := make([]Inline, 0, len(result.Images)+len(result.Documents)) + for i, img := range result.Images { + name := fmt.Sprintf("image-%d", i+1) + if inline, ok := FromBase64(name, img.MimeType, img.Data); ok { + images = append(images, inline) + } + } + for _, doc := range result.Documents { + if !chat.IsImageMimeType(doc.MimeType) || doc.Data == "" { + continue + } + name := doc.Name + if name == "" { + name = "image" + } + if inline, ok := FromBase64(name, doc.MimeType, doc.Data); ok { + images = append(images, inline) + } + } + return images +} + +// FromBase64 decodes and normalizes one base64-encoded image. +func FromBase64(name, mimeType, encoded string) (Inline, bool) { + if strings.TrimSpace(encoded) == "" { + return Inline{}, false + } + + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return Inline{}, false + } + if mimeType == "" { + mimeType = chat.DetectMimeTypeByContent(data) + } + if !chat.IsImageMimeType(mimeType) { + return Inline{}, false + } + if resized, resizeErr := chat.ResizeImage(data, mimeType); resizeErr == nil { + data = resized.Data + mimeType = resized.MimeType + } + + img, _, err := stdimage.Decode(bytes.NewReader(data)) + if err != nil { + return Inline{}, false + } + bounds := img.Bounds() + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + return Inline{}, false + } + + return Inline{ + Name: name, + MIME: mimeType, + PNGData: pngBuf.Bytes(), + Width: bounds.Dx(), + Height: bounds.Dy(), + }, true +} + +// Render returns a label, kitty image sequence, and the rows reserved by it. +func Render(img Inline, width int) []string { + if len(img.PNGData) == 0 || img.Width <= 0 || img.Height <= 0 || width <= 0 { + return nil + } + + cols, rows := cellSize(img, width) + + label := "image" + if img.Name != "" { + label = img.Name + } + if img.MIME != "" { + label += " (" + img.MIME + ")" + } + + out := []string{" " + styles.MutedStyle.Render("🖼 "+label)} + out = append(out, " "+KittySequence(img.PNGData, cols, rows)) + for range rows - 1 { + out = append(out, "") + } + return out +} + +// RenderMarkers renders a compact marker on every occupied row. The normal +// TUI resolves these after viewport clipping, which lets it crop images that +// are partially scrolled off-screen without copying PNG data into every row. +func RenderMarkers(img Inline, width int) []string { + if renderingDisabled.Load() || len(img.PNGData) == 0 || img.Width <= 0 || img.Height <= 0 || width <= 0 { + return nil + } + cols, rows := cellSize(img, width) + id := registerImage(imageID(img.PNGData), img) + + out := make([]string, 0, rows) + for row := range rows { + out = append(out, fmt.Sprintf(" %s%d;%d;%d;%d\x1b\\", markerPrefix, id, cols, rows, row)) + } + return out +} + +func cellSize(img Inline, width int) (cols, rows int) { + cols = min(max(width-4, 1), maxImageCols) + rows = max(1, (img.Height*cols+2*img.Width-1)/(2*img.Width)) + if rows <= maxImageRows { + return cols, rows + } + + // Shrink both dimensions together. Capping rows alone stretches tall and + // square images horizontally because terminal cells are roughly twice as + // tall as they are wide. + cols = min(cols, max(1, maxImageRows*2*img.Width/img.Height)) + rows = min(maxImageRows, max(1, (img.Height*cols+2*img.Width-1)/(2*img.Width))) + return cols, rows +} + +func imageID(data []byte) uint32 { + h := fnv.New32a() + _, _ = h.Write(data) + if id := h.Sum32(); id != 0 { + return id + } + return 1 +} + +func registerImage(id uint32, img Inline) uint32 { + inlineRegistry.Lock() + defer inlineRegistry.Unlock() + for { + if elem, ok := inlineRegistry.entries[id]; ok { + entry := elem.Value.(*registryEntry) + if bytes.Equal(entry.image.PNGData, img.PNGData) { + entry.image = img + inlineRegistry.order.MoveToFront(elem) + return id + } + id++ + if id == 0 { + id = 1 + } + continue + } + + elem := inlineRegistry.order.PushFront(®istryEntry{id: id, image: img}) + inlineRegistry.entries[id] = elem + if inlineRegistry.order.Len() > maxInlineRegistryEntries { + oldest := inlineRegistry.order.Back() + inlineRegistry.order.Remove(oldest) + delete(inlineRegistry.entries, oldest.Value.(*registryEntry).id) + } + return id + } +} + +func registeredImage(id uint32) (Inline, bool) { + inlineRegistry.Lock() + defer inlineRegistry.Unlock() + elem, ok := inlineRegistry.entries[id] + if !ok { + return Inline{}, false + } + inlineRegistry.order.MoveToFront(elem) + return elem.Value.(*registryEntry).image, true +} + +// KittySequence encodes PNG data as a kitty graphics escape sequence. +func KittySequence(pngData []byte, cols, rows int) string { + encoded := base64.StdEncoding.EncodeToString(pngData) + var b strings.Builder + for offset := 0; offset < len(encoded); offset += kittyMaxChunkSize { + end := min(offset+kittyMaxChunkSize, len(encoded)) + more := 0 + if end < len(encoded) { + more = 1 + } + if offset == 0 { + fmt.Fprintf(&b, "\x1b_Ga=T,t=d,f=100,q=2,C=1,c=%d,r=%d,m=%d;%s\x1b\\", cols, rows, more, encoded[offset:end]) + } else { + fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, encoded[offset:end]) + } + } + return b.String() +} diff --git a/pkg/tui/image/image_test.go b/pkg/tui/image/image_test.go new file mode 100644 index 0000000000..60fb22c2ed --- /dev/null +++ b/pkg/tui/image/image_test.go @@ -0,0 +1,70 @@ +package image + +import ( + "container/list" + "testing" + + "github.com/stretchr/testify/assert" +) + +func resetInlineRegistry() { + inlineRegistry.Lock() + defer inlineRegistry.Unlock() + inlineRegistry.entries = make(map[uint32]*list.Element) + inlineRegistry.order.Init() +} + +func TestInlineRegistryIsBounded(t *testing.T) { + resetInlineRegistry() + defer resetInlineRegistry() + + for id := uint32(1); id <= maxInlineRegistryEntries+1; id++ { + registerImage(id, Inline{PNGData: []byte{byte(id)}}) + } + _, oldestPresent := registeredImage(1) + newest, newestPresent := registeredImage(maxInlineRegistryEntries + 1) + assert.False(t, oldestPresent) + assert.True(t, newestPresent) + assert.Equal(t, []byte{byte(maxInlineRegistryEntries + 1)}, newest.PNGData) +} + +func TestInlineRegistryResolvesIDCollisions(t *testing.T) { + resetInlineRegistry() + defer resetInlineRegistry() + + firstID := registerImage(42, Inline{PNGData: []byte("first")}) + secondID := registerImage(42, Inline{PNGData: []byte("second")}) + assert.Equal(t, uint32(42), firstID) + assert.Equal(t, uint32(43), secondID) + + first, ok := registeredImage(firstID) + assert.True(t, ok) + assert.Equal(t, []byte("first"), first.PNGData) + second, ok := registeredImage(secondID) + assert.True(t, ok) + assert.Equal(t, []byte("second"), second.PNGData) +} + +func TestRenderMarkersDisabledDoesNotReserveRows(t *testing.T) { + SetRenderingEnabled(false) + defer SetRenderingEnabled(true) + + markers := RenderMarkers(Inline{PNGData: []byte("png"), Width: 10, Height: 10}, 40) + assert.Nil(t, markers) +} + +func TestCellSizePreservesAspectRatioWhenHeightIsCapped(t *testing.T) { + t.Parallel() + + cols, rows := cellSize(Inline{Width: 1000, Height: 1000}, 100) + assert.Equal(t, 40, cols) + assert.Equal(t, 20, rows) +} + +func TestCellSizeUsesAvailableWidthForWideImage(t *testing.T) { + t.Parallel() + + cols, rows := cellSize(Inline{Width: 1600, Height: 900}, 100) + assert.Equal(t, 60, cols) + assert.Equal(t, 17, rows) +} diff --git a/pkg/tui/image/probe.go b/pkg/tui/image/probe.go new file mode 100644 index 0000000000..9fa5a9961b --- /dev/null +++ b/pkg/tui/image/probe.go @@ -0,0 +1,62 @@ +package image + +import ( + "bytes" + "os" + "time" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +const ( + kittyProbeID = "4242" + kittyProbeTimeout = 300 * time.Millisecond + kittyProbeQuery = "\x1b_Gi=" + kittyProbeID + ",a=q,t=d,f=24,s=1,v=1;AAAA\x1b\\" + kittyProbeOK = "\x1b_Gi=" + kittyProbeID + ";OK\x1b\\" +) + +// SupportsKittyGraphics probes a terminal for Kitty graphics support. It must +// run before the TUI takes ownership of the terminal input stream. +func SupportsKittyGraphics(in, out *os.File) bool { + if in == nil || out == nil || !isatty.IsTerminal(in.Fd()) || !isatty.IsTerminal(out.Fd()) { + return false + } + + state, err := term.MakeRaw(int(in.Fd())) + if err != nil { + return false + } + defer func() { _ = term.Restore(int(in.Fd()), state) }() + + reader, err := uv.NewCancelReader(in) + if err != nil { + return false + } + defer reader.Close() + + timer := time.AfterFunc(kittyProbeTimeout, func() { reader.Cancel() }) + defer timer.Stop() + if _, err := out.WriteString(kittyProbeQuery); err != nil { + return false + } + + response := make([]byte, 0, 128) + buf := make([]byte, 128) + for { + n, err := reader.Read(buf) + if n > 0 { + response = append(response, buf[:n]...) + if bytes.Contains(response, []byte(kittyProbeOK)) { + return true + } + if len(response) > 1024 { + response = response[len(response)-512:] + } + } + if err != nil { + return false + } + } +} diff --git a/pkg/tui/image/probe_test.go b/pkg/tui/image/probe_test.go new file mode 100644 index 0000000000..278ca97062 --- /dev/null +++ b/pkg/tui/image/probe_test.go @@ -0,0 +1,64 @@ +//go:build !windows + +package image + +import ( + "bytes" + "os" + "testing" + "time" + + "github.com/creack/pty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSupportsKittyGraphics(t *testing.T) { + t.Parallel() + + t.Run("non-terminal", func(t *testing.T) { + file, err := os.Open(os.DevNull) + require.NoError(t, err) + defer file.Close() + + start := time.Now() + assert.False(t, SupportsKittyGraphics(file, file)) + assert.Less(t, time.Since(start), kittyProbeTimeout) + }) + + t.Run("supported", func(t *testing.T) { + ptmx, tty, err := pty.Open() + require.NoError(t, err) + defer ptmx.Close() + defer tty.Close() + + go answerKittyProbe(ptmx, kittyProbeOK) + assert.True(t, SupportsKittyGraphics(tty, tty)) + }) + + t.Run("unsupported", func(t *testing.T) { + ptmx, tty, err := pty.Open() + require.NoError(t, err) + defer ptmx.Close() + defer tty.Close() + + go answerKittyProbe(ptmx, "\x1b_Gi="+kittyProbeID+";ENOTSUP\x1b\\") + assert.False(t, SupportsKittyGraphics(tty, tty)) + }) +} + +func answerKittyProbe(terminal *os.File, response string) { + var seen []byte + buf := make([]byte, 128) + for { + n, err := terminal.Read(buf) + if err != nil { + return + } + seen = append(seen, buf[:n]...) + if bytes.Contains(seen, []byte(kittyProbeQuery)) { + _, _ = terminal.WriteString(response) + return + } + } +} diff --git a/pkg/tui/image/writer.go b/pkg/tui/image/writer.go new file mode 100644 index 0000000000..607f95c507 --- /dev/null +++ b/pkg/tui/image/writer.go @@ -0,0 +1,302 @@ +package image + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi" +) + +type overlay struct { + id uint32 + png []byte + x, y int + cols, rows int + pixelW, pixelH int + sourceY, sourceH int +} + +// Writer adds kitty graphics after Bubble Tea has rendered its text cell buffer. +// Bubble Tea intentionally consumes APC sequences while parsing view content, so +// images must be overlaid on the completed frame instead. +type Writer struct { + out io.Writer + + mu sync.Mutex + overlays []overlay + uploaded map[uint32]bool + active bool + dirty bool + enabled bool + supported bool +} + +func NewWriter(out io.Writer) *Writer { + return &Writer{out: out, uploaded: make(map[uint32]bool), enabled: true, supported: true} +} + +// Fd, Read, and Close preserve the terminal file interface when Writer wraps +// stdout. Bubble Tea uses that interface to detect the output TTY. +func (w *Writer) Fd() uintptr { + if file, ok := w.out.(interface{ Fd() uintptr }); ok { + return file.Fd() + } + return ^uintptr(0) +} + +func (w *Writer) Read(p []byte) (int, error) { + if reader, ok := w.out.(io.Reader); ok { + return reader.Read(p) + } + return 0, io.EOF +} + +func (w *Writer) Close() error { + return nil +} + +// SetSupported records whether the terminal answered the Kitty graphics probe. +func (w *Writer) SetSupported(supported bool) { + w.mu.Lock() + defer w.mu.Unlock() + if w.supported != supported { + w.supported = supported + w.dirty = true + } +} + +// RenderingEnabled reports whether both the user setting and terminal support allow images. +func (w *Writer) RenderingEnabled() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.enabled && w.supported +} + +// SetEnabled controls whether image markers become terminal overlays. +func (w *Writer) SetEnabled(enabled bool) { + w.mu.Lock() + defer w.mu.Unlock() + if w.enabled != enabled { + w.enabled = enabled + w.dirty = true + } +} + +// Invalidate forces image data and placements to be rebuilt after the terminal +// clears graphics state, such as during a resize or terminal restore. +func (w *Writer) Invalidate() { + w.mu.Lock() + defer w.mu.Unlock() + clear(w.uploaded) + w.active = false + w.dirty = true +} + +func (w *Writer) SetContent(content string) string { + clean, overlays := extractOverlays(content) + w.mu.Lock() + defer w.mu.Unlock() + if !w.enabled || !w.supported { + overlays = nil + } + if !sameOverlays(w.overlays, overlays) { + w.overlays = overlays + w.dirty = true + } + return clean +} + +func (w *Writer) Write(p []byte) (int, error) { + cleared := bytes.Contains(p, []byte("\x1b[2J")) || bytes.Contains(p, []byte("\x1b[?1049h")) + n, err := w.out.Write(p) + if err != nil { + return n, err + } + + w.mu.Lock() + defer w.mu.Unlock() + if cleared { + clear(w.uploaded) + w.dirty = true + w.active = false + } + if len(w.overlays) == 0 && !w.active { + return n, nil + } + + wasDirty := w.dirty + newUploads := make([]uint32, 0, len(w.overlays)) + var b strings.Builder + b.WriteString("\x1b7") + if wasDirty { + b.WriteString("\x1b_Ga=d,d=a,q=2\x1b\\") + } + for index, image := range w.overlays { + if !w.uploaded[image.id] { + writeTransmission(&b, image.id, image.png) + newUploads = append(newUploads, image.id) + } + fmt.Fprintf(&b, "\x1b[%d;%dH", image.y+1, image.x+1) + fmt.Fprintf(&b, "\x1b_Ga=p,i=%d,p=%d,q=2,C=1,c=%d,r=%d", image.id, index+1, image.cols, image.rows) + if image.sourceY > 0 || image.sourceH < image.pixelH { + fmt.Fprintf(&b, ",x=0,y=%d,w=%d,h=%d", image.sourceY, image.pixelW, image.sourceH) + } + b.WriteString("\x1b\\") + } + b.WriteString("\x1b8") + overlay := b.String() + written, writeErr := io.WriteString(w.out, overlay) + if writeErr == nil && written != len(overlay) { + writeErr = io.ErrShortWrite + } + if writeErr != nil { + return n, writeErr + } + + w.dirty = false + w.active = len(w.overlays) > 0 + for _, id := range newUploads { + w.uploaded[id] = true + } + return n, nil +} + +func writeTransmission(b *strings.Builder, id uint32, png []byte) { + encoded := base64.StdEncoding.EncodeToString(png) + for offset := 0; offset < len(encoded); offset += kittyMaxChunkSize { + end := min(offset+kittyMaxChunkSize, len(encoded)) + more := 0 + if end < len(encoded) { + more = 1 + } + if offset == 0 { + fmt.Fprintf(b, "\x1b_Ga=t,t=d,f=100,i=%d,q=2,m=%d;%s\x1b\\", id, more, encoded[offset:end]) + } else { + fmt.Fprintf(b, "\x1b_Gm=%d;%s\x1b\\", more, encoded[offset:end]) + } + } +} + +func sameOverlays(a, b []overlay) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].id != b[i].id || a[i].x != b[i].x || a[i].y != b[i].y || + a[i].cols != b[i].cols || a[i].rows != b[i].rows || + a[i].pixelW != b[i].pixelW || a[i].pixelH != b[i].pixelH || + a[i].sourceY != b[i].sourceY || a[i].sourceH != b[i].sourceH { + return false + } + } + return true +} + +func extractOverlays(content string) (string, []overlay) { + lines := strings.Split(content, "\n") + overlays := extractMarkerOverlays(lines) + for y, line := range lines { + for { + start := strings.Index(line, "\x1b_G") + if start < 0 { + break + } + x := ansi.StringWidth(line[:start]) + var encoded strings.Builder + cols, rows := 0, 0 + end := start + for strings.HasPrefix(line[end:], "\x1b_G") { + stop := strings.Index(line[end+3:], "\x1b\\") + if stop < 0 { + end += 3 + break + } + stop += end + 3 + body := line[end+3 : stop] + params, payload, _ := strings.Cut(body, ";") + for param := range strings.SplitSeq(params, ",") { + key, value, ok := strings.Cut(param, "=") + if !ok { + continue + } + switch key { + case "c": + cols, _ = strconv.Atoi(value) + case "r": + rows, _ = strconv.Atoi(value) + } + } + encoded.WriteString(payload) + end = stop + 2 + } + data, err := base64.StdEncoding.DecodeString(encoded.String()) + if err == nil && len(data) > 0 && cols > 0 && rows > 0 { + id := imageID(data) + overlays = append(overlays, overlay{id: id, png: data, x: x, y: y, cols: cols, rows: rows}) + } + line = line[:start] + line[end:] + } + lines[y] = line + } + return strings.Join(lines, "\n"), overlays +} + +func extractMarkerOverlays(lines []string) []overlay { + var overlays []overlay + for y, line := range lines { + for { + start := strings.Index(line, markerPrefix) + if start < 0 { + break + } + stopRel := strings.Index(line[start+len(markerPrefix):], "\x1b\\") + if stopRel < 0 { + line = line[:start] + line[start+len(markerPrefix):] + continue + } + stop := start + len(markerPrefix) + stopRel + fields := strings.Split(line[start+len(markerPrefix):stop], ";") + if len(fields) == 4 { + id64, idErr := strconv.ParseUint(fields[0], 10, 32) + cols, colsErr := strconv.Atoi(fields[1]) + totalRows, rowsErr := strconv.Atoi(fields[2]) + row, rowErr := strconv.Atoi(fields[3]) + img, ok := registeredImage(uint32(id64)) + if idErr == nil && colsErr == nil && rowsErr == nil && rowErr == nil && ok && totalRows > 0 { + x := ansi.StringWidth(line[:start]) + if len(overlays) > 0 { + last := &overlays[len(overlays)-1] + lastEndRow := last.sourceY + last.sourceH + expectedSourceY := img.Height * row / totalRows + if uint64(last.id) == id64 && last.x == x && last.y+last.rows == y && lastEndRow == expectedSourceY { + last.rows++ + last.sourceH = img.Height*(row+1)/totalRows - last.sourceY + } else { + overlays = append(overlays, markerOverlay(uint32(id64), img, x, y, cols, totalRows, row)) + } + } else { + overlays = append(overlays, markerOverlay(uint32(id64), img, x, y, cols, totalRows, row)) + } + } + } + line = line[:start] + line[stop+2:] + } + lines[y] = line + } + return overlays +} + +func markerOverlay(id uint32, img Inline, x, y, cols, totalRows, row int) overlay { + sourceY := img.Height * row / totalRows + sourceEnd := img.Height * (row + 1) / totalRows + return overlay{ + id: id, png: img.PNGData, x: x, y: y, cols: cols, rows: 1, + pixelW: img.Width, pixelH: img.Height, + sourceY: sourceY, sourceH: sourceEnd - sourceY, + } +} diff --git a/pkg/tui/image/writer_test.go b/pkg/tui/image/writer_test.go new file mode 100644 index 0000000000..7d27e61d31 --- /dev/null +++ b/pkg/tui/image/writer_test.go @@ -0,0 +1,141 @@ +package image + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type failSecondWrite struct { + buf bytes.Buffer + writes int + failed bool +} + +func (w *failSecondWrite) Write(p []byte) (int, error) { + w.writes++ + if w.writes == 2 && !w.failed { + w.failed = true + return 0, errors.New("terminal write failed") + } + return w.buf.Write(p) +} + +func (w *failSecondWrite) Reset() { + w.buf.Reset() +} + +func (w *failSecondWrite) String() string { + return w.buf.String() +} + +func TestWriterRetriesImageStateAfterOverlayWriteFailure(t *testing.T) { + output := &failSecondWrite{} + writer := NewWriter(output) + writer.SetContent(KittySequence([]byte("png-data"), 20, 10)) + + _, err := writer.Write([]byte("first frame")) + require.Error(t, err) + assert.Empty(t, writer.uploaded) + assert.True(t, writer.dirty) + assert.False(t, writer.active) + + output.Reset() + _, err = writer.Write([]byte("second frame")) + require.NoError(t, err) + assert.Contains(t, output.String(), "a=t,t=d", "failed image upload must be retried") + assert.Contains(t, output.String(), "a=d,d=a", "failed placement reset must be retried") +} + +func TestWriterExtractsAndOverlaysKittyImage(t *testing.T) { + png := []byte("png-data") + content := "header\n " + KittySequence(png, 20, 10) + "\n" + var output bytes.Buffer + writer := NewWriter(&output) + + clean := writer.SetContent(content) + require.NotContains(t, clean, "\x1b_G") + assert.Equal(t, "header\n \n", clean) + + _, err := writer.Write([]byte("frame")) + require.NoError(t, err) + rendered := output.String() + assert.True(t, strings.HasPrefix(rendered, "frame")) + assert.Contains(t, rendered, "a=t,t=d,f=100") + assert.Contains(t, rendered, "\x1b[2;5H") + assert.Contains(t, rendered, "a=p,i=") + assert.Contains(t, rendered, "c=20,r=10") +} + +func TestWriterOnlyUploadsUnchangedImageOnce(t *testing.T) { + var output bytes.Buffer + writer := NewWriter(&output) + writer.SetContent(KittySequence([]byte("png-data"), 20, 10)) + _, err := writer.Write([]byte("first")) + require.NoError(t, err) + + output.Reset() + writer.SetContent(KittySequence([]byte("png-data"), 20, 10)) + _, err = writer.Write([]byte("second")) + require.NoError(t, err) + assert.NotContains(t, output.String(), "a=t,t=d", "image data should remain cached") + assert.Contains(t, output.String(), "a=p", "placement is refreshed after terminal text updates") +} + +func TestMarkerOverlayCropsScrolledImage(t *testing.T) { + img := Inline{Name: "test", PNGData: []byte("png-data"), Width: 100, Height: 100} + lines := RenderMarkers(img, 24) + require.GreaterOrEqual(t, len(lines), 8) + + clean, overlays := extractOverlays(strings.Join(lines[4:8], "\n")) + require.Len(t, overlays, 1) + assert.NotContains(t, clean, markerPrefix) + assert.Equal(t, 0, overlays[0].y) + assert.Equal(t, 4, overlays[0].rows) + assert.Positive(t, overlays[0].sourceY) + assert.Less(t, overlays[0].sourceH, img.Height) +} + +func TestWriterUnsupportedTerminalStripsMarkersWithoutRendering(t *testing.T) { + img := Inline{PNGData: []byte("png-data"), Width: 100, Height: 100} + var output bytes.Buffer + writer := NewWriter(&output) + writer.SetSupported(false) + + clean := writer.SetContent(strings.Join(RenderMarkers(img, 40), "\n")) + assert.NotContains(t, clean, markerPrefix) + _, err := writer.Write([]byte("frame")) + require.NoError(t, err) + assert.Equal(t, "frame", output.String()) +} + +func TestWriterDisabledStripsMarkersWithoutRendering(t *testing.T) { + img := Inline{PNGData: []byte("png-data"), Width: 100, Height: 100} + var output bytes.Buffer + writer := NewWriter(&output) + writer.SetEnabled(false) + + clean := writer.SetContent(strings.Join(RenderMarkers(img, 40), "\n")) + assert.NotContains(t, clean, markerPrefix) + _, err := writer.Write([]byte("frame")) + require.NoError(t, err) + assert.Equal(t, "frame", output.String()) +} + +func TestWriterDeletesPlacementWhenImageLeavesView(t *testing.T) { + var output bytes.Buffer + writer := NewWriter(&output) + writer.SetContent(KittySequence([]byte("png-data"), 20, 10)) + _, err := writer.Write([]byte("first")) + require.NoError(t, err) + + output.Reset() + writer.SetContent("no image") + _, err = writer.Write([]byte("second")) + require.NoError(t, err) + assert.Contains(t, output.String(), "a=d,d=a") +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 1d68071997..85e8a61711 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -38,6 +38,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/tour" "github.com/docker/docker-agent/pkg/tui/core" "github.com/docker/docker-agent/pkg/tui/dialog" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/internal/editorname" "github.com/docker/docker-agent/pkg/tui/internal/termfeatures" "github.com/docker/docker-agent/pkg/tui/messages" @@ -257,6 +258,8 @@ type appModel struct { // disabledCommands holds slash commands to hide and disable. // Normalized to start with "/". disabledCommands map[string]bool + + imageWriter *tuiimage.Writer } // themeFileWatcher is the subset of *styles.ThemeWatcher the model drives. @@ -297,6 +300,13 @@ func WithHideSidebar() Option { } } +// WithImageWriter enables image overlays for Bubble Tea's cell-based renderer. +func WithImageWriter(writer *tuiimage.Writer) Option { + return func(m *appModel) { + m.imageWriter = writer + } +} + // tourMode selects the getting-started tour behavior at startup. type tourMode int @@ -890,6 +900,9 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { // --- Window / Terminal --- case tea.WindowSizeMsg: + if m.imageWriter != nil { + m.imageWriter.Invalidate() + } m.wWidth, m.wHeight = msg.Width, msg.Height cmd := m.handleWindowResize(msg.Width, msg.Height) return m, cmd @@ -2836,10 +2849,17 @@ func (m *appModel) View() tea.View { } compositor := lipgloss.NewCompositor(allLayers...) - return toFullscreenView(compositor.Render(), windowTitle, m.chatPage.IsWorking(), m.leanMode) + return m.fullscreenView(compositor.Render(), windowTitle) } - return toFullscreenView(baseView, windowTitle, m.chatPage.IsWorking(), m.leanMode) + return m.fullscreenView(baseView, windowTitle) +} + +func (m *appModel) fullscreenView(content, windowTitle string) tea.View { + if m.imageWriter != nil { + content = m.imageWriter.SetContent(content) + } + return toFullscreenView(content, windowTitle, m.chatPage.IsWorking(), m.leanMode) } // windowTitle returns the terminal window title for the current model state. diff --git a/pkg/tui/types/types.go b/pkg/tui/types/types.go index 46f3084275..68dbc6ebec 100644 --- a/pkg/tui/types/types.go +++ b/pkg/tui/types/types.go @@ -5,6 +5,7 @@ import ( "time" "github.com/docker/docker-agent/pkg/tools" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" ) // MessageType represents different types of messages @@ -70,6 +71,7 @@ type Message struct { ToolDefinition tools.Tool // Definition of the tool being called ToolStatus ToolStatus // Status for tool calls ToolResult *tools.ToolCallResult // Result of tool call (when completed) + Images []tuiimage.Inline // Prepared terminal images from the result // StartedAt records when a tool call entered ToolStatusRunning. // Used to display elapsed time for long-running tool calls. StartedAt *time.Time From ddf95cd925a3dc3bf5b03741dfd122a7a2b5d762 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 16 Jul 2026 23:54:06 +0200 Subject: [PATCH 2/3] feat(settings): add image rendering preference Signed-off-by: Djordje Lukic --- cmd/root/new.go | 3 +++ cmd/root/run.go | 3 +++ pkg/leantui/events.go | 6 +++++- pkg/leantui/leantui.go | 5 +++++ pkg/tui/dialog/settings.go | 6 +++++- pkg/tui/dialog/settings_test.go | 6 +++--- pkg/tui/handlers.go | 9 ++++++++- pkg/tui/messages/settings.go | 1 + pkg/userconfig/userconfig.go | 11 +++++++++++ pkg/userconfig/userconfig_test.go | 15 +++++++++++++++ 10 files changed, 59 insertions(+), 6 deletions(-) diff --git a/cmd/root/new.go b/cmd/root/new.go index d014ce6e97..f11e9795a2 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -20,6 +20,7 @@ import ( tuiimage "github.com/docker/docker-agent/pkg/tui/image" tuiinput "github.com/docker/docker-agent/pkg/tui/input" "github.com/docker/docker-agent/pkg/tui/styles" + "github.com/docker/docker-agent/pkg/userconfig" ) type newFlags struct { @@ -134,6 +135,8 @@ func runTUIWrapped(ctx context.Context, rt runtime.Runtime, sess *session.Sessio } imageWriter := tuiimage.NewWriter(os.Stdout) imageWriter.SetSupported(tuiimage.SupportsKittyGraphics(os.Stdin, os.Stdout)) + imageWriter.SetEnabled(userconfig.Get().GetRenderImages()) + tuiimage.SetRenderingEnabled(imageWriter.RenderingEnabled()) tuiOpts = append(tuiOpts, tui.WithImageWriter(imageWriter)) model := tui.New(ctx, spawner, a, wd, cleanup, tuiOpts...) if wrap != nil { diff --git a/cmd/root/run.go b/cmd/root/run.go index affb9dba33..7ef73f0c70 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -36,6 +36,7 @@ import ( "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/tour" "github.com/docker/docker-agent/pkg/tui" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/recorder" "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/userconfig" @@ -1030,6 +1031,7 @@ func (f *runExecFlags) runLeanTUI(ctx context.Context, rt runtime.Runtime, sess if wd == "" { wd, _ = os.Getwd() } + renderImages := userconfig.Get().GetRenderImages() && tuiimage.SupportsKittyGraphics(os.Stdin, os.Stdout) return leantui.Run(ctx, leantui.Config{ App: a, WorkingDir: wd, @@ -1039,6 +1041,7 @@ func (f *runExecFlags) runLeanTUI(ctx context.Context, rt runtime.Runtime, sess QueuedMessages: queued, AppName: f.appName, DisabledCommands: f.disabledCommands, + RenderImages: &renderImages, }) } diff --git a/pkg/leantui/events.go b/pkg/leantui/events.go index 95c37cc12e..e315af677a 100644 --- a/pkg/leantui/events.go +++ b/pkg/leantui/events.go @@ -55,7 +55,11 @@ func (m *model) handleEvent(ctx context.Context, ev any) { } } case *runtime.ToolCallResponseEvent: - m.screen.Transcript.FinishTool(e.ToolCallID, ui.ToolResult{Response: e.Response, Result: e.Result, AgentName: e.GetAgentName(), ToolDefinition: e.ToolDefinition, Images: inlineImagesFromToolResult(e.Result)}, m.sessionState) + var images []ui.InlineImage + if m.renderImages { + images = inlineImagesFromToolResult(e.Result) + } + m.screen.Transcript.FinishTool(e.ToolCallID, ui.ToolResult{Response: e.Response, Result: e.Result, AgentName: e.GetAgentName(), ToolDefinition: e.ToolDefinition, Images: images}, m.sessionState) case *runtime.ToolCallConfirmationEvent: m.screen.Transcript.RemoveTool(ui.ToolViewID(e.ToolCall)) toolDef := ui.EnsureToolDefinition(e.ToolCall, e.ToolDefinition) diff --git a/pkg/leantui/leantui.go b/pkg/leantui/leantui.go index 37090c8a91..d448f454bc 100644 --- a/pkg/leantui/leantui.go +++ b/pkg/leantui/leantui.go @@ -27,6 +27,7 @@ type Config struct { AppName string DisabledCommands []string + RenderImages *bool // Banner overrides the ASCII-art welcome banner. When nil the built-in // bannerLines ("docker agent") is used; embedders set it to brand the lean @@ -166,6 +167,7 @@ type model struct { appName string banner []string disabledCommands map[string]bool + renderImages bool } func newModel(term *ui.Terminal, cfg Config) *model { @@ -184,6 +186,8 @@ func newModel(term *ui.Terminal, cfg Config) *model { sessionState = service.NewSessionState(cfg.App.Session()) } + renderImages := cfg.RenderImages == nil || *cfg.RenderImages + return &model{ app: cfg.App, term: term, @@ -197,6 +201,7 @@ func newModel(term *ui.Terminal, cfg Config) *model { appName: appName, banner: cfg.Banner, disabledCommands: disabled, + renderImages: renderImages, } } diff --git a/pkg/tui/dialog/settings.go b/pkg/tui/dialog/settings.go index dd4a088fea..ec2011b117 100644 --- a/pkg/tui/dialog/settings.go +++ b/pkg/tui/dialog/settings.go @@ -43,6 +43,7 @@ const ( rowSplitDiff rowExpandThinking rowHideToolResults + rowRenderImages appearanceRowCount ) @@ -232,6 +233,8 @@ func (d *settingsDialog) changeValue(delta int) tea.Cmd { d.current.ExpandThinking = !d.current.ExpandThinking case rowHideToolResults: d.current.HideToolResults = !d.current.HideToolResults + case rowRenderImages: + d.current.RenderImages = !d.current.RenderImages } if d.selected[d.tab] >= rowPosition && d.selected[d.tab] <= rowTodos { return core.CmdHandler(messages.PreviewLayoutMsg{Layout: d.current.Layout}) @@ -361,7 +364,8 @@ func (d *settingsDialog) renderAppearanceTab(content *Content, inner int) { content.AddSpace(). AddContent(d.renderToggleRow(rowSplitDiff, "Split diff view", d.current.SplitDiffView)). AddContent(d.renderToggleRow(rowExpandThinking, "Expand thinking by default", d.current.ExpandThinking)). - AddContent(d.renderToggleRow(rowHideToolResults, "Hide tool results by default", d.current.HideToolResults)) + AddContent(d.renderToggleRow(rowHideToolResults, "Hide tool results by default", d.current.HideToolResults)). + AddContent(d.renderToggleRow(rowRenderImages, "Render images", d.current.RenderImages)) } func (d *settingsDialog) renderBehaviorTab(content *Content, inner int) { diff --git a/pkg/tui/dialog/settings_test.go b/pkg/tui/dialog/settings_test.go index f1621865da..437e10a80a 100644 --- a/pkg/tui/dialog/settings_test.go +++ b/pkg/tui/dialog/settings_test.go @@ -13,7 +13,7 @@ import ( func newTestSettingsDialog(t *testing.T, layout messages.LayoutSettings) *settingsDialog { t.Helper() - d, ok := NewSettingsDialog(messages.Preferences{Layout: layout, SendMode: messages.SendModeSteer, SplitDiffView: true, TabTitleMaxLength: 20, SoundThreshold: 10}, true).(*settingsDialog) + d, ok := NewSettingsDialog(messages.Preferences{Layout: layout, SendMode: messages.SendModeSteer, SplitDiffView: true, RenderImages: true, TabTitleMaxLength: 20, SoundThreshold: 10}, true).(*settingsDialog) require.True(t, ok) d.Init() d.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) @@ -52,10 +52,10 @@ func TestSettingsDialogNavigation(t *testing.T) { for range 20 { d.Update(down) } - require.Equal(t, rowHideToolResults, d.selected[tabAppearance], "down must stop at the last row") + require.Equal(t, rowRenderImages, d.selected[tabAppearance], "down must stop at the last row") d.Update(up) - require.Equal(t, rowExpandThinking, d.selected[tabAppearance]) + require.Equal(t, rowHideToolResults, d.selected[tabAppearance]) } func TestSettingsDialogTabSwitching(t *testing.T) { diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index c0e6309e38..15a5d24221 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -28,6 +28,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/tool/editfile" "github.com/docker/docker-agent/pkg/tui/core" "github.com/docker/docker-agent/pkg/tui/dialog" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/styles" @@ -881,6 +882,7 @@ func (m *appModel) handleOpenSettingsDialog() (tea.Model, tea.Cmd) { SplitDiffView: settings.GetSplitDiffView(), ExpandThinking: settings.GetExpandThinking(), HideToolResults: settings.HideToolResults, + RenderImages: settings.GetRenderImages(), YOLO: settings.YOLO, RestoreTabs: settings.GetRestoreTabs(), Snapshot: settings.SnapshotsEnabled(), @@ -912,6 +914,10 @@ func (m *appModel) handleApplySettings(msg messages.ApplySettingsMsg) (tea.Model } m.sessionState.SetExpandThinking(preferences.ExpandThinking) m.sessionState.SetHideToolResults(preferences.HideToolResults) + if m.imageWriter != nil { + m.imageWriter.SetEnabled(preferences.RenderImages) + tuiimage.SetRenderingEnabled(m.imageWriter.RenderingEnabled()) + } m.tabBar.SetMaxTitleLength(preferences.TabTitleMaxLength) cmd = tea.Batch(cmd, m.updateChatCmd(messages.SessionToggleChangedMsg{}), m.resizeAll()) @@ -973,6 +979,7 @@ func savePreferences(p messages.Preferences) error { s.CacheStablePrompts = boolPreference(p.CacheStablePrompts, false) s.WarnOnCacheMiss = boolPreference(p.WarnOnCacheMiss, false) s.HideToolResults = p.HideToolResults + s.RenderImages = boolPreference(p.RenderImages, true) s.YOLO = p.YOLO s.Lean = p.Lean s.Sound = p.Sound @@ -1023,7 +1030,7 @@ func saveSettingsToUserConfig(layout messages.LayoutSettings, mode messages.Send return savePreferences(messages.Preferences{ Layout: layout, SendMode: mode, SplitDiffView: settings.GetSplitDiffView(), ExpandThinking: settings.GetExpandThinking(), HideToolResults: settings.HideToolResults, - YOLO: settings.YOLO, RestoreTabs: settings.GetRestoreTabs(), Snapshot: settings.SnapshotsEnabled(), + RenderImages: settings.GetRenderImages(), YOLO: settings.YOLO, RestoreTabs: settings.GetRestoreTabs(), Snapshot: settings.SnapshotsEnabled(), CacheStablePrompts: settings.CacheStablePromptsEnabled(), WarnOnCacheMiss: settings.CacheMissWarningsEnabled(), Lean: settings.Lean, TabTitleMaxLength: settings.GetTabTitleMaxLength(), diff --git a/pkg/tui/messages/settings.go b/pkg/tui/messages/settings.go index bd50e54f74..959ccabbd3 100644 --- a/pkg/tui/messages/settings.go +++ b/pkg/tui/messages/settings.go @@ -108,6 +108,7 @@ type Preferences struct { SplitDiffView bool ExpandThinking bool HideToolResults bool + RenderImages bool YOLO bool RestoreTabs bool Snapshot bool diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 061fcf524d..ad4209ba35 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -51,6 +51,9 @@ type Settings struct { // SplitDiffView enables side-by-side split diff rendering for file edits. // Defaults to true when not set. SplitDiffView *bool `yaml:"split_diff_view,omitempty"` + // RenderImages displays images returned by tools in supported terminals. + // Defaults to true when not set. + RenderImages *bool `yaml:"render_images,omitempty"` // Theme is the default theme reference (e.g., "dark", "light") // Theme files are loaded from ~/.cagent/themes/.yaml // The special value "auto" follows the terminal's light/dark background, @@ -205,6 +208,14 @@ func (s *Settings) GetSplitDiffView() bool { return *s.SplitDiffView } +// GetRenderImages returns whether tool result images are displayed, defaulting to true. +func (s *Settings) GetRenderImages() bool { + if s == nil || s.RenderImages == nil { + return true + } + return *s.RenderImages +} + // GetRestoreTabs returns whether previously open tabs are restored on // launch, defaulting to false. func (s *Settings) GetRestoreTabs() bool { diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go index 83a26f6c40..523752e5ec 100644 --- a/pkg/userconfig/userconfig_test.go +++ b/pkg/userconfig/userconfig_test.go @@ -644,6 +644,20 @@ func TestConfig_Settings_Empty(t *testing.T) { assert.False(t, settings.HideToolResults) assert.False(t, settings.Lean) assert.False(t, settings.GetExpandThinking()) + assert.True(t, settings.GetRenderImages()) +} + +func TestConfig_Settings_RenderImages(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.yaml") + renderImages := false + config := &Config{Settings: &Settings{RenderImages: &renderImages}} + require.NoError(t, config.saveTo(configFile)) + + loaded, err := loadFrom(configFile, "") + require.NoError(t, err) + assert.False(t, loaded.Settings.GetRenderImages()) } func TestConfig_Settings_ExpandThinking(t *testing.T) { @@ -678,6 +692,7 @@ func TestConfig_Settings_GetSettingsNil(t *testing.T) { assert.NotNil(t, settings) assert.False(t, settings.HideToolResults) assert.False(t, settings.GetExpandThinking()) + assert.True(t, settings.GetRenderImages()) } func TestConfig_AliasWithHideToolResults(t *testing.T) { From 76d810d258e94256c190381e0c4a9ae6351772dd Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 16 Jul 2026 23:54:15 +0200 Subject: [PATCH 3/3] docs(settings): document image rendering preference Signed-off-by: Djordje Lukic --- docs/configuration/user-settings/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration/user-settings/index.md b/docs/configuration/user-settings/index.md index a5a98ddd4c..44b52afa84 100644 --- a/docs/configuration/user-settings/index.md +++ b/docs/configuration/user-settings/index.md @@ -39,6 +39,7 @@ You rarely need to hand-edit this file. Most fields are managed from the TUI's ` | `hide_tool_results` | boolean | `false` | Hide tool call results in the TUI by default. Mirrors the `--hide-tool-results` flag and the Ctrl+O toggle. | | `expand_thinking` | boolean | `false` | Start new sessions with thinking/tool blocks expanded instead of collapsed. | | `split_diff_view` | boolean | `true` | Render file-edit diffs side-by-side instead of unified. | +| `render_images` | boolean | `true` | Render images returned by tools in terminals that support the Kitty graphics protocol. | | `theme` | string | `default` | Theme name, loaded from a built-in theme or `~/.cagent/themes/.yaml`. The special value `auto` follows the terminal's light/dark background. See [Theming](../../features/tui/index.md#theming). | | `theme_dark` | string | `default` | Theme applied when `theme: auto` and the terminal background is dark. | | `theme_light` | string | `default-light` | Theme applied when `theme: auto` and the terminal background is light. | @@ -90,6 +91,7 @@ settings: lean: false expand_thinking: false split_diff_view: true + render_images: true hide_tool_results: false sound: true sound_threshold: 10 @@ -124,4 +126,4 @@ User settings are the **lowest-priority** source: they establish defaults, and a - **Aliases sit between CLI flags and user settings.** An [alias](../../features/cli/index.md#docker-agent-alias) (`docker agent alias add ...`) can bundle its own `yolo`, `model`, `hide_tool_results`, and `sandbox` defaults; those apply when the corresponding flag was not explicitly passed, the same way user settings do, but are resolved after user settings so an alias's own choices take priority over your global defaults. - **Permissions are merged, not overridden.** Global `settings.permissions` and an agent's own `permissions:` are combined into a single set of `deny` → `allow` → `ask` patterns before evaluation — a global deny always blocks, regardless of what the agent config allows. See [Merging Behavior](../permissions/index.md#merging-behavior). - **Hooks are additive, not overridden.** For a given lifecycle event, hooks from the agent config, `settings.hooks`, `hooks.d/` drop-ins, and `--hook-*` CLI flags **all** run, in that order. Global hooks cannot be suppressed by an individual agent. -- **Everything else is a plain default.** Fields with no CLI or agent-config equivalent (`sound`, `sound_threshold`, `restore_tabs`, `tab_title_max_length`, `split_diff_view`, `cache_stable_prompts`, `warn_on_cache_miss`, `busy_send_mode`, `keybindings`, `layout`) only ever come from `settings:` (or the `/settings` dialog that writes it) — there is nothing to override them per run. +- **Everything else is a plain default.** Fields with no CLI or agent-config equivalent (`sound`, `sound_threshold`, `restore_tabs`, `tab_title_max_length`, `split_diff_view`, `render_images`, `cache_stable_prompts`, `warn_on_cache_miss`, `busy_send_mode`, `keybindings`, `layout`) only ever come from `settings:` (or the `/settings` dialog that writes it) — there is nothing to override them per run.