fix(tui): preserve collapsed tool layout and error status
- commit
- f8649b41b216c32f4049b46c9bc124e2b07101a9
- author
- patriceckhart · · 2026-09-06 07:55 UTC
- parent
- 67b6b2e
- signature
- unsigned
5 files · +128 -24
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/config.go +2 -2
| @@ -95,8 +95,8 @@ 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 shows only the tool-call header, hiding | |
| 99 | - | // call and result content. Off by default; nil/missing means disabled. | |
| 98 | + | // CollapseToolCall retains the tool-call header, a final preview line, | |
| 99 | + | // and failure status. Off by default; nil/missing means disabled. | |
| 100 | 100 | // Toggle from /settings. | |
| 101 | 101 | CollapseToolCall *bool `json:"collapse_tool_call,omitempty"` | |
| 102 | 102 | ||
| @@ -95,8 +95,8 @@ 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 shows only the tool-call header, hiding | 98 | + | // CollapseToolCall retains the tool-call header, a final preview line, |
| 99 | - | // call and result content. Off by default; nil/missing means disabled. | 99 | + | // and failure status. Off by default; nil/missing means disabled. |
| 100 | // Toggle from /settings. | 100 | // Toggle from /settings. | ||
| 101 | CollapseToolCall *bool `json:"collapse_tool_call,omitempty"` | 101 | CollapseToolCall *bool `json:"collapse_tool_call,omitempty"` | ||
| 102 | 102 | ||||
M packages/agent/modes/interactive.go +3 -3
| @@ -111,8 +111,8 @@ 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 hides call and result content while | |
| 115 | - | // retaining each tool-call header. | |
| 114 | + | // CollapseToolCall retains each tool-call header, a final preview line, | |
| 115 | + | // and failure status while hiding the rest of the body. | |
| 116 | 116 | CollapseToolCall *bool | |
| 117 | 117 | ||
| 118 | 118 | // TUIInputStyle controls the main input rendering: plain or lines. | |
| @@ -3567,7 +3567,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3567 | 3567 | { | |
| 3568 | 3568 | key: "collapse_tool_call", | |
| 3569 | 3569 | label: "collapse tool call rendering", | |
| 3570 | - | desc: "show only the tool-call header, hiding call arguments and result content", | |
| 3570 | + | desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output", | |
| 3571 | 3571 | value: collapseToolCall, | |
| 3572 | 3572 | }, | |
| 3573 | 3573 | { | |
| @@ -111,8 +111,8 @@ 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 hides call and result content while | 114 | + | // CollapseToolCall retains each tool-call header, a final preview line, |
| 115 | - | // retaining each tool-call header. | 115 | + | // and failure status while hiding the rest of the body. |
| 116 | CollapseToolCall *bool | 116 | CollapseToolCall *bool | ||
| 117 | 117 | ||||
| 118 | // TUIInputStyle controls the main input rendering: plain or lines. | 118 | // TUIInputStyle controls the main input rendering: plain or lines. | ||
| @@ -3567,7 +3567,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3567 | { | 3567 | { | ||
| 3568 | key: "collapse_tool_call", | 3568 | key: "collapse_tool_call", | ||
| 3569 | label: "collapse tool call rendering", | 3569 | label: "collapse tool call rendering", | ||
| 3570 | - | desc: "show only the tool-call header, hiding call arguments and result content", | 3570 | + | desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output", |
| 3571 | value: collapseToolCall, | 3571 | value: collapseToolCall, | ||
| 3572 | }, | 3572 | }, | ||
| 3573 | { | 3573 | { | ||
M packages/tui/view.go +51 -19
| @@ -158,8 +158,8 @@ type View struct { | |||
| 158 | 158 | // the current spacious rendering. | |
| 159 | 159 | CompactMode bool | |
| 160 | 160 | ||
| 161 | - | // CollapseToolCall shows only each tool-call header, hiding | |
| 162 | - | // both call previews and result content. | |
| 161 | + | // CollapseToolCall retains each tool-call header, the final rendered | |
| 162 | + | // preview line, and an explicit error indicator for failed calls. | |
| 163 | 163 | CollapseToolCall bool | |
| 164 | 164 | ||
| 165 | 165 | // ExpandAll forces every long tool result to render in full. | |
| @@ -796,7 +796,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 796 | 796 | // instead of stacking unclosed top edges. | |
| 797 | 797 | if v.CollapseToolCall && !v.ExpandAll { | |
| 798 | 798 | body := v.renderToolResultContent(tr.Content, width, color, path, startLine) | |
| 799 | - | lines = append(lines, collapsedToolBoxWithBody(v.Theme, label, body, width, v.CompactMode)...) | |
| 799 | + | lines = append(lines, v.renderCollapsedTool(label, body, width, tr.IsError)...) | |
| 800 | 800 | continue | |
| 801 | 801 | } | |
| 802 | 802 | if v.FlatTools || v.CompactMode { | |
| @@ -874,12 +874,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 874 | 874 | } | |
| 875 | 875 | var body []string | |
| 876 | 876 | if bodyText != "" { | |
| 877 | - | body = v.renderLiveToolResult(bodyText, flatToolBodyRenderWidth(width), v.Theme.ToolOut, tc.LivePath) | |
| 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) | |
| 878 | 886 | } else { | |
| 879 | - | body = v.renderLiveToolBody(tc, width) | |
| 887 | + | body = v.renderLiveToolContent(tc, width) | |
| 880 | 888 | } | |
| 881 | 889 | body = v.collapseToolBody(body, false) | |
| 882 | - | return collapsedToolBoxWithBody(v.Theme, label, body, width, v.CompactMode) | |
| 890 | + | return v.renderCollapsedTool(label, body, width, tc.Error) | |
| 883 | 891 | } | |
| 884 | 892 | ||
| 885 | 893 | // No leading blank: Build()'s inter-message separator already | |
| @@ -1019,13 +1027,19 @@ func (v *View) renderLiveToolResult(text string, width, color int, sourcePath st | |||
| 1019 | 1027 | // | |
| 1020 | 1028 | // Anything else returns nil and only the tool-call header shows. | |
| 1021 | 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 { | |
| 1022 | 1036 | switch tc.Name { | |
| 1023 | 1037 | case "write", "Write": | |
| 1024 | 1038 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | |
| 1025 | 1039 | if !ok || partial == "" { | |
| 1026 | 1040 | return nil | |
| 1027 | 1041 | } | |
| 1028 | - | return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width) | |
| 1042 | + | return v.renderRawFile(partial, tc.LivePath, 1) | |
| 1029 | 1043 | case "edit", "Edit": | |
| 1030 | 1044 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | |
| 1031 | 1045 | if !ok || partial == "" { | |
| @@ -1036,13 +1050,13 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 1036 | 1050 | hint := fmt.Sprintf("edit %d (streaming)", idx) | |
| 1037 | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 1038 | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 1039 | - | return v.wrapLiveBody(body, width) | |
| 1053 | + | return body | |
| 1040 | 1054 | case "bash", "Bash": | |
| 1041 | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | |
| 1042 | 1056 | if !ok || strings.TrimSpace(command) == "" { | |
| 1043 | 1057 | return nil | |
| 1044 | 1058 | } | |
| 1045 | - | return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width) | |
| 1059 | + | return v.renderLiveBashCommand(command, width) | |
| 1046 | 1060 | } | |
| 1047 | 1061 | return nil | |
| 1048 | 1062 | } | |
| @@ -1130,9 +1144,10 @@ const toolBoxInnerPad = 1 | |||
| 1130 | 1144 | // running edge-to-edge while user/assistant rows sit indented. | |
| 1131 | 1145 | const toolBoxOuterMargin = 2 | |
| 1132 | 1146 | ||
| 1133 | - | // collapsedToolBoxWithBody renders a tool frame with at most the final | |
| 1134 | - | // meaningful preview line, preserving the normal spacing and borders. | |
| 1135 | - | func collapsedToolBoxWithBody(th Theme, label string, body []string, width int, compact bool) []string { | |
| 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 | |
| 1136 | 1151 | last := "" | |
| 1137 | 1152 | for i := len(body) - 1; i >= 0; i-- { | |
| 1138 | 1153 | if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 { | |
| @@ -1140,16 +1155,33 @@ func collapsedToolBoxWithBody(th Theme, label string, body []string, width int, | |||
| 1140 | 1155 | break | |
| 1141 | 1156 | } | |
| 1142 | 1157 | } | |
| 1143 | - | if compact { | |
| 1144 | - | out := []string{compactToolBlank(th, width), toolHeaderLine(th, label, width, true)} | |
| 1145 | - | if last != "" { | |
| 1146 | - | out = append(out, compactToolBlank(th, width), toolBodyLine(th, last, width, true)) | |
| 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)) | |
| 1147 | 1173 | } | |
| 1148 | - | return append(out, compactToolBlank(th, width)) | |
| 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 | |
| 1149 | 1181 | } | |
| 1150 | 1182 | out := []string{toolBoxTop(th, label, width), toolBoxSide(th, "", width)} | |
| 1151 | - | if last != "" { | |
| 1152 | - | out = append(out, toolBoxSide(th, last, width)) | |
| 1183 | + | for _, line := range preview { | |
| 1184 | + | out = append(out, toolBoxSide(th, line, width)) | |
| 1153 | 1185 | } | |
| 1154 | 1186 | out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width)) | |
| 1155 | 1187 | return out | |
| @@ -158,8 +158,8 @@ type View struct { | |||||
| 158 | // the current spacious rendering. | 158 | // the current spacious rendering. | ||
| 159 | CompactMode bool | 159 | CompactMode bool | ||
| 160 | 160 | ||||
| 161 | - | // CollapseToolCall shows only each tool-call header, hiding | 161 | + | // CollapseToolCall retains each tool-call header, the final rendered |
| 162 | - | // both call previews and result content. | 162 | + | // preview line, and an explicit error indicator for failed calls. |
| 163 | CollapseToolCall bool | 163 | CollapseToolCall bool | ||
| 164 | 164 | ||||
| 165 | // ExpandAll forces every long tool result to render in full. | 165 | // ExpandAll forces every long tool result to render in full. | ||
| @@ -796,7 +796,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 796 | // instead of stacking unclosed top edges. | 796 | // instead of stacking unclosed top edges. | ||
| 797 | if v.CollapseToolCall && !v.ExpandAll { | 797 | if v.CollapseToolCall && !v.ExpandAll { | ||
| 798 | body := v.renderToolResultContent(tr.Content, width, color, path, startLine) | 798 | body := v.renderToolResultContent(tr.Content, width, color, path, startLine) | ||
| 799 | - | lines = append(lines, collapsedToolBoxWithBody(v.Theme, label, body, width, v.CompactMode)...) | 799 | + | lines = append(lines, v.renderCollapsedTool(label, body, width, tr.IsError)...) |
| 800 | continue | 800 | continue | ||
| 801 | } | 801 | } | ||
| 802 | if v.FlatTools || v.CompactMode { | 802 | if v.FlatTools || v.CompactMode { | ||
| @@ -874,12 +874,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 874 | } | 874 | } | ||
| 875 | var body []string | 875 | var body []string | ||
| 876 | if bodyText != "" { | 876 | if bodyText != "" { | ||
| 877 | - | body = v.renderLiveToolResult(bodyText, flatToolBodyRenderWidth(width), v.Theme.ToolOut, tc.LivePath) | 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) | |||
| 878 | } else { | 886 | } else { | ||
| 879 | - | body = v.renderLiveToolBody(tc, width) | 887 | + | body = v.renderLiveToolContent(tc, width) |
| 880 | } | 888 | } | ||
| 881 | body = v.collapseToolBody(body, false) | 889 | body = v.collapseToolBody(body, false) | ||
| 882 | - | return collapsedToolBoxWithBody(v.Theme, label, body, width, v.CompactMode) | 890 | + | return v.renderCollapsedTool(label, body, width, tc.Error) |
| 883 | } | 891 | } | ||
| 884 | 892 | ||||
| 885 | // No leading blank: Build()'s inter-message separator already | 893 | // No leading blank: Build()'s inter-message separator already | ||
| @@ -1019,13 +1027,19 @@ func (v *View) renderLiveToolResult(text string, width, color int, sourcePath st | |||||
| 1019 | // | 1027 | // | ||
| 1020 | // Anything else returns nil and only the tool-call header shows. | 1028 | // Anything else returns nil and only the tool-call header shows. | ||
| 1021 | 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 { | |||
| 1022 | switch tc.Name { | 1036 | switch tc.Name { | ||
| 1023 | case "write", "Write": | 1037 | case "write", "Write": | ||
| 1024 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | 1038 | partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content") | ||
| 1025 | if !ok || partial == "" { | 1039 | if !ok || partial == "" { | ||
| 1026 | return nil | 1040 | return nil | ||
| 1027 | } | 1041 | } | ||
| 1028 | - | return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width) | 1042 | + | return v.renderRawFile(partial, tc.LivePath, 1) |
| 1029 | case "edit", "Edit": | 1043 | case "edit", "Edit": | ||
| 1030 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | 1044 | partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf) | ||
| 1031 | if !ok || partial == "" { | 1045 | if !ok || partial == "" { | ||
| @@ -1036,13 +1050,13 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||||
| 1036 | hint := fmt.Sprintf("edit %d (streaming)", idx) | 1050 | hint := fmt.Sprintf("edit %d (streaming)", idx) | ||
| 1037 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | ||
| 1038 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | ||
| 1039 | - | return v.wrapLiveBody(body, width) | 1053 | + | return body |
| 1040 | case "bash", "Bash": | 1054 | case "bash", "Bash": | ||
| 1041 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | ||
| 1042 | if !ok || strings.TrimSpace(command) == "" { | 1056 | if !ok || strings.TrimSpace(command) == "" { | ||
| 1043 | return nil | 1057 | return nil | ||
| 1044 | } | 1058 | } | ||
| 1045 | - | return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width) | 1059 | + | return v.renderLiveBashCommand(command, width) |
| 1046 | } | 1060 | } | ||
| 1047 | return nil | 1061 | return nil | ||
| 1048 | } | 1062 | } | ||
| @@ -1130,9 +1144,10 @@ const toolBoxInnerPad = 1 | |||||
| 1130 | // running edge-to-edge while user/assistant rows sit indented. | 1144 | // running edge-to-edge while user/assistant rows sit indented. | ||
| 1131 | const toolBoxOuterMargin = 2 | 1145 | const toolBoxOuterMargin = 2 | ||
| 1132 | 1146 | ||||
| 1133 | - | // collapsedToolBoxWithBody renders a tool frame with at most the final | 1147 | + | // renderCollapsedTool retains the final meaningful preview row and failure |
| 1134 | - | // meaningful preview line, preserving the normal spacing and borders. | 1148 | + | // status, applying the configured layout once to unframed body content. |
| 1135 | - | func collapsedToolBoxWithBody(th Theme, label string, body []string, width int, compact bool) []string { | 1149 | + | func (v *View) renderCollapsedTool(label string, body []string, width int, isError bool) []string { |
| 1150 | + | th := v.Theme | |||
| 1136 | last := "" | 1151 | last := "" | ||
| 1137 | for i := len(body) - 1; i >= 0; i-- { | 1152 | for i := len(body) - 1; i >= 0; i-- { | ||
| 1138 | if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 { | 1153 | if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 { | ||
| @@ -1140,16 +1155,33 @@ func collapsedToolBoxWithBody(th Theme, label string, body []string, width int, | |||||
| 1140 | break | 1155 | break | ||
| 1141 | } | 1156 | } | ||
| 1142 | } | 1157 | } | ||
| 1143 | - | if compact { | 1158 | + | var preview []string |
| 1144 | - | out := []string{compactToolBlank(th, width), toolHeaderLine(th, label, width, true)} | 1159 | + | if isError { |
| 1145 | - | if last != "" { | 1160 | + | preview = append(preview, th.FG256(th.Error, " error")) |
| 1146 | - | out = append(out, compactToolBlank(th, width), toolBodyLine(th, last, width, true)) | 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)) | |||
| 1147 | } | 1173 | } | ||
| 1148 | - | return append(out, compactToolBlank(th, width)) | 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 | |||
| 1149 | } | 1181 | } | ||
| 1150 | out := []string{toolBoxTop(th, label, width), toolBoxSide(th, "", width)} | 1182 | out := []string{toolBoxTop(th, label, width), toolBoxSide(th, "", width)} | ||
| 1151 | - | if last != "" { | 1183 | + | for _, line := range preview { |
| 1152 | - | out = append(out, toolBoxSide(th, last, width)) | 1184 | + | out = append(out, toolBoxSide(th, line, width)) |
| 1153 | } | 1185 | } | ||
| 1154 | out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width)) | 1186 | out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width)) | ||
| 1155 | return out | 1187 | return out | ||
M packages/tui/view_collapse_tools_test.go +71 -0
| @@ -8,6 +8,77 @@ import ( | |||
| 8 | 8 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 9 | ) | |
| 10 | 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 | + | ||
| 11 | 82 | func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) { | |
| 12 | 83 | args := json.RawMessage(`{"command":"echo preview"}`) | |
| 13 | 84 | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | |
| @@ -8,6 +8,77 @@ import ( | |||||
| 8 | "github.com/patriceckhart/zot/packages/provider" | 8 | "github.com/patriceckhart/zot/packages/provider" | ||
| 9 | ) | 9 | ) | ||
| 10 | 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 | + | ||||
| 11 | func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) { | 82 | func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) { | ||
| 12 | args := json.RawMessage(`{"command":"echo preview"}`) | 83 | args := json.RawMessage(`{"command":"echo preview"}`) | ||
| 13 | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | 84 | v := View{Theme: Dark, CollapseToolCall: true, ToolCalls: []ToolCallView{{ | ||