detached f8649b4

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
415415 ```
416416
417417 - **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`.
418419 - **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`.
419420 - **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).
420421 - **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 {
9595 // default; nil/missing means disabled. Toggle from /settings.
9696 CompactMode *bool `json:"compact_mode,omitempty"`
9797
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.
100100 // Toggle from /settings.
101101 CollapseToolCall *bool `json:"collapse_tool_call,omitempty"`
102102
@@ -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, hiding98+ // 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 {
111111 // When true, it enables both flat tool calls and compact user turns.
112112 CompactMode *bool
113113
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.
116116 CollapseToolCall *bool
117117
118118 // TUIInputStyle controls the main input rendering: plain or lines.
@@ -3567,7 +3567,7 @@ func (i *Interactive) openSettingsDialog() {
35673567 {
35683568 key: "collapse_tool_call",
35693569 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",
35713571 value: collapseToolCall,
35723572 },
35733573 {
@@ -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 *bool112 CompactMode *bool
113 113
114- // CollapseToolCall hides call and result content while114+ // 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 *bool116 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 {
158158 // the current spacious rendering.
159159 CompactMode bool
160160
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.
163163 CollapseToolCall bool
164164
165165 // 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
796796 // instead of stacking unclosed top edges.
797797 if v.CollapseToolCall && !v.ExpandAll {
798798 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)...)
800800 continue
801801 }
802802 if v.FlatTools || v.CompactMode {
@@ -874,12 +874,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
874874 }
875875 var body []string
876876 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)
878886 } else {
879- body = v.renderLiveToolBody(tc, width)
887+ body = v.renderLiveToolContent(tc, width)
880888 }
881889 body = v.collapseToolBody(body, false)
882- return collapsedToolBoxWithBody(v.Theme, label, body, width, v.CompactMode)
890+ return v.renderCollapsedTool(label, body, width, tc.Error)
883891 }
884892
885893 // No leading blank: Build()'s inter-message separator already
@@ -1019,13 +1027,19 @@ func (v *View) renderLiveToolResult(text string, width, color int, sourcePath st
10191027 //
10201028 // Anything else returns nil and only the tool-call header shows.
10211029 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 {
10221036 switch tc.Name {
10231037 case "write", "Write":
10241038 partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
10251039 if !ok || partial == "" {
10261040 return nil
10271041 }
1028- return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
1042+ return v.renderRawFile(partial, tc.LivePath, 1)
10291043 case "edit", "Edit":
10301044 partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
10311045 if !ok || partial == "" {
@@ -1036,13 +1050,13 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
10361050 hint := fmt.Sprintf("edit %d (streaming)", idx)
10371051 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
10381052 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1039- return v.wrapLiveBody(body, width)
1053+ return body
10401054 case "bash", "Bash":
10411055 command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
10421056 if !ok || strings.TrimSpace(command) == "" {
10431057 return nil
10441058 }
1045- return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
1059+ return v.renderLiveBashCommand(command, width)
10461060 }
10471061 return nil
10481062 }
@@ -1130,9 +1144,10 @@ const toolBoxInnerPad = 1
11301144 // running edge-to-edge while user/assistant rows sit indented.
11311145 const toolBoxOuterMargin = 2
11321146
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
11361151 last := ""
11371152 for i := len(body) - 1; i >= 0; i-- {
11381153 if _, line := parseImageFootprint(body[i]); visibleWidth(strings.TrimSpace(line)) > 0 {
@@ -1140,16 +1155,33 @@ func collapsedToolBoxWithBody(th Theme, label string, body []string, width int,
11401155 break
11411156 }
11421157 }
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))
11471173 }
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
11491181 }
11501182 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))
11531185 }
11541186 out = append(out, toolBoxSide(th, "", width), toolBoxBottom(th, width))
11551187 return out
@@ -158,8 +158,8 @@ type View struct {
158 // the current spacious rendering.158 // the current spacious rendering.
159 CompactMode bool159 CompactMode bool
160 160
161- // CollapseToolCall shows only each tool-call header, hiding161+ // 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 bool163 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 continue800 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 []string875 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 already893 // 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 nil1040 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 nil1057 return nil
1044 }1058 }
1045- return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)1059+ return v.renderLiveBashCommand(command, width)
1046 }1060 }
1047 return nil1061 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 = 21145 const toolBoxOuterMargin = 2
1132 1146
1133-// collapsedToolBoxWithBody renders a tool frame with at most the final1147+// 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 break1155 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 out1187 return out
M packages/tui/view_collapse_tools_test.go +71 -0
@@ -8,6 +8,77 @@ import (
88 "github.com/patriceckhart/zot/packages/provider"
99 )
1010
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+
1182 func TestCollapseToolCallShowsLastBoxPreviewLine(t *testing.T) {
1283 args := json.RawMessage(`{"command":"echo preview"}`)
1384 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{{

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close