detached 6aaa6af

Merge branch 'main' into feat/opencode-go-grok-kimi-k3

commit
6aaa6afc705761463ef7f66f8e4379164ea60322
author
Patric Eckhart · · 2026-07-21 16:09 UTC
committer
GitHub · · 2026-07-21 16:09 UTC
merge of
54d7166 + 7e6033a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +137 -17
A packages/agent/modes/confirm_dialog_test.go +75 -0
@@ -0,0 +1,75 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
14+ resp := make(chan core.ConfirmDecision, 1)
15+ dialog := newConfirmDialog()
16+ dialog.Enqueue(&confirmRequest{
17+ toolName: "edit",
18+ preview: "large edit",
19+ resp: resp,
20+ })
21+ args, err := json.Marshal(map[string]any{
22+ "path": "sample.ts",
23+ "edits": []map[string]string{{
24+ "oldText": "old",
25+ "newText": strings.Repeat("const value = 1\n", 20),
26+ }},
27+ })
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ view := &tui.View{
32+ ToolCalls: []tui.ToolCallView{{
33+ ID: "call-1",
34+ Name: "edit",
35+ RawJSONBuf: string(args),
36+ LivePath: "sample.ts",
37+ }},
38+ }
39+ i := &Interactive{
40+ view: view,
41+ confirmDialog: dialog,
42+ dirty: make(chan struct{}, 1),
43+ }
44+ if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
45+ t.Fatalf("test preview was not initially collapsed:\n%s", rendered)
46+ }
47+
48+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
49+
50+ if !i.view.ExpandAll {
51+ t.Fatal("ctrl+o did not expand tool previews while confirmation was active")
52+ }
53+ if rendered := strings.Join(view.Build(80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
54+ t.Fatalf("live edit preview remained collapsed after ctrl+o:\n%s", rendered)
55+ }
56+ if !dialog.Active() {
57+ t.Fatal("ctrl+o closed the confirmation dialog")
58+ }
59+ select {
60+ case decision := <-resp:
61+ t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
62+ default:
63+ }
64+
65+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
66+ if i.view.ExpandAll {
67+ t.Fatal("second ctrl+o did not collapse tool previews during confirmation")
68+ }
69+ if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
70+ t.Fatalf("live edit preview remained expanded after second ctrl+o:\n%s", rendered)
71+ }
72+ if !dialog.Active() {
73+ t.Fatal("second ctrl+o closed the confirmation dialog")
74+ }
75+}
@@ -0,0 +1,75 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
14+ resp := make(chan core.ConfirmDecision, 1)
15+ dialog := newConfirmDialog()
16+ dialog.Enqueue(&confirmRequest{
17+ toolName: "edit",
18+ preview: "large edit",
19+ resp: resp,
20+ })
21+ args, err := json.Marshal(map[string]any{
22+ "path": "sample.ts",
23+ "edits": []map[string]string{{
24+ "oldText": "old",
25+ "newText": strings.Repeat("const value = 1\n", 20),
26+ }},
27+ })
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ view := &tui.View{
32+ ToolCalls: []tui.ToolCallView{{
33+ ID: "call-1",
34+ Name: "edit",
35+ RawJSONBuf: string(args),
36+ LivePath: "sample.ts",
37+ }},
38+ }
39+ i := &Interactive{
40+ view: view,
41+ confirmDialog: dialog,
42+ dirty: make(chan struct{}, 1),
43+ }
44+ if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
45+ t.Fatalf("test preview was not initially collapsed:\n%s", rendered)
46+ }
47+
48+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
49+
50+ if !i.view.ExpandAll {
51+ t.Fatal("ctrl+o did not expand tool previews while confirmation was active")
52+ }
53+ if rendered := strings.Join(view.Build(80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
54+ t.Fatalf("live edit preview remained collapsed after ctrl+o:\n%s", rendered)
55+ }
56+ if !dialog.Active() {
57+ t.Fatal("ctrl+o closed the confirmation dialog")
58+ }
59+ select {
60+ case decision := <-resp:
61+ t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
62+ default:
63+ }
64+
65+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
66+ if i.view.ExpandAll {
67+ t.Fatal("second ctrl+o did not collapse tool previews during confirmation")
68+ }
69+ if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
70+ t.Fatalf("live edit preview remained expanded after second ctrl+o:\n%s", rendered)
71+ }
72+ if !dialog.Active() {
73+ t.Fatal("second ctrl+o closed the confirmation dialog")
74+ }
75+}
M packages/agent/modes/interactive.go +26 -13
@@ -1795,6 +1795,25 @@ func (i *Interactive) clearFileSuggestQuery() {
17951795 }
17961796 }
17971797
1798+// setToolExpansion updates long tool result expansion and replays the
1799+// transcript so already-emitted rows reflect the new state.
1800+func (i *Interactive) setToolExpansion(expanded bool) {
1801+ i.mu.Lock()
1802+ i.view.ExpandAll = expanded
1803+ if i.rend != nil {
1804+ i.rend.Clear()
1805+ }
1806+ i.mu.Unlock()
1807+ i.invalidate()
1808+}
1809+
1810+func (i *Interactive) toggleToolExpansion() {
1811+ i.mu.Lock()
1812+ expanded := !i.view.ExpandAll
1813+ i.mu.Unlock()
1814+ i.setToolExpansion(expanded)
1815+}
1816+
17981817 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
17991818 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
18001819 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -1814,8 +1833,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18141833
18151834 // Confirm dialog has highest priority: the agent goroutine is
18161835 // blocked waiting for an answer, so we must not let keys leak
1817- // anywhere else while it's up.
1836+ // anywhere else while it's up. Ctrl+O remains available so the
1837+ // user can expand or collapse a tool preview before deciding.
18181838 if i.confirmDialog.Active() {
1839+ if k.Kind == tui.KeyCtrlO {
1840+ i.toggleToolExpansion()
1841+ return false
1842+ }
18191843 i.confirmDialog.HandleKey(k)
18201844 i.invalidate()
18211845 return false
@@ -2163,18 +2187,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21632187 i.pasteClipboard()
21642188 return false
21652189 case tui.KeyCtrlO:
2166- // Toggle expansion of collapsed tool results. Affects every tool
2167- // call in the transcript — press again to re-collapse.
2168- // In main-screen scrollback mode this changes already-emitted
2169- // transcript rows, so do a full clear+replay instead of trying
2170- // to edit old scrollback in place.
2171- i.mu.Lock()
2172- i.view.ExpandAll = !i.view.ExpandAll
2173- if i.rend != nil {
2174- i.rend.Clear()
2175- }
2176- i.mu.Unlock()
2177- i.invalidate()
2190+ i.toggleToolExpansion()
21782191 return false
21792192 case tui.KeyPageUp:
21802193 i.scrollBy(+i.chatPage())
@@ -1795,6 +1795,25 @@ func (i *Interactive) clearFileSuggestQuery() {
1795 }1795 }
1796 }1796 }
1797 1797
1798+// setToolExpansion updates long tool result expansion and replays the
1799+// transcript so already-emitted rows reflect the new state.
1800+func (i *Interactive) setToolExpansion(expanded bool) {
1801+ i.mu.Lock()
1802+ i.view.ExpandAll = expanded
1803+ if i.rend != nil {
1804+ i.rend.Clear()
1805+ }
1806+ i.mu.Unlock()
1807+ i.invalidate()
1808+}
1809+
1810+func (i *Interactive) toggleToolExpansion() {
1811+ i.mu.Lock()
1812+ expanded := !i.view.ExpandAll
1813+ i.mu.Unlock()
1814+ i.setToolExpansion(expanded)
1815+}
1816+
1798 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {1817 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1799 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so1818 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1800 // pressing ctrl+c then typing then ctrl+c much later doesn't quit1819 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -1814,8 +1833,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1814 1833
1815 // Confirm dialog has highest priority: the agent goroutine is1834 // Confirm dialog has highest priority: the agent goroutine is
1816 // blocked waiting for an answer, so we must not let keys leak1835 // blocked waiting for an answer, so we must not let keys leak
1817- // anywhere else while it's up.1836+ // anywhere else while it's up. Ctrl+O remains available so the
1837+ // user can expand or collapse a tool preview before deciding.
1818 if i.confirmDialog.Active() {1838 if i.confirmDialog.Active() {
1839+ if k.Kind == tui.KeyCtrlO {
1840+ i.toggleToolExpansion()
1841+ return false
1842+ }
1819 i.confirmDialog.HandleKey(k)1843 i.confirmDialog.HandleKey(k)
1820 i.invalidate()1844 i.invalidate()
1821 return false1845 return false
@@ -2163,18 +2187,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2163 i.pasteClipboard()2187 i.pasteClipboard()
2164 return false2188 return false
2165 case tui.KeyCtrlO:2189 case tui.KeyCtrlO:
2166- // Toggle expansion of collapsed tool results. Affects every tool2190+ i.toggleToolExpansion()
2167- // call in the transcript — press again to re-collapse.
2168- // In main-screen scrollback mode this changes already-emitted
2169- // transcript rows, so do a full clear+replay instead of trying
2170- // to edit old scrollback in place.
2171- i.mu.Lock()
2172- i.view.ExpandAll = !i.view.ExpandAll
2173- if i.rend != nil {
2174- i.rend.Clear()
2175- }
2176- i.mu.Unlock()
2177- i.invalidate()
2178 return false2191 return false
2179 case tui.KeyPageUp:2192 case tui.KeyPageUp:
2180 i.scrollBy(+i.chatPage())2193 i.scrollBy(+i.chatPage())
M packages/tui/view.go +14 -4
@@ -130,7 +130,8 @@ type View struct {
130130 // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only
131131 // consulted while a call has no Result; interactive mode prunes
132132 // the map when a turn ends.
133- liveBodyHigh map[string]int
133+ liveBodyHigh map[string]int
134+ liveBodyExpandAll bool
134135
135136 // FlatTools renders tool calls without the bordered panel: a quiet
136137 // header line per call plus indented, frameless output. The
@@ -255,6 +256,7 @@ func (v *View) Build(width int) []string {
255256 // scrollback renderers can keep these rows outside the immutable transcript
256257 // so native scrolling stays stable while a turn streams.
257258 func (v *View) BuildLive(width int) []string {
259+ v.prepareLiveBodyHeights()
258260 var out []string
259261 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
260262 const indent = " "
@@ -329,9 +331,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
329331 if v.renderCache == nil {
330332 v.renderCache = make(map[msgCacheKey][]string)
331333 }
332- if v.liveBodyHigh == nil {
333- v.liveBodyHigh = make(map[string]int)
334- }
334+ v.prepareLiveBodyHeights()
335335 // Drop high-water entries for calls that are gone or finalised so
336336 // the reservation never leaks into a later, shorter tool box.
337337 if len(v.liveBodyHigh) > 0 {
@@ -463,6 +463,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
463463 return out, anchors
464464 }
465465
466+// prepareLiveBodyHeights resets live tool height reservations when ctrl+o
467+// changes expansion state. Expanded content must not keep a collapsed box
468+// padded to its former full height.
469+func (v *View) prepareLiveBodyHeights() {
470+ if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll {
471+ v.liveBodyHigh = make(map[string]int)
472+ v.liveBodyExpandAll = v.ExpandAll
473+ }
474+}
475+
466476 // refreshToolPaths rebuilds the tool_use_id -> path map from the
467477 // current transcript. Called once per Build() so tool result blocks
468478 // (which may be cached) can look up their syntax language when they
@@ -130,7 +130,8 @@ type View struct {
130 // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only130 // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only
131 // consulted while a call has no Result; interactive mode prunes131 // consulted while a call has no Result; interactive mode prunes
132 // the map when a turn ends.132 // the map when a turn ends.
133- liveBodyHigh map[string]int133+ liveBodyHigh map[string]int
134+ liveBodyExpandAll bool
134 135
135 // FlatTools renders tool calls without the bordered panel: a quiet136 // FlatTools renders tool calls without the bordered panel: a quiet
136 // header line per call plus indented, frameless output. The137 // header line per call plus indented, frameless output. The
@@ -255,6 +256,7 @@ func (v *View) Build(width int) []string {
255 // scrollback renderers can keep these rows outside the immutable transcript256 // scrollback renderers can keep these rows outside the immutable transcript
256 // so native scrolling stays stable while a turn streams.257 // so native scrolling stays stable while a turn streams.
257 func (v *View) BuildLive(width int) []string {258 func (v *View) BuildLive(width int) []string {
259+ v.prepareLiveBodyHeights()
258 var out []string260 var out []string
259 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {261 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
260 const indent = " "262 const indent = " "
@@ -329,9 +331,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
329 if v.renderCache == nil {331 if v.renderCache == nil {
330 v.renderCache = make(map[msgCacheKey][]string)332 v.renderCache = make(map[msgCacheKey][]string)
331 }333 }
332- if v.liveBodyHigh == nil {334+ v.prepareLiveBodyHeights()
333- v.liveBodyHigh = make(map[string]int)
334- }
335 // Drop high-water entries for calls that are gone or finalised so335 // Drop high-water entries for calls that are gone or finalised so
336 // the reservation never leaks into a later, shorter tool box.336 // the reservation never leaks into a later, shorter tool box.
337 if len(v.liveBodyHigh) > 0 {337 if len(v.liveBodyHigh) > 0 {
@@ -463,6 +463,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
463 return out, anchors463 return out, anchors
464 }464 }
465 465
466+// prepareLiveBodyHeights resets live tool height reservations when ctrl+o
467+// changes expansion state. Expanded content must not keep a collapsed box
468+// padded to its former full height.
469+func (v *View) prepareLiveBodyHeights() {
470+ if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll {
471+ v.liveBodyHigh = make(map[string]int)
472+ v.liveBodyExpandAll = v.ExpandAll
473+ }
474+}
475+
466 // refreshToolPaths rebuilds the tool_use_id -> path map from the476 // refreshToolPaths rebuilds the tool_use_id -> path map from the
467 // current transcript. Called once per Build() so tool result blocks477 // current transcript. Called once per Build() so tool result blocks
468 // (which may be cached) can look up their syntax language when they478 // (which may be cached) can look up their syntax language when they
M packages/tui/view_tool_overlay_test.go +22 -0
@@ -74,6 +74,28 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
7474 }
7575 }
7676
77+func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
78+ args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)
79+ v := View{
80+ Theme: Dark,
81+ ToolCalls: []ToolCallView{
82+ {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
83+ },
84+ }
85+
86+ collapsedRows := len(v.Build(80))
87+ v.ExpandAll = true
88+ expandedRows := len(v.Build(80))
89+ if expandedRows <= collapsedRows {
90+ t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
91+ }
92+
93+ v.ExpandAll = false
94+ if got := len(v.Build(80)); got != collapsedRows {
95+ t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
96+ }
97+}
98+
7799 func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
78100 v := View{Theme: Dark}
79101 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
@@ -74,6 +74,28 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
74 }74 }
75 }75 }
76 76
77+func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
78+ args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)
79+ v := View{
80+ Theme: Dark,
81+ ToolCalls: []ToolCallView{
82+ {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
83+ },
84+ }
85+
86+ collapsedRows := len(v.Build(80))
87+ v.ExpandAll = true
88+ expandedRows := len(v.Build(80))
89+ if expandedRows <= collapsedRows {
90+ t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
91+ }
92+
93+ v.ExpandAll = false
94+ if got := len(v.Build(80)); got != collapsedRows {
95+ t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
96+ }
97+}
98+
77 func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {99 func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
78 v := View{Theme: Dark}100 v := View{Theme: Dark}
79 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)101 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)

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