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 Unverified
89ACE636A8263BA0The 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 | |||
| 555 | 555 | ||
| 556 | 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 | 590 | ## Queued messages | |
| 559 | 591 | ||
| 560 | 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. | |
| @@ -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 messages | 590 | ## 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 { | |||
| 966 | 966 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 967 | 967 | RespectGitignore: initialCfg.RespectGitignore, | |
| 968 | 968 | ThemeName: initialCfg.Theme, | |
| 969 | + | FlatTools: initialCfg.FlatToolRender(), | |
| 969 | 970 | ExtensionThemes: extMgr.ThemeOptions, | |
| 970 | 971 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 971 | 972 | 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 { | |||
| 29 | 29 | Temperature *float32 `json:"temperature,omitempty"` | |
| 30 | 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 | 39 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | |
| 33 | 40 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |
| 34 | 41 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |
| @@ -97,6 +104,23 @@ func ZotHome() string { | |||
| 97 | 104 | // ConfigPath returns the path to config.json. | |
| 98 | 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 | 124 | // AuthPath returns the path to auth.json. | |
| 101 | 125 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | |
| 102 | 126 | ||
| @@ -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 by | 39 | // 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 { | |||
| 61 | 61 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 62 | 62 | 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 | 69 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 65 | 70 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 66 | 71 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| @@ -475,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 475 | 480 | view: &tui.View{ | |
| 476 | 481 | Theme: cfg.Theme, | |
| 477 | 482 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 483 | + | FlatTools: cfg.FlatTools, | |
| 478 | 484 | }, | |
| 479 | 485 | // Prompt is the standard half-block accent bar used by chat | |
| 480 | 486 | // 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 string | 62 | 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. The | 69 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | ||
| 65 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | 70 | // 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 chat | 485 | // Prompt is the standard half-block accent bar used by chat | ||
| 480 | // speaker labels too, so the input gutter matches the rest | 486 | // speaker labels too, so the input gutter matches the rest | ||
M packages/tui/view.go +110 -2
| @@ -132,6 +132,12 @@ type View struct { | |||
| 132 | 132 | // the map when a turn ends. | |
| 133 | 133 | 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 | 141 | // ExpandAll forces every long tool result to render in full. | |
| 136 | 142 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| 137 | 143 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |
| @@ -706,6 +712,17 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 706 | 712 | // from the matching ToolCallBlock so multiple calls | |
| 707 | 713 | // in one assistant message render as N adjacent boxes | |
| 708 | 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 | 726 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 710 | 727 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | |
| 711 | 728 | if tr.IsError { | |
| @@ -774,6 +791,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 774 | 791 | } | |
| 775 | 792 | 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 | 802 | for len(body) < high { | |
| 778 | 803 | body = append(body, toolBoxSide(v.Theme, "", width)) | |
| 779 | 804 | } | |
| @@ -790,6 +815,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 790 | 815 | // directly closed by the bottom. Avoids a blank interior row | |
| 791 | 816 | // for no-output tools. | |
| 792 | 817 | if tc.Result == "" { | |
| 818 | + | if v.FlatTools { | |
| 819 | + | lines = append(lines, flatToolHeader(v.Theme, label, width)) | |
| 820 | + | return lines | |
| 821 | + | } | |
| 793 | 822 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 794 | 823 | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| 795 | 824 | return lines | |
| @@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 800 | 829 | // vertical edges; bottom edge closes the box. Blank interior | |
| 801 | 830 | // rows after the top and before the bottom give the body a bit | |
| 802 | 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 | 832 | color := v.Theme.ToolOut | |
| 806 | 833 | if tc.Error { | |
| 807 | 834 | 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 | 847 | body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color) | |
| 810 | 848 | for _, l := range v.collapseToolBody(body, false) { | |
| 811 | 849 | imgCells, stripped := parseImageFootprint(l) | |
| @@ -863,6 +901,9 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 863 | 901 | ||
| 864 | 902 | func (v *View) renderLiveBashCommand(command string, width int) []string { | |
| 865 | 903 | inner := toolBoxBodyRenderWidth(width) | |
| 904 | + | if v.FlatTools { | |
| 905 | + | inner = flatToolBodyRenderWidth(width) | |
| 906 | + | } | |
| 866 | 907 | prompt := v.Theme.FG256(v.Theme.Muted, "$ ") | |
| 867 | 908 | var out []string | |
| 868 | 909 | for i, line := range strings.Split(command, "\n") { | |
| @@ -896,6 +937,13 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { | |||
| 896 | 937 | func (v *View) wrapLiveBody(body []string, width int) []string { | |
| 897 | 938 | body = v.collapseToolBody(body, false) | |
| 898 | 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 | 947 | for _, l := range body { | |
| 900 | 948 | imgCells, stripped := parseImageFootprint(l) | |
| 901 | 949 | if hasImageEscapeLine(stripped) { | |
| @@ -994,6 +1042,49 @@ func splitToolLabel(label string) (name, rest string) { | |||
| 994 | 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 | 1088 | // toolBoxBottom renders the bottom edge of a tool block: | |
| 998 | 1089 | // | |
| 999 | 1090 | // └─────────────────────────────────────────────────────────────────────────────┘ | |
| @@ -1080,6 +1171,20 @@ func toolBoxBodyRenderWidth(width int) int { | |||
| 1080 | 1171 | 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 | 1188 | // toolBoxSide wraps a single body line with vertical box edges: | |
| 1084 | 1189 | // | |
| 1085 | 1190 | // │ foo bar baz │ | |
| @@ -1202,6 +1307,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||
| 1202 | 1307 | switch bb := b.(type) { | |
| 1203 | 1308 | case provider.TextBlock: | |
| 1204 | 1309 | bodyWidth := toolBoxBodyRenderWidth(width) | |
| 1310 | + | if v.FlatTools { | |
| 1311 | + | bodyWidth = flatToolBodyRenderWidth(width) | |
| 1312 | + | } | |
| 1205 | 1313 | body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) | |
| 1206 | 1314 | case provider.ImageBlock: | |
| 1207 | 1315 | 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]int | 133 | 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 than | 142 | // Toggled from the tui by ctrl+o. When false, results longer than | ||
| 137 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | 143 | // 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 calls | 712 | // from the matching ToolCallBlock so multiple calls | ||
| 707 | // in one assistant message render as N adjacent boxes | 713 | // 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] = high | 792 | 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 row | 815 | // 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 lines | 824 | return lines | ||
| @@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 800 | // vertical edges; bottom edge closes the box. Blank interior | 829 | // vertical edges; bottom edge closes the box. Blank interior | ||
| 801 | // rows after the top and before the bottom give the body a bit | 830 | // 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.ToolOut | 832 | color := v.Theme.ToolOut | ||
| 806 | if tc.Error { | 833 | if tc.Error { | ||
| 807 | color = v.Theme.Error | 834 | 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 []string | 908 | 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 + toolBoxBodyTrimLeft | 1171 | 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 = true | 1315 | 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 | + | } | |||