detached cf4ee29

feat(tui): add flat (boxless) tool-render mode

Tool calls render inside a bordered panel by default. On screens with
many calls the borders read as busy, so add an opt-in flat mode: a
quiet header line per call plus indented, border-free output. Tool
name, arg summary, streamed live output, theme colors, and the
ctrl+o truncation/expand behaviour are all preserved.

Gated behind a non-breaking toggle (default stays box):

  - config.json "tool_render": "box" | "flat"
  - ZOT_FLAT_TOOLS env var overrides the config for a single run

Purely a TUI-layer change; core and agent are untouched.
commit
cf4ee29d7918a8f4b7e9439fe66225ff2ee7e1c8
author
kekkker · · 2026-06-26 08:19 UTC
parent
b325477
signature
GPG Unverified89ACE636A8263BA0 The key is unknown
6 files · +309 -2
M README.md +32 -0
@@ -555,6 +555,38 @@ When a tool returns an image (for example `read` on a PNG), zot renders it inlin
555555
556556 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.
557557
558+## Tool rendering
559+
560+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.
561+
562+Set the `tool_render` key in `$ZOT_HOME/config.json`:
563+
564+```json
565+{
566+ "tool_render": "flat"
567+}
568+```
569+
570+| Value | Effect |
571+|---|---|
572+| unset / `"box"` (default) | Each tool call is wrapped in a bordered panel. |
573+| `"flat"` | Boxless: a quiet header line plus indented output. |
574+
575+The `ZOT_FLAT_TOOLS` env var overrides the config for a single run, which is handy for trying it without editing the file:
576+
577+| Value | Effect |
578+|---|---|
579+| `1`, `true`, `yes`, `on`, `flat` | Force flat rendering. |
580+| `0`, `false`, `no`, `off`, `box` | Force the bordered panel. |
581+| unset | Fall back to the `tool_render` config key. |
582+
583+```sh
584+ZOT_FLAT_TOOLS=1 zot # flat, just this run
585+ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat"
586+```
587+
588+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.
589+
558590 ## Queued messages
559591
560592 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: <text>` 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.
@@ -555,6 +555,38 @@ When a tool returns an image (for example `read` on a PNG), zot renders it inlin
555 555
556 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.556 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.
557 557
558+## Tool rendering
559+
560+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.
561+
562+Set the `tool_render` key in `$ZOT_HOME/config.json`:
563+
564+```json
565+{
566+ "tool_render": "flat"
567+}
568+```
569+
570+| Value | Effect |
571+|---|---|
572+| unset / `"box"` (default) | Each tool call is wrapped in a bordered panel. |
573+| `"flat"` | Boxless: a quiet header line plus indented output. |
574+
575+The `ZOT_FLAT_TOOLS` env var overrides the config for a single run, which is handy for trying it without editing the file:
576+
577+| Value | Effect |
578+|---|---|
579+| `1`, `true`, `yes`, `on`, `flat` | Force flat rendering. |
580+| `0`, `false`, `no`, `off`, `box` | Force the bordered panel. |
581+| unset | Fall back to the `tool_render` config key. |
582+
583+```sh
584+ZOT_FLAT_TOOLS=1 zot # flat, just this run
585+ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat"
586+```
587+
588+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.
589+
558 ## Queued messages590 ## Queued messages
559 591
560 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: <text>` 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.592 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: <text>` 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.
M packages/agent/cli.go +1 -0
@@ -966,6 +966,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967967 RespectGitignore: initialCfg.RespectGitignore,
968968 ThemeName: initialCfg.Theme,
969+ FlatTools: initialCfg.FlatToolRender(),
969970 ExtensionThemes: extMgr.ThemeOptions,
970971 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
971972 SettingsStore: configSettingsStore{},
@@ -966,6 +966,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967 RespectGitignore: initialCfg.RespectGitignore,967 RespectGitignore: initialCfg.RespectGitignore,
968 ThemeName: initialCfg.Theme,968 ThemeName: initialCfg.Theme,
969+ FlatTools: initialCfg.FlatToolRender(),
969 ExtensionThemes: extMgr.ThemeOptions,970 ExtensionThemes: extMgr.ThemeOptions,
970 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,971 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
971 SettingsStore: configSettingsStore{},972 SettingsStore: configSettingsStore{},
M packages/agent/config.go +24 -0
@@ -29,6 +29,13 @@ type Config struct {
2929 Temperature *float32 `json:"temperature,omitempty"`
3030 Theme string `json:"theme"`
3131
32+ // ToolRender selects how tool calls are drawn in interactive mode.
33+ // "box" (default, or empty) wraps each call in a bordered panel;
34+ // "flat" drops the frame for a quiet header line plus indented,
35+ // frameless output. The ZOT_FLAT_TOOLS env var overrides this when
36+ // set ("1"/"true" forces flat, "0"/"false" forces box).
37+ ToolRender string `json:"tool_render,omitempty"`
38+
3239 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
3340 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
3441 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
@@ -97,6 +104,23 @@ func ZotHome() string {
97104 // ConfigPath returns the path to config.json.
98105 func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
99106
107+// FlatToolRender reports whether tool calls should render flat (no
108+// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over
109+// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
110+// "no"/"off" force box. Otherwise the config's tool_render is
111+// consulted; "flat" is flat, anything else (including empty) is box.
112+func (c Config) FlatToolRender() bool {
113+ if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" {
114+ switch v {
115+ case "1", "true", "yes", "on", "flat":
116+ return true
117+ case "0", "false", "no", "off", "box":
118+ return false
119+ }
120+ }
121+ return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
122+}
123+
100124 // AuthPath returns the path to auth.json.
101125 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
102126
@@ -29,6 +29,13 @@ type Config struct {
29 Temperature *float32 `json:"temperature,omitempty"`29 Temperature *float32 `json:"temperature,omitempty"`
30 Theme string `json:"theme"`30 Theme string `json:"theme"`
31 31
32+ // ToolRender selects how tool calls are drawn in interactive mode.
33+ // "box" (default, or empty) wraps each call in a bordered panel;
34+ // "flat" drops the frame for a quiet header line plus indented,
35+ // frameless output. The ZOT_FLAT_TOOLS env var overrides this when
36+ // set ("1"/"true" forces flat, "0"/"false" forces box).
37+ ToolRender string `json:"tool_render,omitempty"`
38+
32 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by39 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
33 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.40 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
34 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`41 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
@@ -97,6 +104,23 @@ func ZotHome() string {
97 // ConfigPath returns the path to config.json.104 // ConfigPath returns the path to config.json.
98 func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }105 func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
99 106
107+// FlatToolRender reports whether tool calls should render flat (no
108+// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over
109+// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
110+// "no"/"off" force box. Otherwise the config's tool_render is
111+// consulted; "flat" is flat, anything else (including empty) is box.
112+func (c Config) FlatToolRender() bool {
113+ if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" {
114+ switch v {
115+ case "1", "true", "yes", "on", "flat":
116+ return true
117+ case "0", "false", "no", "off", "box":
118+ return false
119+ }
120+ }
121+ return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
122+}
123+
100 // AuthPath returns the path to auth.json.124 // AuthPath returns the path to auth.json.
101 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }125 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
102 126
M packages/agent/modes/interactive.go +6 -0
@@ -61,6 +61,11 @@ type InteractiveConfig struct {
6161 // ThemeName mirrors the persisted config theme value. Empty means auto.
6262 ThemeName string
6363
64+ // FlatTools renders tool calls without the bordered panel (a quiet
65+ // header line plus indented, frameless output). Mirrors the
66+ // resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
67+ FlatTools bool
68+
6469 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
6570 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
6671 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -475,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
475480 view: &tui.View{
476481 Theme: cfg.Theme,
477482 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
483+ FlatTools: cfg.FlatTools,
478484 },
479485 // Prompt is the standard half-block accent bar used by chat
480486 // speaker labels too, so the input gutter matches the rest
@@ -61,6 +61,11 @@ type InteractiveConfig struct {
61 // ThemeName mirrors the persisted config theme value. Empty means auto.61 // ThemeName mirrors the persisted config theme value. Empty means auto.
62 ThemeName string62 ThemeName string
63 63
64+ // FlatTools renders tool calls without the bordered panel (a quiet
65+ // header line plus indented, frameless output). Mirrors the
66+ // resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
67+ FlatTools bool
68+
64 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The69 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
65 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal70 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
66 // forwards Command/Super keypresses, but Ctrl is the displayed chord.71 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -475,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
475 view: &tui.View{480 view: &tui.View{
476 Theme: cfg.Theme,481 Theme: cfg.Theme,
477 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),482 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
483+ FlatTools: cfg.FlatTools,
478 },484 },
479 // Prompt is the standard half-block accent bar used by chat485 // Prompt is the standard half-block accent bar used by chat
480 // speaker labels too, so the input gutter matches the rest486 // speaker labels too, so the input gutter matches the rest
M packages/tui/view.go +110 -2
@@ -132,6 +132,12 @@ type View struct {
132132 // the map when a turn ends.
133133 liveBodyHigh map[string]int
134134
135+ // FlatTools renders tool calls without the bordered panel: a quiet
136+ // header line per call plus indented, frameless output. The
137+ // truncation/expand behaviour and theme colors are unchanged.
138+ // False (the default) keeps the bordered box.
139+ FlatTools bool
140+
135141 // ExpandAll forces every long tool result to render in full.
136142 // Toggled from the tui by ctrl+o. When false, results longer than
137143 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -706,6 +712,17 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
706712 // from the matching ToolCallBlock so multiple calls
707713 // in one assistant message render as N adjacent boxes
708714 // instead of stacking unclosed top edges.
715+ if v.FlatTools {
716+ lines = append(lines, flatToolHeader(v.Theme, label, width))
717+ if tr.IsError {
718+ lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
719+ }
720+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
721+ _, stripped := parseImageFootprint(line)
722+ lines = append(lines, flatToolBody(v.Theme, stripped))
723+ }
724+ continue
725+ }
709726 lines = append(lines, toolBoxTop(v.Theme, label, width))
710727 lines = append(lines, toolBoxSide(v.Theme, "", width))
711728 if tr.IsError {
@@ -774,6 +791,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
774791 }
775792 v.liveBodyHigh[tc.ID] = high
776793 }
794+ if v.FlatTools {
795+ for len(body) < high {
796+ body = append(body, "")
797+ }
798+ lines = append(lines, flatToolHeader(v.Theme, label, width))
799+ lines = append(lines, body...)
800+ return lines
801+ }
777802 for len(body) < high {
778803 body = append(body, toolBoxSide(v.Theme, "", width))
779804 }
@@ -790,6 +815,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
790815 // directly closed by the bottom. Avoids a blank interior row
791816 // for no-output tools.
792817 if tc.Result == "" {
818+ if v.FlatTools {
819+ lines = append(lines, flatToolHeader(v.Theme, label, width))
820+ return lines
821+ }
793822 lines = append(lines, toolBoxTop(v.Theme, label, width))
794823 lines = append(lines, toolBoxBottom(v.Theme, width))
795824 return lines
@@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
800829 // vertical edges; bottom edge closes the box. Blank interior
801830 // rows after the top and before the bottom give the body a bit
802831 // of breathing room from the corners.
803- lines = append(lines, toolBoxTop(v.Theme, label, width))
804- lines = append(lines, toolBoxSide(v.Theme, "", width))
805832 color := v.Theme.ToolOut
806833 if tc.Error {
807834 color = v.Theme.Error
808835 }
836+ if v.FlatTools {
837+ lines = append(lines, flatToolHeader(v.Theme, label, width))
838+ body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
839+ for _, l := range v.collapseToolBody(body, false) {
840+ _, stripped := parseImageFootprint(l)
841+ lines = append(lines, flatToolBody(v.Theme, stripped))
842+ }
843+ return lines
844+ }
845+ lines = append(lines, toolBoxTop(v.Theme, label, width))
846+ lines = append(lines, toolBoxSide(v.Theme, "", width))
809847 body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
810848 for _, l := range v.collapseToolBody(body, false) {
811849 imgCells, stripped := parseImageFootprint(l)
@@ -863,6 +901,9 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
863901
864902 func (v *View) renderLiveBashCommand(command string, width int) []string {
865903 inner := toolBoxBodyRenderWidth(width)
904+ if v.FlatTools {
905+ inner = flatToolBodyRenderWidth(width)
906+ }
866907 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
867908 var out []string
868909 for i, line := range strings.Split(command, "\n") {
@@ -896,6 +937,13 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
896937 func (v *View) wrapLiveBody(body []string, width int) []string {
897938 body = v.collapseToolBody(body, false)
898939 out := make([]string, 0, len(body))
940+ if v.FlatTools {
941+ for _, l := range body {
942+ _, stripped := parseImageFootprint(l)
943+ out = append(out, flatToolBody(v.Theme, stripped))
944+ }
945+ return out
946+ }
899947 for _, l := range body {
900948 imgCells, stripped := parseImageFootprint(l)
901949 if hasImageEscapeLine(stripped) {
@@ -994,6 +1042,49 @@ func splitToolLabel(label string) (name, rest string) {
9941042 return label[:idx], label[idx:]
9951043 }
9961044
1045+// flatToolHeader renders the boxless header line for a tool call:
1046+//
1047+// ▌ bash xcrun simctl list devices
1048+//
1049+// It carries the same information as toolBoxTop (tool name + short
1050+// args) but drops the frame: a single accent gutter glyph, the tool
1051+// name in the foreground color, and the argument summary muted. The
1052+// line is left-aligned at the same column the box's opening corner
1053+// used (toolBoxOuterMargin) so flat and box renders share a column.
1054+func flatToolHeader(th Theme, label string, width int) string {
1055+ label = oneLineToolLabel(label)
1056+ name, rest := splitToolLabel(label)
1057+ margin := strings.Repeat(" ", toolBoxOuterMargin)
1058+ gutter := th.FG256(th.Accent, "▌") + " "
1059+ // Budget the visible text to the content width so a very long
1060+ // argument summary doesn't run off the right edge; truncate with
1061+ // an ellipsis like the box header does.
1062+ avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
1063+ if avail < 12 {
1064+ avail = 12
1065+ }
1066+ if visibleWidth(name+rest) > avail {
1067+ over := visibleWidth(name+rest) - avail
1068+ runes := []rune(rest)
1069+ if over+3 < len(runes) {
1070+ rest = string(runes[:len(runes)-over-3]) + "..."
1071+ } else if visibleWidth(name) <= avail {
1072+ rest = ""
1073+ }
1074+ }
1075+ return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
1076+}
1077+
1078+// flatToolBody indents a single body line for boxless rendering. It
1079+// trims the same leading padding box bodies do, then prepends a fixed
1080+// gutter indent so output sits in a readable column under the header
1081+// without any vertical frame. ANSI styling in the line is preserved.
1082+func flatToolBody(th Theme, line string) string {
1083+ _ = th
1084+ line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1085+ return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1086+}
1087+
9971088 // toolBoxBottom renders the bottom edge of a tool block:
9981089 //
9991090 // └─────────────────────────────────────────────────────────────────────────────┘
@@ -1080,6 +1171,20 @@ func toolBoxBodyRenderWidth(width int) int {
10801171 return inner + toolBoxBodyTrimLeft
10811172 }
10821173
1174+// flatToolBodyRenderWidth is the width body renderers should target in
1175+// flat mode. There's no box frame, just the flatToolBody indent
1176+// (toolBoxOuterMargin+2 cells), so the renderable width is the
1177+// terminal width minus that indent. Renderers emit a 4-cell indent of
1178+// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
1179+// so add that back like toolBoxBodyRenderWidth does.
1180+func flatToolBodyRenderWidth(width int) int {
1181+ inner := width - (toolBoxOuterMargin + 2)
1182+ if inner < 1 {
1183+ inner = 1
1184+ }
1185+ return inner + toolBoxBodyTrimLeft
1186+}
1187+
10831188 // toolBoxSide wraps a single body line with vertical box edges:
10841189 //
10851190 // │ foo bar baz │
@@ -1202,6 +1307,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
12021307 switch bb := b.(type) {
12031308 case provider.TextBlock:
12041309 bodyWidth := toolBoxBodyRenderWidth(width)
1310+ if v.FlatTools {
1311+ bodyWidth = flatToolBodyRenderWidth(width)
1312+ }
12051313 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
12061314 case provider.ImageBlock:
12071315 hasImage = true
@@ -132,6 +132,12 @@ type View struct {
132 // the map when a turn ends.132 // the map when a turn ends.
133 liveBodyHigh map[string]int133 liveBodyHigh map[string]int
134 134
135+ // FlatTools renders tool calls without the bordered panel: a quiet
136+ // header line per call plus indented, frameless output. The
137+ // truncation/expand behaviour and theme colors are unchanged.
138+ // False (the default) keeps the bordered box.
139+ FlatTools bool
140+
135 // ExpandAll forces every long tool result to render in full.141 // ExpandAll forces every long tool result to render in full.
136 // Toggled from the tui by ctrl+o. When false, results longer than142 // Toggled from the tui by ctrl+o. When false, results longer than
137 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a143 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -706,6 +712,17 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
706 // from the matching ToolCallBlock so multiple calls712 // from the matching ToolCallBlock so multiple calls
707 // in one assistant message render as N adjacent boxes713 // in one assistant message render as N adjacent boxes
708 // instead of stacking unclosed top edges.714 // instead of stacking unclosed top edges.
715+ if v.FlatTools {
716+ lines = append(lines, flatToolHeader(v.Theme, label, width))
717+ if tr.IsError {
718+ lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
719+ }
720+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
721+ _, stripped := parseImageFootprint(line)
722+ lines = append(lines, flatToolBody(v.Theme, stripped))
723+ }
724+ continue
725+ }
709 lines = append(lines, toolBoxTop(v.Theme, label, width))726 lines = append(lines, toolBoxTop(v.Theme, label, width))
710 lines = append(lines, toolBoxSide(v.Theme, "", width))727 lines = append(lines, toolBoxSide(v.Theme, "", width))
711 if tr.IsError {728 if tr.IsError {
@@ -774,6 +791,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
774 }791 }
775 v.liveBodyHigh[tc.ID] = high792 v.liveBodyHigh[tc.ID] = high
776 }793 }
794+ if v.FlatTools {
795+ for len(body) < high {
796+ body = append(body, "")
797+ }
798+ lines = append(lines, flatToolHeader(v.Theme, label, width))
799+ lines = append(lines, body...)
800+ return lines
801+ }
777 for len(body) < high {802 for len(body) < high {
778 body = append(body, toolBoxSide(v.Theme, "", width))803 body = append(body, toolBoxSide(v.Theme, "", width))
779 }804 }
@@ -790,6 +815,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
790 // directly closed by the bottom. Avoids a blank interior row815 // directly closed by the bottom. Avoids a blank interior row
791 // for no-output tools.816 // for no-output tools.
792 if tc.Result == "" {817 if tc.Result == "" {
818+ if v.FlatTools {
819+ lines = append(lines, flatToolHeader(v.Theme, label, width))
820+ return lines
821+ }
793 lines = append(lines, toolBoxTop(v.Theme, label, width))822 lines = append(lines, toolBoxTop(v.Theme, label, width))
794 lines = append(lines, toolBoxBottom(v.Theme, width))823 lines = append(lines, toolBoxBottom(v.Theme, width))
795 return lines824 return lines
@@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
800 // vertical edges; bottom edge closes the box. Blank interior829 // vertical edges; bottom edge closes the box. Blank interior
801 // rows after the top and before the bottom give the body a bit830 // rows after the top and before the bottom give the body a bit
802 // of breathing room from the corners.831 // of breathing room from the corners.
803- lines = append(lines, toolBoxTop(v.Theme, label, width))
804- lines = append(lines, toolBoxSide(v.Theme, "", width))
805 color := v.Theme.ToolOut832 color := v.Theme.ToolOut
806 if tc.Error {833 if tc.Error {
807 color = v.Theme.Error834 color = v.Theme.Error
808 }835 }
836+ if v.FlatTools {
837+ lines = append(lines, flatToolHeader(v.Theme, label, width))
838+ body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
839+ for _, l := range v.collapseToolBody(body, false) {
840+ _, stripped := parseImageFootprint(l)
841+ lines = append(lines, flatToolBody(v.Theme, stripped))
842+ }
843+ return lines
844+ }
845+ lines = append(lines, toolBoxTop(v.Theme, label, width))
846+ lines = append(lines, toolBoxSide(v.Theme, "", width))
809 body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)847 body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
810 for _, l := range v.collapseToolBody(body, false) {848 for _, l := range v.collapseToolBody(body, false) {
811 imgCells, stripped := parseImageFootprint(l)849 imgCells, stripped := parseImageFootprint(l)
@@ -863,6 +901,9 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
863 901
864 func (v *View) renderLiveBashCommand(command string, width int) []string {902 func (v *View) renderLiveBashCommand(command string, width int) []string {
865 inner := toolBoxBodyRenderWidth(width)903 inner := toolBoxBodyRenderWidth(width)
904+ if v.FlatTools {
905+ inner = flatToolBodyRenderWidth(width)
906+ }
866 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")907 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
867 var out []string908 var out []string
868 for i, line := range strings.Split(command, "\n") {909 for i, line := range strings.Split(command, "\n") {
@@ -896,6 +937,13 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
896 func (v *View) wrapLiveBody(body []string, width int) []string {937 func (v *View) wrapLiveBody(body []string, width int) []string {
897 body = v.collapseToolBody(body, false)938 body = v.collapseToolBody(body, false)
898 out := make([]string, 0, len(body))939 out := make([]string, 0, len(body))
940+ if v.FlatTools {
941+ for _, l := range body {
942+ _, stripped := parseImageFootprint(l)
943+ out = append(out, flatToolBody(v.Theme, stripped))
944+ }
945+ return out
946+ }
899 for _, l := range body {947 for _, l := range body {
900 imgCells, stripped := parseImageFootprint(l)948 imgCells, stripped := parseImageFootprint(l)
901 if hasImageEscapeLine(stripped) {949 if hasImageEscapeLine(stripped) {
@@ -994,6 +1042,49 @@ func splitToolLabel(label string) (name, rest string) {
994 return label[:idx], label[idx:]1042 return label[:idx], label[idx:]
995 }1043 }
996 1044
1045+// flatToolHeader renders the boxless header line for a tool call:
1046+//
1047+// ▌ bash xcrun simctl list devices
1048+//
1049+// It carries the same information as toolBoxTop (tool name + short
1050+// args) but drops the frame: a single accent gutter glyph, the tool
1051+// name in the foreground color, and the argument summary muted. The
1052+// line is left-aligned at the same column the box's opening corner
1053+// used (toolBoxOuterMargin) so flat and box renders share a column.
1054+func flatToolHeader(th Theme, label string, width int) string {
1055+ label = oneLineToolLabel(label)
1056+ name, rest := splitToolLabel(label)
1057+ margin := strings.Repeat(" ", toolBoxOuterMargin)
1058+ gutter := th.FG256(th.Accent, "▌") + " "
1059+ // Budget the visible text to the content width so a very long
1060+ // argument summary doesn't run off the right edge; truncate with
1061+ // an ellipsis like the box header does.
1062+ avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
1063+ if avail < 12 {
1064+ avail = 12
1065+ }
1066+ if visibleWidth(name+rest) > avail {
1067+ over := visibleWidth(name+rest) - avail
1068+ runes := []rune(rest)
1069+ if over+3 < len(runes) {
1070+ rest = string(runes[:len(runes)-over-3]) + "..."
1071+ } else if visibleWidth(name) <= avail {
1072+ rest = ""
1073+ }
1074+ }
1075+ return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
1076+}
1077+
1078+// flatToolBody indents a single body line for boxless rendering. It
1079+// trims the same leading padding box bodies do, then prepends a fixed
1080+// gutter indent so output sits in a readable column under the header
1081+// without any vertical frame. ANSI styling in the line is preserved.
1082+func flatToolBody(th Theme, line string) string {
1083+ _ = th
1084+ line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1085+ return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1086+}
1087+
997 // toolBoxBottom renders the bottom edge of a tool block:1088 // toolBoxBottom renders the bottom edge of a tool block:
998 //1089 //
999 // └─────────────────────────────────────────────────────────────────────────────┘1090 // └─────────────────────────────────────────────────────────────────────────────┘
@@ -1080,6 +1171,20 @@ func toolBoxBodyRenderWidth(width int) int {
1080 return inner + toolBoxBodyTrimLeft1171 return inner + toolBoxBodyTrimLeft
1081 }1172 }
1082 1173
1174+// flatToolBodyRenderWidth is the width body renderers should target in
1175+// flat mode. There's no box frame, just the flatToolBody indent
1176+// (toolBoxOuterMargin+2 cells), so the renderable width is the
1177+// terminal width minus that indent. Renderers emit a 4-cell indent of
1178+// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
1179+// so add that back like toolBoxBodyRenderWidth does.
1180+func flatToolBodyRenderWidth(width int) int {
1181+ inner := width - (toolBoxOuterMargin + 2)
1182+ if inner < 1 {
1183+ inner = 1
1184+ }
1185+ return inner + toolBoxBodyTrimLeft
1186+}
1187+
1083 // toolBoxSide wraps a single body line with vertical box edges:1188 // toolBoxSide wraps a single body line with vertical box edges:
1084 //1189 //
1085 // │ foo bar baz │1190 // │ foo bar baz │
@@ -1202,6 +1307,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
1202 switch bb := b.(type) {1307 switch bb := b.(type) {
1203 case provider.TextBlock:1308 case provider.TextBlock:
1204 bodyWidth := toolBoxBodyRenderWidth(width)1309 bodyWidth := toolBoxBodyRenderWidth(width)
1310+ if v.FlatTools {
1311+ bodyWidth = flatToolBodyRenderWidth(width)
1312+ }
1205 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)1313 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
1206 case provider.ImageBlock:1314 case provider.ImageBlock:
1207 hasImage = true1315 hasImage = true
A packages/tui/view_flat_tools_test.go +136 -0
@@ -0,0 +1,136 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// boxGlyphs are the runes that only appear when a tool call is drawn
13+// inside the bordered panel. Flat mode must emit none of them.
14+const boxGlyphs = "┌└│┐┘"
15+
16+func assertNoBoxGlyphs(t *testing.T, plain string) {
17+ t.Helper()
18+ if strings.ContainsAny(plain, boxGlyphs) {
19+ t.Fatalf("flat render still contains box glyphs:\n%s", plain)
20+ }
21+}
22+
23+func TestFlatToolRenderDropsBorders(t *testing.T) {
24+ args := json.RawMessage(`{"command":"echo hi"}`)
25+ v := View{
26+ Theme: Dark,
27+ FlatTools: true,
28+ ToolCalls: []ToolCallView{
29+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
30+ },
31+ }
32+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
33+ assertNoBoxGlyphs(t, plain)
34+ if !strings.Contains(plain, "bash") {
35+ t.Fatalf("flat header lost the tool name:\n%s", plain)
36+ }
37+ if !strings.Contains(plain, "hi") {
38+ t.Fatalf("flat render lost the output:\n%s", plain)
39+ }
40+}
41+
42+// The box render of the same call still has borders — the toggle is
43+// what changes, not the data.
44+func TestBoxToolRenderKeepsBorders(t *testing.T) {
45+ args := json.RawMessage(`{"command":"echo hi"}`)
46+ v := View{
47+ Theme: Dark,
48+ ToolCalls: []ToolCallView{
49+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
50+ },
51+ }
52+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
53+ if !strings.ContainsAny(plain, boxGlyphs) {
54+ t.Fatalf("box render should contain border glyphs:\n%s", plain)
55+ }
56+}
57+
58+func TestFlatToolRenderLiveBodyHasNoBorders(t *testing.T) {
59+ args := json.RawMessage(`{"command":"printf 'start' && sleep 60"}`)
60+ v := View{
61+ Theme: Dark,
62+ FlatTools: true,
63+ ToolCalls: []ToolCallView{
64+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)},
65+ },
66+ }
67+ plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
68+ assertNoBoxGlyphs(t, plain)
69+ if !strings.Contains(plain, "$ printf 'start'") {
70+ t.Fatalf("flat live body lost the streamed command:\n%s", plain)
71+ }
72+}
73+
74+// Truncation + the ctrl+o expand footer must survive flat mode.
75+func TestFlatToolRenderKeepsTruncationFooter(t *testing.T) {
76+ var b strings.Builder
77+ for i := 0; i < ToolCollapseLines+50; i++ {
78+ fmt.Fprintf(&b, "line %d\n", i)
79+ }
80+ args := json.RawMessage(`{"command":"seq 999"}`)
81+ collapsed := View{
82+ Theme: Dark,
83+ FlatTools: true,
84+ ToolCalls: []ToolCallView{
85+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: b.String()},
86+ },
87+ }
88+ plain := stripANSI(strings.Join(collapsed.Build(80), "\n"))
89+ assertNoBoxGlyphs(t, plain)
90+ if !strings.Contains(plain, "ctrl+o to expand") {
91+ t.Fatalf("flat render dropped the truncation footer:\n%s", plain)
92+ }
93+
94+ // ExpandAll shows everything and still has no borders.
95+ collapsed.ExpandAll = true
96+ full := stripANSI(strings.Join(collapsed.Build(80), "\n"))
97+ assertNoBoxGlyphs(t, full)
98+ if strings.Contains(full, "ctrl+o to expand") {
99+ t.Fatalf("expanded flat render should not show the footer:\n%s", full)
100+ }
101+ if !strings.Contains(full, fmt.Sprintf("line %d", ToolCollapseLines+40)) {
102+ t.Fatalf("expanded flat render is missing later lines:\n%s", full)
103+ }
104+}
105+
106+// A finished tool result that comes back as a RoleTool message (the
107+// transcript path, distinct from the in-flight ToolCalls path) also
108+// renders flat.
109+func TestFlatToolRenderTranscriptResult(t *testing.T) {
110+ callArgs := json.RawMessage(`{"command":"echo done"}`)
111+ v := View{
112+ Theme: Dark,
113+ FlatTools: true,
114+ Messages: []provider.Message{
115+ {
116+ Role: provider.RoleAssistant,
117+ Content: []provider.Content{
118+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: callArgs},
119+ },
120+ },
121+ {
122+ Role: provider.RoleTool,
123+ Content: []provider.Content{
124+ provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{
125+ provider.TextBlock{Text: "done"},
126+ }},
127+ },
128+ },
129+ },
130+ }
131+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
132+ assertNoBoxGlyphs(t, plain)
133+ if !strings.Contains(plain, "bash") || !strings.Contains(plain, "done") {
134+ t.Fatalf("flat transcript result lost header or output:\n%s", plain)
135+ }
136+}
@@ -0,0 +1,136 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// boxGlyphs are the runes that only appear when a tool call is drawn
13+// inside the bordered panel. Flat mode must emit none of them.
14+const boxGlyphs = "┌└│┐┘"
15+
16+func assertNoBoxGlyphs(t *testing.T, plain string) {
17+ t.Helper()
18+ if strings.ContainsAny(plain, boxGlyphs) {
19+ t.Fatalf("flat render still contains box glyphs:\n%s", plain)
20+ }
21+}
22+
23+func TestFlatToolRenderDropsBorders(t *testing.T) {
24+ args := json.RawMessage(`{"command":"echo hi"}`)
25+ v := View{
26+ Theme: Dark,
27+ FlatTools: true,
28+ ToolCalls: []ToolCallView{
29+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
30+ },
31+ }
32+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
33+ assertNoBoxGlyphs(t, plain)
34+ if !strings.Contains(plain, "bash") {
35+ t.Fatalf("flat header lost the tool name:\n%s", plain)
36+ }
37+ if !strings.Contains(plain, "hi") {
38+ t.Fatalf("flat render lost the output:\n%s", plain)
39+ }
40+}
41+
42+// The box render of the same call still has borders — the toggle is
43+// what changes, not the data.
44+func TestBoxToolRenderKeepsBorders(t *testing.T) {
45+ args := json.RawMessage(`{"command":"echo hi"}`)
46+ v := View{
47+ Theme: Dark,
48+ ToolCalls: []ToolCallView{
49+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
50+ },
51+ }
52+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
53+ if !strings.ContainsAny(plain, boxGlyphs) {
54+ t.Fatalf("box render should contain border glyphs:\n%s", plain)
55+ }
56+}
57+
58+func TestFlatToolRenderLiveBodyHasNoBorders(t *testing.T) {
59+ args := json.RawMessage(`{"command":"printf 'start' && sleep 60"}`)
60+ v := View{
61+ Theme: Dark,
62+ FlatTools: true,
63+ ToolCalls: []ToolCallView{
64+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)},
65+ },
66+ }
67+ plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
68+ assertNoBoxGlyphs(t, plain)
69+ if !strings.Contains(plain, "$ printf 'start'") {
70+ t.Fatalf("flat live body lost the streamed command:\n%s", plain)
71+ }
72+}
73+
74+// Truncation + the ctrl+o expand footer must survive flat mode.
75+func TestFlatToolRenderKeepsTruncationFooter(t *testing.T) {
76+ var b strings.Builder
77+ for i := 0; i < ToolCollapseLines+50; i++ {
78+ fmt.Fprintf(&b, "line %d\n", i)
79+ }
80+ args := json.RawMessage(`{"command":"seq 999"}`)
81+ collapsed := View{
82+ Theme: Dark,
83+ FlatTools: true,
84+ ToolCalls: []ToolCallView{
85+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: b.String()},
86+ },
87+ }
88+ plain := stripANSI(strings.Join(collapsed.Build(80), "\n"))
89+ assertNoBoxGlyphs(t, plain)
90+ if !strings.Contains(plain, "ctrl+o to expand") {
91+ t.Fatalf("flat render dropped the truncation footer:\n%s", plain)
92+ }
93+
94+ // ExpandAll shows everything and still has no borders.
95+ collapsed.ExpandAll = true
96+ full := stripANSI(strings.Join(collapsed.Build(80), "\n"))
97+ assertNoBoxGlyphs(t, full)
98+ if strings.Contains(full, "ctrl+o to expand") {
99+ t.Fatalf("expanded flat render should not show the footer:\n%s", full)
100+ }
101+ if !strings.Contains(full, fmt.Sprintf("line %d", ToolCollapseLines+40)) {
102+ t.Fatalf("expanded flat render is missing later lines:\n%s", full)
103+ }
104+}
105+
106+// A finished tool result that comes back as a RoleTool message (the
107+// transcript path, distinct from the in-flight ToolCalls path) also
108+// renders flat.
109+func TestFlatToolRenderTranscriptResult(t *testing.T) {
110+ callArgs := json.RawMessage(`{"command":"echo done"}`)
111+ v := View{
112+ Theme: Dark,
113+ FlatTools: true,
114+ Messages: []provider.Message{
115+ {
116+ Role: provider.RoleAssistant,
117+ Content: []provider.Content{
118+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: callArgs},
119+ },
120+ },
121+ {
122+ Role: provider.RoleTool,
123+ Content: []provider.Content{
124+ provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{
125+ provider.TextBlock{Text: "done"},
126+ }},
127+ },
128+ },
129+ },
130+ }
131+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
132+ assertNoBoxGlyphs(t, plain)
133+ if !strings.Contains(plain, "bash") || !strings.Contains(plain, "done") {
134+ t.Fatalf("flat transcript result lost header or output:\n%s", plain)
135+ }
136+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close