Add setting for collapsed tool call rendering (#168)
* Add setting for collapsed tool call rendering * fix(tui): preserve collapsed tool layout and error status --------- Co-authored-by: patriceckhart <mail@patriceckhart.com>
- commit
- 9ef467de624a0145bca372932fa44fabacbba226
- author
- ggbalaazs · · 2026-09-06 07:57 UTC
- committer
- GitHub · · 2026-09-06 07:57 UTC
- parent
- e18c6ae
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.3.65
7 files · +275 -3
M README.md +1 -0
| @@ -415,6 +415,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 415 | 415 | ``` | |
| 416 | 416 | ||
| 417 | 417 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| 418 | + | - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`. | |
| 418 | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| 419 | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | |
| 420 | 421 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | |
| @@ -415,6 +415,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 415 | ``` | 415 | ``` | ||
| 416 | 416 | ||||
| 417 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 417 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 418 | + | - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`. | |||
| 418 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| 419 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | ||
| 420 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | 421 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | ||
M packages/agent/cli.go +1 -0
| @@ -1287,6 +1287,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1287 | 1287 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 1288 | 1288 | RespectGitignore: initialCfg.RespectGitignore, | |
| 1289 | 1289 | CompactMode: initialCfg.CompactMode, | |
| 1290 | + | CollapseToolCall: initialCfg.CollapseToolCall, | |
| 1290 | 1291 | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 1291 | 1292 | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 1292 | 1293 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| @@ -1287,6 +1287,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1287 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 1287 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| 1288 | RespectGitignore: initialCfg.RespectGitignore, | 1288 | RespectGitignore: initialCfg.RespectGitignore, | ||
| 1289 | CompactMode: initialCfg.CompactMode, | 1289 | CompactMode: initialCfg.CompactMode, | ||
| 1290 | + | CollapseToolCall: initialCfg.CollapseToolCall, | |||
| 1290 | TUIInputStyle: initialCfg.TUIInputStyle, | 1291 | TUIInputStyle: initialCfg.TUIInputStyle, | ||
| 1291 | TUIStatusPosition: initialCfg.TUIStatusPosition, | 1292 | TUIStatusPosition: initialCfg.TUIStatusPosition, | ||
| 1292 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | 1293 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | ||
M packages/agent/config.go +5 -0
| @@ -95,6 +95,11 @@ type Config struct { | |||
| 95 | 95 | // default; nil/missing means disabled. Toggle from /settings. | |
| 96 | 96 | CompactMode *bool `json:"compact_mode,omitempty"` | |
| 97 | 97 | ||
| 98 | + | // CollapseToolCall retains the tool-call header, a final preview line, | |
| 99 | + | // and failure status. Off by default; nil/missing means disabled. | |
| 100 | + | // Toggle from /settings. | |
| 101 | + | CollapseToolCall *bool `json:"collapse_tool_call,omitempty"` | |
| 102 | + | ||
| 98 | 103 | // ShowInstructionsAtStartup lists loaded context files, extensions, | |
| 99 | 104 | // and user-installed skills above the transcript. Off by default; | |
| 100 | 105 | // nil/missing means disabled. Toggle from /settings. | |
| @@ -95,6 +95,11 @@ type Config struct { | |||||
| 95 | // default; nil/missing means disabled. Toggle from /settings. | 95 | // default; nil/missing means disabled. Toggle from /settings. | ||
| 96 | CompactMode *bool `json:"compact_mode,omitempty"` | 96 | CompactMode *bool `json:"compact_mode,omitempty"` | ||
| 97 | 97 | ||||
| 98 | + | // CollapseToolCall retains the tool-call header, a final preview line, | |||
| 99 | + | // and failure status. Off by default; nil/missing means disabled. | |||
| 100 | + | // Toggle from /settings. | |||
| 101 | + | CollapseToolCall *bool `json:"collapse_tool_call,omitempty"` | |||
| 102 | + | ||||
| 98 | // ShowInstructionsAtStartup lists loaded context files, extensions, | 103 | // ShowInstructionsAtStartup lists loaded context files, extensions, | ||
| 99 | // and user-installed skills above the transcript. Off by default; | 104 | // and user-installed skills above the transcript. Off by default; | ||
| 100 | // nil/missing means disabled. Toggle from /settings. | 105 | // nil/missing means disabled. Toggle from /settings. | ||
M packages/agent/modes/interactive.go +30 -0
| @@ -111,6 +111,10 @@ type InteractiveConfig struct { | |||
| 111 | 111 | // When true, it enables both flat tool calls and compact user turns. | |
| 112 | 112 | CompactMode *bool | |
| 113 | 113 | ||
| 114 | + | // CollapseToolCall retains each tool-call header, a final preview line, | |
| 115 | + | // and failure status while hiding the rest of the body. | |
| 116 | + | CollapseToolCall *bool | |
| 117 | + | ||
| 114 | 118 | // TUIInputStyle controls the main input rendering: plain or lines. | |
| 115 | 119 | TUIInputStyle string | |
| 116 | 120 | ||
| @@ -350,6 +354,7 @@ type SettingsStore interface { | |||
| 350 | 354 | SetRecursiveFileSuggest(enabled bool) error | |
| 351 | 355 | SetRespectGitignore(enabled bool) error | |
| 352 | 356 | SetCompactMode(enabled bool) error | |
| 357 | + | SetCollapseToolCall(enabled bool) error | |
| 353 | 358 | SetTUIInputStyle(style string) error | |
| 354 | 359 | SetTUIStatusPosition(position string) error | |
| 355 | 360 | SetTUIWorkingPosition(position string) error | |
| @@ -626,6 +631,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 626 | 631 | FlatTools: cfg.FlatTools, | |
| 627 | 632 | CompactUser: cfg.CompactUser, | |
| 628 | 633 | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | |
| 634 | + | CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall, | |
| 629 | 635 | StartupAgentName: startupAgentName, | |
| 630 | 636 | StartupContextPaths: startupContextPaths, | |
| 631 | 637 | StartupExtensionNames: startupExtensionNames, | |
| @@ -3416,6 +3422,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3416 | 3422 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 3417 | 3423 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 3418 | 3424 | compactMode := i.compactModeEnabled() | |
| 3425 | + | collapseToolCall := i.cfg.CollapseToolCall != nil && *i.cfg.CollapseToolCall | |
| 3419 | 3426 | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | |
| 3420 | 3427 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 3421 | 3428 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| @@ -3557,6 +3564,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3557 | 3564 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | |
| 3558 | 3565 | value: compactMode, | |
| 3559 | 3566 | }, | |
| 3567 | + | { | |
| 3568 | + | key: "collapse_tool_call", | |
| 3569 | + | label: "collapse tool call rendering", | |
| 3570 | + | desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output", | |
| 3571 | + | value: collapseToolCall, | |
| 3572 | + | }, | |
| 3560 | 3573 | { | |
| 3561 | 3574 | key: "show_instructions_at_startup", | |
| 3562 | 3575 | label: "show loaded resources at startup", | |
| @@ -3966,6 +3979,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3966 | 3979 | i.statusOK = "compact transcript rendering " + onOff(value) | |
| 3967 | 3980 | i.statusErr = "" | |
| 3968 | 3981 | i.mu.Unlock() | |
| 3982 | + | case "collapse_tool_call": | |
| 3983 | + | val := value | |
| 3984 | + | i.cfg.CollapseToolCall = &val | |
| 3985 | + | if i.cfg.SettingsStore != nil { | |
| 3986 | + | if err := i.cfg.SettingsStore.SetCollapseToolCall(value); err != nil { | |
| 3987 | + | i.mu.Lock() | |
| 3988 | + | i.statusErr = "settings: " + err.Error() | |
| 3989 | + | i.mu.Unlock() | |
| 3990 | + | return | |
| 3991 | + | } | |
| 3992 | + | } | |
| 3993 | + | i.mu.Lock() | |
| 3994 | + | i.view.CollapseToolCall = value | |
| 3995 | + | i.view.InvalidateRenderCache() | |
| 3996 | + | i.statusOK = "collapse tool call rendering " + onOff(value) | |
| 3997 | + | i.statusErr = "" | |
| 3998 | + | i.mu.Unlock() | |
| 3969 | 3999 | case "show_instructions_at_startup": | |
| 3970 | 4000 | val := value | |
| 3971 | 4001 | i.cfg.ShowInstructionsAtStartup = &val | |
| @@ -111,6 +111,10 @@ type InteractiveConfig struct { | |||||
| 111 | // When true, it enables both flat tool calls and compact user turns. | 111 | // When true, it enables both flat tool calls and compact user turns. | ||
| 112 | CompactMode *bool | 112 | CompactMode *bool | ||
| 113 | 113 | ||||
| 114 | + | // CollapseToolCall retains each tool-call header, a final preview line, | |||
| 115 | + | // and failure status while hiding the rest of the body. | |||
| 116 | + | CollapseToolCall *bool | |||
| 117 | + | ||||
| 114 | // TUIInputStyle controls the main input rendering: plain or lines. | 118 | // TUIInputStyle controls the main input rendering: plain or lines. | ||
| 115 | TUIInputStyle string | 119 | TUIInputStyle string | ||
| 116 | 120 | ||||
| @@ -350,6 +354,7 @@ type SettingsStore interface { | |||||
| 350 | SetRecursiveFileSuggest(enabled bool) error | 354 | SetRecursiveFileSuggest(enabled bool) error | ||
| 351 | SetRespectGitignore(enabled bool) error | 355 | SetRespectGitignore(enabled bool) error | ||
| 352 | SetCompactMode(enabled bool) error | 356 | SetCompactMode(enabled bool) error | ||
| 357 | + | SetCollapseToolCall(enabled bool) error | |||
| 353 | SetTUIInputStyle(style string) error | 358 | SetTUIInputStyle(style string) error | ||
| 354 | SetTUIStatusPosition(position string) error | 359 | SetTUIStatusPosition(position string) error | ||
| 355 | SetTUIWorkingPosition(position string) error | 360 | SetTUIWorkingPosition(position string) error | ||
| @@ -626,6 +631,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 626 | FlatTools: cfg.FlatTools, | 631 | FlatTools: cfg.FlatTools, | ||
| 627 | CompactUser: cfg.CompactUser, | 632 | CompactUser: cfg.CompactUser, | ||
| 628 | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | 633 | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | ||
| 634 | + | CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall, | |||
| 629 | StartupAgentName: startupAgentName, | 635 | StartupAgentName: startupAgentName, | ||
| 630 | StartupContextPaths: startupContextPaths, | 636 | StartupContextPaths: startupContextPaths, | ||
| 631 | StartupExtensionNames: startupExtensionNames, | 637 | StartupExtensionNames: startupExtensionNames, | ||
| @@ -3416,6 +3422,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3416 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 3422 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 3417 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 3423 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | ||
| 3418 | compactMode := i.compactModeEnabled() | 3424 | compactMode := i.compactModeEnabled() | ||
| 3425 | + | collapseToolCall := i.cfg.CollapseToolCall != nil && *i.cfg.CollapseToolCall | |||
| 3419 | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | 3426 | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | ||
| 3420 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | 3427 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | ||
| 3421 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | 3428 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | ||
| @@ -3557,6 +3564,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3557 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | 3564 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | ||
| 3558 | value: compactMode, | 3565 | value: compactMode, | ||
| 3559 | }, | 3566 | }, | ||
| 3567 | + | { | |||
| 3568 | + | key: "collapse_tool_call", | |||
| 3569 | + | label: "collapse tool call rendering", | |||
| 3570 | + | desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output", | |||
| 3571 | + | value: collapseToolCall, | |||
| 3572 | + | }, | |||
| 3560 | { | 3573 | { | ||
| 3561 | key: "show_instructions_at_startup", | 3574 | key: "show_instructions_at_startup", | ||
| 3562 | label: "show loaded resources at startup", | 3575 | label: "show loaded resources at startup", | ||
| @@ -3966,6 +3979,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 3966 | i.statusOK = "compact transcript rendering " + onOff(value) | 3979 | i.statusOK = "compact transcript rendering " + onOff(value) | ||
| 3967 | i.statusErr = "" | 3980 | i.statusErr = "" | ||
| 3968 | i.mu.Unlock() | 3981 | i.mu.Unlock() | ||
| 3982 | + | case "collapse_tool_call": | |||
| 3983 | + | val := value | |||
| 3984 | + | i.cfg.CollapseToolCall = &val | |||
| 3985 | + | if i.cfg.SettingsStore != nil { | |||
| 3986 | + | if err := i.cfg.SettingsStore.SetCollapseToolCall(value); err != nil { | |||
| 3987 | + | i.mu.Lock() | |||
| 3988 | + | i.statusErr = "settings: " + err.Error() | |||
| 3989 | + | i.mu.Unlock() | |||
| 3990 | + | return | |||
| 3991 | + | } | |||
| 3992 | + | } | |||
| 3993 | + | i.mu.Lock() | |||
| 3994 | + | i.view.CollapseToolCall = value | |||
| 3995 | + | i.view.InvalidateRenderCache() | |||
| 3996 | + | i.statusOK = "collapse tool call rendering " + onOff(value) | |||
| 3997 | + | i.statusErr = "" | |||
| 3998 | + | i.mu.Unlock() | |||
| 3969 | case "show_instructions_at_startup": | 3999 | case "show_instructions_at_startup": | ||
| 3970 | val := value | 4000 | val := value | ||
| 3971 | i.cfg.ShowInstructionsAtStartup = &val | 4001 | i.cfg.ShowInstructionsAtStartup = &val | ||
M packages/agent/settings_store.go +9 -0
| @@ -104,6 +104,15 @@ func (configSettingsStore) SetCompactMode(enabled bool) error { | |||
| 104 | 104 | return SaveConfig(cfg) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | + | func (configSettingsStore) SetCollapseToolCall(enabled bool) error { | |
| 108 | + | cfg, err := LoadConfig() | |
| 109 | + | if err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | cfg.CollapseToolCall = &enabled | |
| 113 | + | return SaveConfig(cfg) | |
| 114 | + | } | |
| 115 | + | ||
| 107 | 116 | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | |
| 108 | 117 | cfg, err := LoadConfig() | |
| 109 | 118 | if err != nil { | |
| @@ -104,6 +104,15 @@ func (configSettingsStore) SetCompactMode(enabled bool) error { | |||||
| 104 | return SaveConfig(cfg) | 104 | return SaveConfig(cfg) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | + | func (configSettingsStore) SetCollapseToolCall(enabled bool) error { | |||
| 108 | + | cfg, err := LoadConfig() | |||
| 109 | + | if err != nil { | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | cfg.CollapseToolCall = &enabled | |||
| 113 | + | return SaveConfig(cfg) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 107 | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | 116 | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | ||
| 108 | cfg, err := LoadConfig() | 117 | cfg, err := LoadConfig() | ||
| 109 | if err != nil { | 118 | if err != nil { | ||
M packages/tui/view.go +84 -3
| @@ -158,6 +158,10 @@ type View struct { | |||
| 158 | 158 | // the current spacious rendering. | |
| 159 | 159 | CompactMode bool | |
| 160 | 160 | ||
| 161 | + | // CollapseToolCall retains each tool-call header, the final rendered | |
| 162 | + | // preview line, and an explicit error indicator for failed calls. | |
| 163 | + | CollapseToolCall bool | |
| 164 | + | ||
| 161 | 165 | // ExpandAll forces every long tool result to render in full. | |
| 162 | 166 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| 163 | 167 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |
| @@ -790,6 +794,11 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 790 | 794 | // from the matching ToolCallBlock so multiple calls | |
| 791 | 795 | // in one assistant message render as N adjacent boxes | |
| 792 | 796 | // instead of stacking unclosed top edges. | |
| 797 | + | if v.CollapseToolCall && !v.ExpandAll { | |
| 798 | + | body := v.renderToolResultContent(tr.Content, width, color, path, startLine) | |
| 799 | + | lines = append(lines, v.renderCollapsedTool(label, body, width, tr.IsError)...) | |
| 800 | + | continue | |
| 801 | + | } | |
| 793 | 802 | if v.FlatTools || v.CompactMode { | |
| 794 | 803 | if v.CompactMode { | |
| 795 | 804 | lines = append(lines, compactToolBlank(v.Theme, width)) | |
| @@ -858,6 +867,29 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 858 | 867 | } | |
| 859 | 868 | label := tc.Name + " " + arg | |
| 860 | 869 | ||
| 870 | + | if v.CollapseToolCall && !v.ExpandAll { | |
| 871 | + | bodyText := tc.Result | |
| 872 | + | if bodyText == "" { | |
| 873 | + | bodyText = tc.Preview | |
| 874 | + | } | |
| 875 | + | var body []string | |
| 876 | + | if bodyText != "" { | |
| 877 | + | color := v.Theme.ToolOut | |
| 878 | + | if tc.Error { | |
| 879 | + | color = v.Theme.Error | |
| 880 | + | } | |
| 881 | + | bodyWidth := toolBoxBodyRenderWidth(width) | |
| 882 | + | if v.FlatTools || v.CompactMode { | |
| 883 | + | bodyWidth = flatToolBodyRenderWidth(width) | |
| 884 | + | } | |
| 885 | + | body = v.renderLiveToolResult(bodyText, bodyWidth, color, tc.LivePath) | |
| 886 | + | } else { | |
| 887 | + | body = v.renderLiveToolContent(tc, width) | |
| 888 | + | } | |
| 889 | + | body = v.collapseToolBody(body, false) | |
| 890 | + | return v.renderCollapsedTool(label, body, width, tc.Error) | |
| 891 | + | } | |
| 892 | + | ||
| 861 | 893 | // No leading blank: Build()'s inter-message separator already | |
| 862 | 894 | // places one blank row between the previous transcript content | |
| 863 | 895 | // and the live overlay, matching the spacing the same call has | |
| @@ -995,13 +1027,19 @@ func (v *View) renderLiveToolResult(text string, width, color int, sourcePath st | |||
| 995 | 1027 | // | |
| 996 | 1028 | // Anything else returns nil and only the tool-call header shows. | |
| 997 | 1029 | func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |
| 1030 | + | return v.wrapLiveBody(v.renderLiveToolContent(tc, width), width) | |
| 1031 | + | } | |
| 1032 | + | ||
| 1033 | + | // renderLiveToolContent returns unframed preview rows so collapsed calls | |
| 1034 | + | // can select their preview before applying borders or background padding. | |
| 1035 | + | func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string { | |
| 998 | 1036 | switch tc.Name { | |
| 999 | 1037 | case "write", "Write": | |
| 1000 | 1038 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | |
| 1001 | 1039 | if !ok || partial == "" { | |
| 1002 | 1040 | return nil | |
| 1003 | 1041 | } | |
| 1004 | - | return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width) | |
| 1042 | + | return v.renderRawFile(partial, tc.LivePath, 1) | |
| 1005 | 1043 | case "edit", "Edit": | |
| 1006 | 1044 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | |
| 1007 | 1045 | if !ok || partial == "" { | |
| @@ -1012,13 +1050,13 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 1012 | 1050 | hint := fmt.Sprintf("edit %d (streaming)", idx) | |
| 1013 | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 1014 | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 1015 | - | return v.wrapLiveBody(body, width) | |
| 1053 | + | return body | |
| 1016 | 1054 | case "bash", "Bash": | |
| 1017 | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | |
| 1018 | 1056 | if !ok || strings.TrimSpace(command) == "" { | |
| 1019 | 1057 | return nil | |
| 1020 | 1058 | } | |
| 1021 | - | return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width) | |
| 1059 | + | return v.renderLiveBashCommand(command, width) | |
| 1022 | 1060 | } | |
| 1023 | 1061 | return nil | |
| 1024 | 1062 | } | |
| @@ -1106,6 +1144,49 @@ const toolBoxInnerPad = 1 | |||
| 1106 | 1144 | // running edge-to-edge while user/assistant rows sit indented. | |
| 1107 | 1145 | const toolBoxOuterMargin = 2 | |
| 1108 | 1146 | ||
| 1147 | + | // renderCollapsedTool retains the final meaningful preview row and failure | |
| 1148 | + | // status, applying the configured layout once to unframed body content. | |
| 1149 | + | func (v *View) renderCollapsedTool(label string, body []string, width int, isError bool) []string { | |
| 1150 | + | th := v.Theme | |
| 1151 | + | last := "" | |
| 1152 | + | for i := len(body) - 1; i >= 0; i-- { | |
| 1153 | + | if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 { | |
| 1154 | + | last = line | |
| 1155 | + | break | |
| 1156 | + | } | |
| 1157 | + | } | |
| 1158 | + | var preview []string | |
| 1159 | + | if isError { | |
| 1160 | + | preview = append(preview, th.FG256(th.Error, " error")) | |
| 1161 | + | } | |
| 1162 | + | if last != "" { | |
| 1163 | + | preview = append(preview, last) | |
| 1164 | + | } | |
| 1165 | + | if v.FlatTools || v.CompactMode { | |
| 1166 | + | var out []string | |
| 1167 | + | if v.CompactMode { | |
| 1168 | + | out = append(out, compactToolBlank(th, width)) | |
| 1169 | + | } | |
| 1170 | + | out = append(out, toolHeaderLine(th, label, width, v.CompactMode)) | |
| 1171 | + | if v.CompactMode && len(preview) > 0 { | |
| 1172 | + | out = append(out, compactToolBlank(th, width)) | |
| 1173 | + | } | |
| 1174 | + | for _, line := range preview { | |
| 1175 | + | out = append(out, toolBodyLine(th, line, width, v.CompactMode)) | |
| 1176 | + | } | |
| 1177 | + | if v.CompactMode { | |
| 1178 | + | out = append(out, compactToolBlank(th, width)) | |
| 1179 | + | } | |
| 1180 | + | return out | |
| 1181 | + | } | |
| 1182 | + | out := []string{toolBoxTop(th, label, width), toolBoxSide(th, "", width)} | |
| 1183 | + | for _, line := range preview { | |
| 1184 | + | out = append(out, toolBoxSide(th, line, width)) | |
| 1185 | + | } | |
| 1186 | + | out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width)) | |
| 1187 | + | return out | |
| 1188 | + | } | |
| 1189 | + | ||
| 1109 | 1190 | // toolBoxTop renders the labelled top edge of a tool block: | |
| 1110 | 1191 | // | |
| 1111 | 1192 | // ┌─ bash xcrun simctl list devices ─────────────────────────────┐ | |
| @@ -158,6 +158,10 @@ type View struct { | |||||
| 158 | // the current spacious rendering. | 158 | // the current spacious rendering. | ||
| 159 | CompactMode bool | 159 | CompactMode bool | ||
| 160 | 160 | ||||
| 161 | + | // CollapseToolCall retains each tool-call header, the final rendered | |||
| 162 | + | // preview line, and an explicit error indicator for failed calls. | |||
| 163 | + | CollapseToolCall bool | |||
| 164 | + | ||||
| 161 | // ExpandAll forces every long tool result to render in full. | 165 | // ExpandAll forces every long tool result to render in full. | ||
| 162 | // Toggled from the tui by ctrl+o. When false, results longer than | 166 | // Toggled from the tui by ctrl+o. When false, results longer than | ||
| 163 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | 167 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | ||
| @@ -790,6 +794,11 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 790 | // from the matching ToolCallBlock so multiple calls | 794 | // from the matching ToolCallBlock so multiple calls | ||
| 791 | // in one assistant message render as N adjacent boxes | 795 | // in one assistant message render as N adjacent boxes | ||
| 792 | // instead of stacking unclosed top edges. | 796 | // instead of stacking unclosed top edges. | ||
| 797 | + | if v.CollapseToolCall && !v.ExpandAll { | |||
| 798 | + | body := v.renderToolResultContent(tr.Content, width, color, path, startLine) | |||
| 799 | + | lines = append(lines, v.renderCollapsedTool(label, body, width, tr.IsError)...) | |||
| 800 | + | continue | |||
| 801 | + | } | |||
| 793 | if v.FlatTools || v.CompactMode { | 802 | if v.FlatTools || v.CompactMode { | ||
| 794 | if v.CompactMode { | 803 | if v.CompactMode { | ||
| 795 | lines = append(lines, compactToolBlank(v.Theme, width)) | 804 | lines = append(lines, compactToolBlank(v.Theme, width)) | ||
| @@ -858,6 +867,29 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 858 | } | 867 | } | ||
| 859 | label := tc.Name + " " + arg | 868 | label := tc.Name + " " + arg | ||
| 860 | 869 | ||||
| 870 | + | if v.CollapseToolCall && !v.ExpandAll { | |||
| 871 | + | bodyText := tc.Result | |||
| 872 | + | if bodyText == "" { | |||
| 873 | + | bodyText = tc.Preview | |||
| 874 | + | } | |||
| 875 | + | var body []string | |||
| 876 | + | if bodyText != "" { | |||
| 877 | + | color := v.Theme.ToolOut | |||
| 878 | + | if tc.Error { | |||
| 879 | + | color = v.Theme.Error | |||
| 880 | + | } | |||
| 881 | + | bodyWidth := toolBoxBodyRenderWidth(width) | |||
| 882 | + | if v.FlatTools || v.CompactMode { | |||
| 883 | + | bodyWidth = flatToolBodyRenderWidth(width) | |||
| 884 | + | } | |||
| 885 | + | body = v.renderLiveToolResult(bodyText, bodyWidth, color, tc.LivePath) | |||
| 886 | + | } else { | |||
| 887 | + | body = v.renderLiveToolContent(tc, width) | |||
| 888 | + | } | |||
| 889 | + | body = v.collapseToolBody(body, false) | |||
| 890 | + | return v.renderCollapsedTool(label, body, width, tc.Error) | |||
| 891 | + | } | |||
| 892 | + | ||||
| 861 | // No leading blank: Build()'s inter-message separator already | 893 | // No leading blank: Build()'s inter-message separator already | ||
| 862 | // places one blank row between the previous transcript content | 894 | // places one blank row between the previous transcript content | ||
| 863 | // and the live overlay, matching the spacing the same call has | 895 | // and the live overlay, matching the spacing the same call has | ||
| @@ -995,13 +1027,19 @@ func (v *View) renderLiveToolResult(text string, width, color int, sourcePath st | |||||
| 995 | // | 1027 | // | ||
| 996 | // Anything else returns nil and only the tool-call header shows. | 1028 | // Anything else returns nil and only the tool-call header shows. | ||
| 997 | func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | 1029 | func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | ||
| 1030 | + | return v.wrapLiveBody(v.renderLiveToolContent(tc, width), width) | |||
| 1031 | + | } | |||
| 1032 | + | ||||
| 1033 | + | // renderLiveToolContent returns unframed preview rows so collapsed calls | |||
| 1034 | + | // can select their preview before applying borders or background padding. | |||
| 1035 | + | func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string { | |||
| 998 | switch tc.Name { | 1036 | switch tc.Name { | ||
| 999 | case "write", "Write": | 1037 | case "write", "Write": | ||
| 1000 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | 1038 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | ||
| 1001 | if !ok || partial == "" { | 1039 | if !ok || partial == "" { | ||
| 1002 | return nil | 1040 | return nil | ||
| 1003 | } | 1041 | } | ||
| 1004 | - | return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width) | 1042 | + | return v.renderRawFile(partial, tc.LivePath, 1) |
| 1005 | case "edit", "Edit": | 1043 | case "edit", "Edit": | ||
| 1006 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | 1044 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | ||
| 1007 | if !ok || partial == "" { | 1045 | if !ok || partial == "" { | ||
| @@ -1012,13 +1050,13 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||||
| 1012 | hint := fmt.Sprintf("edit %d (streaming)", idx) | 1050 | hint := fmt.Sprintf("edit %d (streaming)", idx) | ||
| 1013 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | ||
| 1014 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | ||
| 1015 | - | return v.wrapLiveBody(body, width) | 1053 | + | return body |
| 1016 | case "bash", "Bash": | 1054 | case "bash", "Bash": | ||
| 1017 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | ||
| 1018 | if !ok || strings.TrimSpace(command) == "" { | 1056 | if !ok || strings.TrimSpace(command) == "" { | ||
| 1019 | return nil | 1057 | return nil | ||
| 1020 | } | 1058 | } | ||
| 1021 | - | return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width) | 1059 | + | return v.renderLiveBashCommand(command, width) |
| 1022 | } | 1060 | } | ||
| 1023 | return nil | 1061 | return nil | ||
| 1024 | } | 1062 | } | ||
| @@ -1106,6 +1144,49 @@ const toolBoxInnerPad = 1 | |||||
| 1106 | // running edge-to-edge while user/assistant rows sit indented. | 1144 | // running edge-to-edge while user/assistant rows sit indented. | ||
| 1107 | const toolBoxOuterMargin = 2 | 1145 | const toolBoxOuterMargin = 2 | ||
| 1108 | 1146 | ||||
| 1147 | + | // renderCollapsedTool retains the final meaningful preview row and failure | |||
| 1148 | + | // status, applying the configured layout once to unframed body content. | |||
| 1149 | + | func (v *View) renderCollapsedTool(label string, body []string, width int, isError bool) []string { | |||
| 1150 | + | th := v.Theme | |||
| 1151 | + | last := "" | |||
| 1152 | + | for i := len(body) - 1; i >= 0; i-- { | |||
| 1153 | + | if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 { | |||
| 1154 | + | last = line | |||
| 1155 | + | break | |||
| 1156 | + | } | |||
| 1157 | + | } | |||
| 1158 | + | var preview []string | |||
| 1159 | + | if isError { | |||
| 1160 | + | preview = append(preview, th.FG256(th.Error, " error")) | |||
| 1161 | + | } | |||
| 1162 | + | if last != "" { | |||
| 1163 | + | preview = append(preview, last) | |||
| 1164 | + | } | |||
| 1165 | + | if v.FlatTools || v.CompactMode { | |||
| 1166 | + | var out []string | |||
| 1167 | + | if v.CompactMode { | |||
| 1168 | + | out = append(out, compactToolBlank(th, width)) | |||
| 1169 | + | } | |||
| 1170 | + | out = append(out, toolHeaderLine(th, label, width, v.CompactMode)) | |||
| 1171 | + | if v.CompactMode && len(preview) > 0 { | |||
| 1172 | + | out = append(out, compactToolBlank(th, width)) | |||
| 1173 | + | } | |||
| 1174 | + | for _, line := range preview { | |||
| 1175 | + | out = append(out, toolBodyLine(th, line, width, v.CompactMode)) | |||
| 1176 | + | } | |||
| 1177 | + | if v.CompactMode { | |||
| 1178 | + | out = append(out, compactToolBlank(th, width)) | |||
| 1179 | + | } | |||
| 1180 | + | return out | |||
| 1181 | + | } | |||
| 1182 | + | out := []string{toolBoxTop(th, label, width), toolBoxSide(th, "", width)} | |||
| 1183 | + | for _, line := range preview { | |||
| 1184 | + | out = append(out, toolBoxSide(th, line, width)) | |||
| 1185 | + | } | |||
| 1186 | + | out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width)) | |||
| 1187 | + | return out | |||
| 1188 | + | } | |||
| 1189 | + | ||||
| 1109 | // toolBoxTop renders the labelled top edge of a tool block: | 1190 | // toolBoxTop renders the labelled top edge of a tool block: | ||
| 1110 | // | 1191 | // | ||
| 1111 | // ┌─ bash xcrun simctl list devices ─────────────────────────────┐ | 1192 | // ┌─ bash xcrun simctl list devices ─────────────────────────────┐ | ||
A packages/tui/view_collapse_tools_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestCollapseToolCallPreservesLayout(t *testing.T) { | |
| 12 | + | for _, layout := range []string{"box", "flat", "compact"} { | |
| 13 | + | for _, tool := range []struct { | |
| 14 | + | name, args, preview string | |
| 15 | + | }{ | |
| 16 | + | {"bash", `{"command":"printf hello"}`, "$ printf hello"}, | |
| 17 | + | {"write", `{"path":"test.txt","content":"hello"}`, "hello"}, | |
| 18 | + | {"edit", `{"path":"test.txt","edits":[{"oldText":"old","newText":"hello"}]}`, "hello"}, | |
| 19 | + | } { | |
| 20 | + | t.Run(layout+"/"+tool.name, func(t *testing.T) { | |
| 21 | + | v := View{Theme: Dark, CollapseToolCall: true, FlatTools: layout == "flat", CompactMode: layout == "compact"} | |
| 22 | + | for _, expanded := range []bool{false, true} { | |
| 23 | + | v.ExpandAll = expanded | |
| 24 | + | rows := v.RenderToolCall(ToolCallView{Name: tool.name, RawJSONBuf: tool.args}, 80) | |
| 25 | + | found := false | |
| 26 | + | for _, row := range rows { | |
| 27 | + | plain := stripANSI(row) | |
| 28 | + | if strings.Contains(plain, tool.preview) { | |
| 29 | + | found = true | |
| 30 | + | want := 0 | |
| 31 | + | if layout == "box" { | |
| 32 | + | want = 2 | |
| 33 | + | } | |
| 34 | + | if got := strings.Count(plain, "│"); got != want { | |
| 35 | + | t.Errorf("expanded=%v: got %d borders, want %d: %s", expanded, got, want, plain) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | if layout != "box" && strings.ContainsAny(plain, "┌┐└┘│") { | |
| 39 | + | t.Errorf("expanded=%v: unexpected frame: %s", expanded, plain) | |
| 40 | + | } | |
| 41 | + | if visibleWidth(row) > 80 { | |
| 42 | + | t.Errorf("expanded=%v: row exceeds terminal width: %s", expanded, plain) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | if !found { | |
| 46 | + | t.Fatalf("expanded=%v: missing preview: %v", expanded, rows) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestCollapseToolCallPreservesErrors(t *testing.T) { | |
| 55 | + | for _, layout := range []string{"box", "flat", "compact"} { | |
| 56 | + | for _, text := range []string{"", "failed", strings.Repeat("output\n", ToolCollapseLines+1)} { | |
| 57 | + | for _, live := range []bool{false, true} { | |
| 58 | + | v := View{Theme: Dark, CollapseToolCall: true, FlatTools: layout == "flat", CompactMode: layout == "compact"} | |
| 59 | + | var rows []string | |
| 60 | + | if live { | |
| 61 | + | rows = v.RenderToolCall(ToolCallView{Name: "bash", Result: text, Error: true}, 80) | |
| 62 | + | } else { | |
| 63 | + | rows = v.renderMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 64 | + | provider.ToolResultBlock{IsError: true, Content: []provider.Content{provider.TextBlock{Text: text}}}, | |
| 65 | + | }}, 80, false) | |
| 66 | + | } | |
| 67 | + | rendered := strings.Join(rows, "\n") | |
| 68 | + | if !strings.Contains(rendered, v.Theme.FG256(v.Theme.Error, "error")) { | |
| 69 | + | t.Errorf("%s live=%v: missing error indicator: %s", layout, live, stripANSI(rendered)) | |
| 70 | + | } | |
| 71 | + | if text == "failed" && !strings.Contains(rendered, v.Theme.FG256(v.Theme.Error, text)) { | |
| 72 | + | t.Errorf("%s live=%v: missing error styling: %q", layout, live, rendered) | |
| 73 | + | } | |
| 74 | + | if layout != "box" && strings.ContainsAny(stripANSI(rendered), "┌┐└┘│") { | |
| 75 | + | t.Errorf("%s live=%v: unexpected frame: %s", layout, live, stripANSI(rendered)) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) { | |
| 83 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |
| 84 | + | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | |
| 85 | + | ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "first-output\nlast-output\n", | |
| 86 | + | }}} | |
| 87 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |
| 88 | + | if !strings.Contains(plain, "bash") { | |
| 89 | + | t.Fatalf("collapsed render lost tool header:\n%s", plain) | |
| 90 | + | } | |
| 91 | + | if !strings.Contains(plain, "last-output") || strings.Contains(plain, "first-output") { | |
| 92 | + | t.Fatalf("collapsed render did not show only the last result line:\n%s", plain) | |
| 93 | + | } | |
| 94 | + | if !strings.ContainsAny(plain, "┌┐") || !strings.ContainsAny(plain, "└┘") || !strings.Contains(plain, "│") { | |
| 95 | + | t.Fatalf("collapsed box render did not retain the complete empty frame:\n%s", plain) | |
| 96 | + | } | |
| 97 | + | v.ExpandAll = true | |
| 98 | + | expanded := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |
| 99 | + | if !strings.Contains(expanded, "first-output") { | |
| 100 | + | t.Fatalf("ctrl-o expansion did not reveal non-compact collapsed tool result:\n%s", expanded) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestCollapseToolCallShowsLastCompactPreviewLine(t *testing.T) { | |
| 105 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |
| 106 | + | v := View{ | |
| 107 | + | Theme: Dark, | |
| 108 | + | CompactMode: true, | |
| 109 | + | CollapseToolCall: true, | |
| 110 | + | Messages: []provider.Message{ | |
| 111 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 112 | + | provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args}, | |
| 113 | + | }}, | |
| 114 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 115 | + | provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{provider.TextBlock{Text: "first-output\nlast-output"}}}, | |
| 116 | + | }}, | |
| 117 | + | }, | |
| 118 | + | } | |
| 119 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |
| 120 | + | if !strings.Contains(plain, "bash") { | |
| 121 | + | t.Fatalf("collapsed compact render lost tool header:\n%s", plain) | |
| 122 | + | } | |
| 123 | + | if !strings.Contains(plain, "last-output") || strings.Contains(plain, "first-output") { | |
| 124 | + | t.Fatalf("collapsed compact render did not show only the last result line:\n%s", plain) | |
| 125 | + | } | |
| 126 | + | v.ExpandAll = true | |
| 127 | + | expanded := stripANSI(strings.Join(v.Build(80), "\n")) | |
| 128 | + | if !strings.Contains(expanded, "first-output") { | |
| 129 | + | t.Fatalf("ctrl-o expansion did not reveal collapsed tool result:\n%s", expanded) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestCollapseToolCallShowsLastLivePreviewLine(t *testing.T) { | |
| 134 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |
| 135 | + | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | |
| 136 | + | ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args), | |
| 137 | + | }}} | |
| 138 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |
| 139 | + | if !strings.Contains(plain, "$ echo") { | |
| 140 | + | t.Fatalf("collapsed live render lost its one-line preview:\n%s", plain) | |
| 141 | + | } | |
| 142 | + | if !strings.Contains(plain, "bash") { | |
| 143 | + | t.Fatalf("collapsed live render lost tool header:\n%s", plain) | |
| 144 | + | } | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCollapseToolCallPreservesLayout(t *testing.T) { | |||
| 12 | + | for _, layout := range []string{"box", "flat", "compact"} { | |||
| 13 | + | for _, tool := range []struct { | |||
| 14 | + | name, args, preview string | |||
| 15 | + | }{ | |||
| 16 | + | {"bash", `{"command":"printf hello"}`, "$ printf hello"}, | |||
| 17 | + | {"write", `{"path":"test.txt","content":"hello"}`, "hello"}, | |||
| 18 | + | {"edit", `{"path":"test.txt","edits":[{"oldText":"old","newText":"hello"}]}`, "hello"}, | |||
| 19 | + | } { | |||
| 20 | + | t.Run(layout+"/"+tool.name, func(t *testing.T) { | |||
| 21 | + | v := View{Theme: Dark, CollapseToolCall: true, FlatTools: layout == "flat", CompactMode: layout == "compact"} | |||
| 22 | + | for _, expanded := range []bool{false, true} { | |||
| 23 | + | v.ExpandAll = expanded | |||
| 24 | + | rows := v.RenderToolCall(ToolCallView{Name: tool.name, RawJSONBuf: tool.args}, 80) | |||
| 25 | + | found := false | |||
| 26 | + | for _, row := range rows { | |||
| 27 | + | plain := stripANSI(row) | |||
| 28 | + | if strings.Contains(plain, tool.preview) { | |||
| 29 | + | found = true | |||
| 30 | + | want := 0 | |||
| 31 | + | if layout == "box" { | |||
| 32 | + | want = 2 | |||
| 33 | + | } | |||
| 34 | + | if got := strings.Count(plain, "│"); got != want { | |||
| 35 | + | t.Errorf("expanded=%v: got %d borders, want %d: %s", expanded, got, want, plain) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | if layout != "box" && strings.ContainsAny(plain, "┌┐└┘│") { | |||
| 39 | + | t.Errorf("expanded=%v: unexpected frame: %s", expanded, plain) | |||
| 40 | + | } | |||
| 41 | + | if visibleWidth(row) > 80 { | |||
| 42 | + | t.Errorf("expanded=%v: row exceeds terminal width: %s", expanded, plain) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | if !found { | |||
| 46 | + | t.Fatalf("expanded=%v: missing preview: %v", expanded, rows) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestCollapseToolCallPreservesErrors(t *testing.T) { | |||
| 55 | + | for _, layout := range []string{"box", "flat", "compact"} { | |||
| 56 | + | for _, text := range []string{"", "failed", strings.Repeat("output\n", ToolCollapseLines+1)} { | |||
| 57 | + | for _, live := range []bool{false, true} { | |||
| 58 | + | v := View{Theme: Dark, CollapseToolCall: true, FlatTools: layout == "flat", CompactMode: layout == "compact"} | |||
| 59 | + | var rows []string | |||
| 60 | + | if live { | |||
| 61 | + | rows = v.RenderToolCall(ToolCallView{Name: "bash", Result: text, Error: true}, 80) | |||
| 62 | + | } else { | |||
| 63 | + | rows = v.renderMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |||
| 64 | + | provider.ToolResultBlock{IsError: true, Content: []provider.Content{provider.TextBlock{Text: text}}}, | |||
| 65 | + | }}, 80, false) | |||
| 66 | + | } | |||
| 67 | + | rendered := strings.Join(rows, "\n") | |||
| 68 | + | if !strings.Contains(rendered, v.Theme.FG256(v.Theme.Error, "error")) { | |||
| 69 | + | t.Errorf("%s live=%v: missing error indicator: %s", layout, live, stripANSI(rendered)) | |||
| 70 | + | } | |||
| 71 | + | if text == "failed" && !strings.Contains(rendered, v.Theme.FG256(v.Theme.Error, text)) { | |||
| 72 | + | t.Errorf("%s live=%v: missing error styling: %q", layout, live, rendered) | |||
| 73 | + | } | |||
| 74 | + | if layout != "box" && strings.ContainsAny(stripANSI(rendered), "┌┐└┘│") { | |||
| 75 | + | t.Errorf("%s live=%v: unexpected frame: %s", layout, live, stripANSI(rendered)) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) { | |||
| 83 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |||
| 84 | + | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | |||
| 85 | + | ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "first-output\nlast-output\n", | |||
| 86 | + | }}} | |||
| 87 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |||
| 88 | + | if !strings.Contains(plain, "bash") { | |||
| 89 | + | t.Fatalf("collapsed render lost tool header:\n%s", plain) | |||
| 90 | + | } | |||
| 91 | + | if !strings.Contains(plain, "last-output") || strings.Contains(plain, "first-output") { | |||
| 92 | + | t.Fatalf("collapsed render did not show only the last result line:\n%s", plain) | |||
| 93 | + | } | |||
| 94 | + | if !strings.ContainsAny(plain, "┌┐") || !strings.ContainsAny(plain, "└┘") || !strings.Contains(plain, "│") { | |||
| 95 | + | t.Fatalf("collapsed box render did not retain the complete empty frame:\n%s", plain) | |||
| 96 | + | } | |||
| 97 | + | v.ExpandAll = true | |||
| 98 | + | expanded := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |||
| 99 | + | if !strings.Contains(expanded, "first-output") { | |||
| 100 | + | t.Fatalf("ctrl-o expansion did not reveal non-compact collapsed tool result:\n%s", expanded) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestCollapseToolCallShowsLastCompactPreviewLine(t *testing.T) { | |||
| 105 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |||
| 106 | + | v := View{ | |||
| 107 | + | Theme: Dark, | |||
| 108 | + | CompactMode: true, | |||
| 109 | + | CollapseToolCall: true, | |||
| 110 | + | Messages: []provider.Message{ | |||
| 111 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 112 | + | provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args}, | |||
| 113 | + | }}, | |||
| 114 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 115 | + | provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{provider.TextBlock{Text: "first-output\nlast-output"}}}, | |||
| 116 | + | }}, | |||
| 117 | + | }, | |||
| 118 | + | } | |||
| 119 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |||
| 120 | + | if !strings.Contains(plain, "bash") { | |||
| 121 | + | t.Fatalf("collapsed compact render lost tool header:\n%s", plain) | |||
| 122 | + | } | |||
| 123 | + | if !strings.Contains(plain, "last-output") || strings.Contains(plain, "first-output") { | |||
| 124 | + | t.Fatalf("collapsed compact render did not show only the last result line:\n%s", plain) | |||
| 125 | + | } | |||
| 126 | + | v.ExpandAll = true | |||
| 127 | + | expanded := stripANSI(strings.Join(v.Build(80), "\n")) | |||
| 128 | + | if !strings.Contains(expanded, "first-output") { | |||
| 129 | + | t.Fatalf("ctrl-o expansion did not reveal collapsed tool result:\n%s", expanded) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestCollapseToolCallShowsLastLivePreviewLine(t *testing.T) { | |||
| 134 | + | args := json.RawMessage(`{"command":"echo preview"}`) | |||
| 135 | + | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | |||
| 136 | + | ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args), | |||
| 137 | + | }}} | |||
| 138 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |||
| 139 | + | if !strings.Contains(plain, "$ echo") { | |||
| 140 | + | t.Fatalf("collapsed live render lost its one-line preview:\n%s", plain) | |||
| 141 | + | } | |||
| 142 | + | if !strings.Contains(plain, "bash") { | |||
| 143 | + | t.Fatalf("collapsed live render lost tool header:\n%s", plain) | |||
| 144 | + | } | |||
| 145 | + | } | |||