diff --git a/README.md b/README.md index 8e22609..7cd17b5 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,38 @@ When a tool returns an image (for example `read` on a PNG), zot renders it inlin Frames containing images are full-repainted (no differential diff) to prevent stale image pixels from lingering through scroll. That costs one terminal flash per image-containing frame; set `ZOT_INLINE_IMAGES=off` if that bothers you. +## Tool rendering + +By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zot also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. + +Set the `tool_render` key in `$ZOT_HOME/config.json`: + +```json +{ + "tool_render": "flat" +} +``` + +| Value | Effect | +|---|---| +| unset / `"box"` (default) | Each tool call is wrapped in a bordered panel. | +| `"flat"` | Boxless: a quiet header line plus indented output. | + +The `ZOT_FLAT_TOOLS` env var overrides the config for a single run, which is handy for trying it without editing the file: + +| Value | Effect | +|---|---| +| `1`, `true`, `yes`, `on`, `flat` | Force flat rendering. | +| `0`, `false`, `no`, `off`, `box` | Force the bordered panel. | +| unset | Fall back to the `tool_render` config key. | + +```sh +ZOT_FLAT_TOOLS=1 zot # flat, just this run +ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat" +``` + +Either way, theme colors still drive the rendering (the header uses your accent/foreground, output uses the tool-output color) and `ctrl+o` still expands a truncated result. + ## Queued messages You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: ` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot. diff --git a/packages/agent/cli.go b/packages/agent/cli.go index fbc0781..9af212d 100644 --- a/packages/agent/cli.go +++ b/packages/agent/cli.go @@ -966,6 +966,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, RespectGitignore: initialCfg.RespectGitignore, ThemeName: initialCfg.Theme, + FlatTools: initialCfg.FlatToolRender(), ExtensionThemes: extMgr.ThemeOptions, AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, SettingsStore: configSettingsStore{}, diff --git a/packages/agent/config.go b/packages/agent/config.go index e818b61..96382a3 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -29,6 +29,13 @@ type Config struct { Temperature *float32 `json:"temperature,omitempty"` Theme string `json:"theme"` + // ToolRender selects how tool calls are drawn in interactive mode. + // "box" (default, or empty) wraps each call in a bordered panel; + // "flat" drops the frame for a quiet header line plus indented, + // frameless output. The ZOT_FLAT_TOOLS env var overrides this when + // set ("1"/"true" forces flat, "0"/"false" forces box). + ToolRender string `json:"tool_render,omitempty"` + // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` @@ -97,6 +104,23 @@ func ZotHome() string { // ConfigPath returns the path to config.json. func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } +// FlatToolRender reports whether tool calls should render flat (no +// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over +// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/ +// "no"/"off" force box. Otherwise the config's tool_render is +// consulted; "flat" is flat, anything else (including empty) is box. +func (c Config) FlatToolRender() bool { + if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" { + switch v { + case "1", "true", "yes", "on", "flat": + return true + case "0", "false", "no", "off", "box": + return false + } + } + return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat") +} + // AuthPath returns the path to auth.json. func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index 11a2a88..03c8751 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -61,6 +61,11 @@ type InteractiveConfig struct { // ThemeName mirrors the persisted config theme value. Empty means auto. ThemeName string + // FlatTools renders tool calls without the bordered panel (a quiet + // header line plus indented, frameless output). Mirrors the + // resolved tool_render config / ZOT_FLAT_TOOLS env at startup. + FlatTools bool + // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal // forwards Command/Super keypresses, but Ctrl is the displayed chord. @@ -475,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { view: &tui.View{ Theme: cfg.Theme, ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), + FlatTools: cfg.FlatTools, }, // Prompt is the standard half-block accent bar used by chat // speaker labels too, so the input gutter matches the rest diff --git a/packages/tui/view.go b/packages/tui/view.go index 7bab87f..740b278 100644 --- a/packages/tui/view.go +++ b/packages/tui/view.go @@ -132,6 +132,12 @@ type View struct { // the map when a turn ends. liveBodyHigh map[string]int + // FlatTools renders tool calls without the bordered panel: a quiet + // header line per call plus indented, frameless output. The + // truncation/expand behaviour and theme colors are unchanged. + // False (the default) keeps the bordered box. + FlatTools bool + // ExpandAll forces every long tool result to render in full. // Toggled from the tui by ctrl+o. When false, results longer than // ToolCollapseLines collapse to ToolCollapsePreview lines plus a @@ -706,6 +712,17 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str // from the matching ToolCallBlock so multiple calls // in one assistant message render as N adjacent boxes // instead of stacking unclosed top edges. + if v.FlatTools { + lines = append(lines, flatToolHeader(v.Theme, label, width)) + if tr.IsError { + lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error"))) + } + for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { + _, stripped := parseImageFootprint(line) + lines = append(lines, flatToolBody(v.Theme, stripped)) + } + continue + } lines = append(lines, toolBoxTop(v.Theme, label, width)) lines = append(lines, toolBoxSide(v.Theme, "", width)) if tr.IsError { @@ -774,6 +791,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { } v.liveBodyHigh[tc.ID] = high } + if v.FlatTools { + for len(body) < high { + body = append(body, "") + } + lines = append(lines, flatToolHeader(v.Theme, label, width)) + lines = append(lines, body...) + return lines + } for len(body) < high { body = append(body, toolBoxSide(v.Theme, "", width)) } @@ -790,6 +815,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { // directly closed by the bottom. Avoids a blank interior row // for no-output tools. if tc.Result == "" { + if v.FlatTools { + lines = append(lines, flatToolHeader(v.Theme, label, width)) + return lines + } lines = append(lines, toolBoxTop(v.Theme, label, width)) lines = append(lines, toolBoxBottom(v.Theme, width)) return lines @@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { // vertical edges; bottom edge closes the box. Blank interior // rows after the top and before the bottom give the body a bit // of breathing room from the corners. - lines = append(lines, toolBoxTop(v.Theme, label, width)) - lines = append(lines, toolBoxSide(v.Theme, "", width)) color := v.Theme.ToolOut if tc.Error { color = v.Theme.Error } + if v.FlatTools { + lines = append(lines, flatToolHeader(v.Theme, label, width)) + body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color) + for _, l := range v.collapseToolBody(body, false) { + _, stripped := parseImageFootprint(l) + lines = append(lines, flatToolBody(v.Theme, stripped)) + } + return lines + } + lines = append(lines, toolBoxTop(v.Theme, label, width)) + lines = append(lines, toolBoxSide(v.Theme, "", width)) body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color) for _, l := range v.collapseToolBody(body, false) { imgCells, stripped := parseImageFootprint(l) @@ -863,6 +901,9 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { func (v *View) renderLiveBashCommand(command string, width int) []string { inner := toolBoxBodyRenderWidth(width) + if v.FlatTools { + inner = flatToolBodyRenderWidth(width) + } prompt := v.Theme.FG256(v.Theme.Muted, "$ ") var out []string for i, line := range strings.Split(command, "\n") { @@ -896,6 +937,13 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { func (v *View) wrapLiveBody(body []string, width int) []string { body = v.collapseToolBody(body, false) out := make([]string, 0, len(body)) + if v.FlatTools { + for _, l := range body { + _, stripped := parseImageFootprint(l) + out = append(out, flatToolBody(v.Theme, stripped)) + } + return out + } for _, l := range body { imgCells, stripped := parseImageFootprint(l) if hasImageEscapeLine(stripped) { @@ -994,6 +1042,49 @@ func splitToolLabel(label string) (name, rest string) { return label[:idx], label[idx:] } +// flatToolHeader renders the boxless header line for a tool call: +// +// ▌ bash xcrun simctl list devices +// +// It carries the same information as toolBoxTop (tool name + short +// args) but drops the frame: a single accent gutter glyph, the tool +// name in the foreground color, and the argument summary muted. The +// line is left-aligned at the same column the box's opening corner +// used (toolBoxOuterMargin) so flat and box renders share a column. +func flatToolHeader(th Theme, label string, width int) string { + label = oneLineToolLabel(label) + name, rest := splitToolLabel(label) + margin := strings.Repeat(" ", toolBoxOuterMargin) + gutter := th.FG256(th.Accent, "▌") + " " + // Budget the visible text to the content width so a very long + // argument summary doesn't run off the right edge; truncate with + // an ellipsis like the box header does. + avail := width - toolBoxOuterMargin - visibleWidth("▌ ") + if avail < 12 { + avail = 12 + } + if visibleWidth(name+rest) > avail { + over := visibleWidth(name+rest) - avail + runes := []rune(rest) + if over+3 < len(runes) { + rest = string(runes[:len(runes)-over-3]) + "..." + } else if visibleWidth(name) <= avail { + rest = "" + } + } + return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest) +} + +// flatToolBody indents a single body line for boxless rendering. It +// trims the same leading padding box bodies do, then prepends a fixed +// gutter indent so output sits in a readable column under the header +// without any vertical frame. ANSI styling in the line is preserved. +func flatToolBody(th Theme, line string) string { + _ = th + line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) + return strings.Repeat(" ", toolBoxOuterMargin+2) + line +} + // toolBoxBottom renders the bottom edge of a tool block: // // └─────────────────────────────────────────────────────────────────────────────┘ @@ -1080,6 +1171,20 @@ func toolBoxBodyRenderWidth(width int) int { return inner + toolBoxBodyTrimLeft } +// flatToolBodyRenderWidth is the width body renderers should target in +// flat mode. There's no box frame, just the flatToolBody indent +// (toolBoxOuterMargin+2 cells), so the renderable width is the +// terminal width minus that indent. Renderers emit a 4-cell indent of +// their own which flatToolBody trims toolBoxBodyTrimLeft cells from, +// so add that back like toolBoxBodyRenderWidth does. +func flatToolBodyRenderWidth(width int) int { + inner := width - (toolBoxOuterMargin + 2) + if inner < 1 { + inner = 1 + } + return inner + toolBoxBodyTrimLeft +} + // toolBoxSide wraps a single body line with vertical box edges: // // │ foo bar baz │ @@ -1202,6 +1307,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i switch bb := b.(type) { case provider.TextBlock: bodyWidth := toolBoxBodyRenderWidth(width) + if v.FlatTools { + bodyWidth = flatToolBodyRenderWidth(width) + } body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) case provider.ImageBlock: hasImage = true diff --git a/packages/tui/view_flat_tools_test.go b/packages/tui/view_flat_tools_test.go new file mode 100644 index 0000000..01e538d --- /dev/null +++ b/packages/tui/view_flat_tools_test.go @@ -0,0 +1,136 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/patriceckhart/zot/packages/provider" +) + +// boxGlyphs are the runes that only appear when a tool call is drawn +// inside the bordered panel. Flat mode must emit none of them. +const boxGlyphs = "┌└│┐┘" + +func assertNoBoxGlyphs(t *testing.T, plain string) { + t.Helper() + if strings.ContainsAny(plain, boxGlyphs) { + t.Fatalf("flat render still contains box glyphs:\n%s", plain) + } +} + +func TestFlatToolRenderDropsBorders(t *testing.T) { + args := json.RawMessage(`{"command":"echo hi"}`) + v := View{ + Theme: Dark, + FlatTools: true, + ToolCalls: []ToolCallView{ + {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"}, + }, + } + plain := stripANSI(strings.Join(v.Build(80), "\n")) + assertNoBoxGlyphs(t, plain) + if !strings.Contains(plain, "bash") { + t.Fatalf("flat header lost the tool name:\n%s", plain) + } + if !strings.Contains(plain, "hi") { + t.Fatalf("flat render lost the output:\n%s", plain) + } +} + +// The box render of the same call still has borders — the toggle is +// what changes, not the data. +func TestBoxToolRenderKeepsBorders(t *testing.T) { + args := json.RawMessage(`{"command":"echo hi"}`) + v := View{ + Theme: Dark, + ToolCalls: []ToolCallView{ + {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"}, + }, + } + plain := stripANSI(strings.Join(v.Build(80), "\n")) + if !strings.ContainsAny(plain, boxGlyphs) { + t.Fatalf("box render should contain border glyphs:\n%s", plain) + } +} + +func TestFlatToolRenderLiveBodyHasNoBorders(t *testing.T) { + args := json.RawMessage(`{"command":"printf 'start' && sleep 60"}`) + v := View{ + Theme: Dark, + FlatTools: true, + ToolCalls: []ToolCallView{ + {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)}, + }, + } + plain := stripANSI(strings.Join(v.BuildLive(100), "\n")) + assertNoBoxGlyphs(t, plain) + if !strings.Contains(plain, "$ printf 'start'") { + t.Fatalf("flat live body lost the streamed command:\n%s", plain) + } +} + +// Truncation + the ctrl+o expand footer must survive flat mode. +func TestFlatToolRenderKeepsTruncationFooter(t *testing.T) { + var b strings.Builder + for i := 0; i < ToolCollapseLines+50; i++ { + fmt.Fprintf(&b, "line %d\n", i) + } + args := json.RawMessage(`{"command":"seq 999"}`) + collapsed := View{ + Theme: Dark, + FlatTools: true, + ToolCalls: []ToolCallView{ + {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: b.String()}, + }, + } + plain := stripANSI(strings.Join(collapsed.Build(80), "\n")) + assertNoBoxGlyphs(t, plain) + if !strings.Contains(plain, "ctrl+o to expand") { + t.Fatalf("flat render dropped the truncation footer:\n%s", plain) + } + + // ExpandAll shows everything and still has no borders. + collapsed.ExpandAll = true + full := stripANSI(strings.Join(collapsed.Build(80), "\n")) + assertNoBoxGlyphs(t, full) + if strings.Contains(full, "ctrl+o to expand") { + t.Fatalf("expanded flat render should not show the footer:\n%s", full) + } + if !strings.Contains(full, fmt.Sprintf("line %d", ToolCollapseLines+40)) { + t.Fatalf("expanded flat render is missing later lines:\n%s", full) + } +} + +// A finished tool result that comes back as a RoleTool message (the +// transcript path, distinct from the in-flight ToolCalls path) also +// renders flat. +func TestFlatToolRenderTranscriptResult(t *testing.T) { + callArgs := json.RawMessage(`{"command":"echo done"}`) + v := View{ + Theme: Dark, + FlatTools: true, + Messages: []provider.Message{ + { + Role: provider.RoleAssistant, + Content: []provider.Content{ + provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: callArgs}, + }, + }, + { + Role: provider.RoleTool, + Content: []provider.Content{ + provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{ + provider.TextBlock{Text: "done"}, + }}, + }, + }, + }, + } + plain := stripANSI(strings.Join(v.Build(80), "\n")) + assertNoBoxGlyphs(t, plain) + if !strings.Contains(plain, "bash") || !strings.Contains(plain, "done") { + t.Fatalf("flat transcript result lost header or output:\n%s", plain) + } +}