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
9 changes: 8 additions & 1 deletion cmd/root/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ 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"
"github.com/docker/docker-agent/pkg/userconfig"
)

type newFlags struct {
Expand Down Expand Up @@ -131,12 +133,17 @@ 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))
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 {
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 {
Expand Down
3 changes: 3 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
}

Expand Down
4 changes: 3 additions & 1 deletion docs/configuration/user-settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>O</kbd> 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/<name>.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. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
6 changes: 5 additions & 1 deletion pkg/leantui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 4 additions & 71 deletions pkg/leantui/image.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions pkg/leantui/leantui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -197,6 +201,7 @@ func newModel(term *ui.Terminal, cfg Config) *model {
appName: appName,
banner: cfg.Banner,
disabledCommands: disabled,
renderImages: renderImages,
}
}

Expand Down
59 changes: 4 additions & 55 deletions pkg/leantui/ui/image.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions pkg/tui/components/tool/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,16 @@ func resolve(key string) (Builder, bool) {
// Lookup order: exact tool name, then "category:<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)
}
19 changes: 19 additions & 0 deletions pkg/tui/components/tool/factory_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tool

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading