Merge branch 'main' into feat/opencode-go-grok-kimi-k3
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() { | |||
| 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 | 1817 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 1799 | 1818 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 1800 | 1819 | // 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 | 1834 | // Confirm dialog has highest priority: the agent goroutine is | |
| 1816 | 1835 | // 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 | 1838 | if i.confirmDialog.Active() { | |
| 1839 | + | if k.Kind == tui.KeyCtrlO { | |
| 1840 | + | i.toggleToolExpansion() | |
| 1841 | + | return false | |
| 1842 | + | } | |
| 1819 | 1843 | i.confirmDialog.HandleKey(k) | |
| 1820 | 1844 | i.invalidate() | |
| 1821 | 1845 | return false | |
| @@ -2163,18 +2187,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2163 | 2187 | i.pasteClipboard() | |
| 2164 | 2188 | return false | |
| 2165 | 2189 | 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() | |
| 2178 | 2191 | return false | |
| 2179 | 2192 | case tui.KeyPageUp: | |
| 2180 | 2193 | 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, so | 1818 | // 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 quit | 1819 | // 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 is | 1834 | // Confirm dialog has highest priority: the agent goroutine is | ||
| 1816 | // blocked waiting for an answer, so we must not let keys leak | 1835 | // 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 false | 1845 | 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 false | 2188 | return false | ||
| 2165 | case tui.KeyCtrlO: | 2189 | case tui.KeyCtrlO: | ||
| 2166 | - | // Toggle expansion of collapsed tool results. Affects every tool | 2190 | + | 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 false | 2191 | 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 { | |||
| 130 | 130 | // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only | |
| 131 | 131 | // consulted while a call has no Result; interactive mode prunes | |
| 132 | 132 | // the map when a turn ends. | |
| 133 | - | liveBodyHigh map[string]int | |
| 133 | + | liveBodyHigh map[string]int | |
| 134 | + | liveBodyExpandAll bool | |
| 134 | 135 | ||
| 135 | 136 | // FlatTools renders tool calls without the bordered panel: a quiet | |
| 136 | 137 | // header line per call plus indented, frameless output. The | |
| @@ -255,6 +256,7 @@ func (v *View) Build(width int) []string { | |||
| 255 | 256 | // scrollback renderers can keep these rows outside the immutable transcript | |
| 256 | 257 | // so native scrolling stays stable while a turn streams. | |
| 257 | 258 | func (v *View) BuildLive(width int) []string { | |
| 259 | + | v.prepareLiveBodyHeights() | |
| 258 | 260 | var out []string | |
| 259 | 261 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |
| 260 | 262 | const indent = " " | |
| @@ -329,9 +331,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 329 | 331 | if v.renderCache == nil { | |
| 330 | 332 | v.renderCache = make(map[msgCacheKey][]string) | |
| 331 | 333 | } | |
| 332 | - | if v.liveBodyHigh == nil { | |
| 333 | - | v.liveBodyHigh = make(map[string]int) | |
| 334 | - | } | |
| 334 | + | v.prepareLiveBodyHeights() | |
| 335 | 335 | // Drop high-water entries for calls that are gone or finalised so | |
| 336 | 336 | // the reservation never leaks into a later, shorter tool box. | |
| 337 | 337 | if len(v.liveBodyHigh) > 0 { | |
| @@ -463,6 +463,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 463 | 463 | 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 | 476 | // refreshToolPaths rebuilds the tool_use_id -> path map from the | |
| 467 | 477 | // current transcript. Called once per Build() so tool result blocks | |
| 468 | 478 | // (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 only | 130 | // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only | ||
| 131 | // consulted while a call has no Result; interactive mode prunes | 131 | // 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]int | 133 | + | liveBodyHigh map[string]int |
| 134 | + | liveBodyExpandAll bool | |||
| 134 | 135 | ||||
| 135 | // FlatTools renders tool calls without the bordered panel: a quiet | 136 | // FlatTools renders tool calls without the bordered panel: a quiet | ||
| 136 | // header line per call plus indented, frameless output. The | 137 | // 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 transcript | 256 | // 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 []string | 260 | 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 so | 335 | // 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, anchors | 463 | 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 the | 476 | // refreshToolPaths rebuilds the tool_use_id -> path map from the | ||
| 467 | // current transcript. Called once per Build() so tool result blocks | 477 | // current transcript. Called once per Build() so tool result blocks | ||
| 468 | // (which may be cached) can look up their syntax language when they | 478 | // (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) { | |||
| 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 | 99 | func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) { | |
| 78 | 100 | v := View{Theme: Dark} | |
| 79 | 101 | 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"}`) | ||