fix(tui): scope live tool height reservation per call id
- commit
- 292bc58eb6af0f91b27f9cf117a2981491a97b70
- author
- patriceckhart · · 2026-06-19 16:09 UTC
- parent
- 31437dd
- signature
- unsigned
2 files · +105 -100
M packages/tui/view.go +59 -78
| @@ -122,15 +122,15 @@ type View struct { | |||
| 122 | 122 | Streaming string // current assistant text delta | |
| 123 | 123 | StreamingActive bool | |
| 124 | 124 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| 125 | - | // LiveToolMinRows reserves vertical space for live tool overlays. | |
| 126 | - | // Interactive mode feeds this with the max rows observed in the | |
| 127 | - | // current turn so a streaming/editing tool never makes the lower | |
| 128 | - | // status/editor area jump upward when its preview shrinks or is | |
| 129 | - | // temporarily replaced by another phase. | |
| 130 | - | LiveToolMinRows int | |
| 131 | - | LastLiveToolRows int | |
| 132 | - | StatusLine string | |
| 133 | - | Err string | |
| 125 | + | StatusLine string | |
| 126 | + | Err string | |
| 127 | + | ||
| 128 | + | // liveBodyHigh tracks the tallest live-preview body height seen | |
| 129 | + | // per tool-call id, so a streaming edit/write/bash box never | |
| 130 | + | // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only | |
| 131 | + | // consulted while a call has no Result; interactive mode prunes | |
| 132 | + | // the map when a turn ends. | |
| 133 | + | liveBodyHigh map[string]int | |
| 134 | 134 | ||
| 135 | 135 | // ExpandAll forces every long tool result to render in full. | |
| 136 | 136 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| @@ -257,8 +257,17 @@ func (v *View) BuildLive(width int) []string { | |||
| 257 | 257 | } | |
| 258 | 258 | } | |
| 259 | 259 | } | |
| 260 | - | liveTools := v.renderLiveToolCalls(width, finalised, len(out) > 0) | |
| 261 | - | out = append(out, liveTools...) | |
| 260 | + | insertedToolGap := false | |
| 261 | + | for _, tc := range v.ToolCalls { | |
| 262 | + | if finalised[tc.ID] { | |
| 263 | + | continue | |
| 264 | + | } | |
| 265 | + | if !insertedToolGap && len(out) > 0 { | |
| 266 | + | out = append(out, "") | |
| 267 | + | } | |
| 268 | + | insertedToolGap = true | |
| 269 | + | out = append(out, v.renderToolCall(tc, width)...) | |
| 270 | + | } | |
| 262 | 271 | if v.Err != "" { | |
| 263 | 272 | out = append(out, v.renderErr(width)...) | |
| 264 | 273 | // out = append(out, "") | |
| @@ -302,6 +311,24 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 302 | 311 | if v.renderCache == nil { | |
| 303 | 312 | v.renderCache = make(map[msgCacheKey][]string) | |
| 304 | 313 | } | |
| 314 | + | if v.liveBodyHigh == nil { | |
| 315 | + | v.liveBodyHigh = make(map[string]int) | |
| 316 | + | } | |
| 317 | + | // Drop high-water entries for calls that are gone or finalised so | |
| 318 | + | // the reservation never leaks into a later, shorter tool box. | |
| 319 | + | if len(v.liveBodyHigh) > 0 { | |
| 320 | + | active := make(map[string]bool, len(v.ToolCalls)) | |
| 321 | + | for _, tc := range v.ToolCalls { | |
| 322 | + | if tc.Result == "" { | |
| 323 | + | active[tc.ID] = true | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | for id := range v.liveBodyHigh { | |
| 327 | + | if !active[id] { | |
| 328 | + | delete(v.liveBodyHigh, id) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | } | |
| 305 | 332 | ||
| 306 | 333 | // Pre-render every message (hits the cache for unchanged ones) so | |
| 307 | 334 | // we can allocate `out` in a single shot with the exact capacity. | |
| @@ -404,9 +431,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 404 | 431 | } | |
| 405 | 432 | } | |
| 406 | 433 | } | |
| 407 | - | liveTools := v.renderLiveToolCalls(width, finalised, false) | |
| 408 | - | out = append(out, liveTools...) | |
| 409 | - | if len(liveTools) > 0 { | |
| 434 | + | for _, tc := range v.ToolCalls { | |
| 435 | + | if finalised[tc.ID] { | |
| 436 | + | continue | |
| 437 | + | } | |
| 438 | + | out = append(out, v.renderToolCall(tc, width)...) | |
| 410 | 439 | out = append(out, "") | |
| 411 | 440 | } | |
| 412 | 441 | if v.Err != "" { | |
| @@ -705,70 +734,6 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 705 | 734 | return lines | |
| 706 | 735 | } | |
| 707 | 736 | ||
| 708 | - | func (v *View) renderLiveToolCalls(width int, finalised map[string]bool, leadingGap bool) []string { | |
| 709 | - | var out []string | |
| 710 | - | insertedToolGap := false | |
| 711 | - | for _, tc := range v.ToolCalls { | |
| 712 | - | if finalised[tc.ID] { | |
| 713 | - | continue | |
| 714 | - | } | |
| 715 | - | if !insertedToolGap && leadingGap { | |
| 716 | - | out = append(out, "") | |
| 717 | - | } | |
| 718 | - | insertedToolGap = true | |
| 719 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 720 | - | } | |
| 721 | - | v.LastLiveToolRows = len(out) | |
| 722 | - | minRows := v.LiveToolMinRows | |
| 723 | - | if minRows < v.LastLiveToolRows { | |
| 724 | - | minRows = v.LastLiveToolRows | |
| 725 | - | } | |
| 726 | - | // Pad up to the reserved height so a live tool overlay never | |
| 727 | - | // shrinks within a turn (which would yank the editor/status band | |
| 728 | - | // upward and then push it back down on the next phase). The pad | |
| 729 | - | // rows are injected *inside* the last open tool box, just above its | |
| 730 | - | // bottom edge, so they are real box-side rows: trailing-blank | |
| 731 | - | // trimming in the caller can't strip them and the box keeps a | |
| 732 | - | // stable footprint. | |
| 733 | - | if need := minRows - len(out); need > 0 { | |
| 734 | - | out = v.padLiveToolBox(out, need, width) | |
| 735 | - | } | |
| 736 | - | return out | |
| 737 | - | } | |
| 738 | - | ||
| 739 | - | // padLiveToolBox inserts `need` blank box-side rows just before the | |
| 740 | - | // final bottom-edge row of out. If the last row isn't a box bottom | |
| 741 | - | // edge (no open box to grow), it falls back to appending plain blank | |
| 742 | - | // rows. Returns the grown slice. | |
| 743 | - | func (v *View) padLiveToolBox(out []string, need, width int) []string { | |
| 744 | - | bottomIdx := -1 | |
| 745 | - | for i := len(out) - 1; i >= 0; i-- { | |
| 746 | - | if strings.Contains(stripANSI(out[i]), "\u2514") { // "\u2514" = box bottom-left corner | |
| 747 | - | bottomIdx = i | |
| 748 | - | break | |
| 749 | - | } | |
| 750 | - | if strings.TrimSpace(stripANSI(out[i])) != "" { | |
| 751 | - | break | |
| 752 | - | } | |
| 753 | - | } | |
| 754 | - | if bottomIdx < 0 { | |
| 755 | - | for need > 0 { | |
| 756 | - | out = append(out, "") | |
| 757 | - | need-- | |
| 758 | - | } | |
| 759 | - | return out | |
| 760 | - | } | |
| 761 | - | pad := make([]string, 0, need) | |
| 762 | - | for i := 0; i < need; i++ { | |
| 763 | - | pad = append(pad, toolBoxSide(v.Theme, "", width)) | |
| 764 | - | } | |
| 765 | - | grown := make([]string, 0, len(out)+need) | |
| 766 | - | grown = append(grown, out[:bottomIdx]...) | |
| 767 | - | grown = append(grown, pad...) | |
| 768 | - | grown = append(grown, out[bottomIdx:]...) | |
| 769 | - | return grown | |
| 770 | - | } | |
| 771 | - | ||
| 772 | 737 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| 773 | 738 | var lines []string | |
| 774 | 739 | ||
| @@ -796,6 +761,22 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 796 | 761 | // EvToolUseEnd and EvToolResult. | |
| 797 | 762 | if tc.Result == "" { | |
| 798 | 763 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | |
| 764 | + | // Pad the live preview up to the call's high-water height so | |
| 765 | + | // it never shrinks mid-stream (e.g. between edit 1 and the | |
| 766 | + | // start of edit 2). Padding sits inside the box as real | |
| 767 | + | // box-side rows so the surrounding layout stays put. The | |
| 768 | + | // high-water mark is per call id and is dropped the moment a | |
| 769 | + | // Result lands (this branch only runs while Result == ""). | |
| 770 | + | high := len(body) | |
| 771 | + | if v.liveBodyHigh != nil { | |
| 772 | + | if prev := v.liveBodyHigh[tc.ID]; prev > high { | |
| 773 | + | high = prev | |
| 774 | + | } | |
| 775 | + | v.liveBodyHigh[tc.ID] = high | |
| 776 | + | } | |
| 777 | + | for len(body) < high { | |
| 778 | + | body = append(body, toolBoxSide(v.Theme, "", width)) | |
| 779 | + | } | |
| 799 | 780 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 800 | 781 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | |
| 801 | 782 | lines = append(lines, body...) | |
| @@ -122,15 +122,15 @@ type View struct { | |||||
| 122 | Streaming string // current assistant text delta | 122 | Streaming string // current assistant text delta | ||
| 123 | StreamingActive bool | 123 | StreamingActive bool | ||
| 124 | ToolCalls []ToolCallView // tool calls in flight or completed | 124 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| 125 | - | // LiveToolMinRows reserves vertical space for live tool overlays. | 125 | + | StatusLine string |
| 126 | - | // Interactive mode feeds this with the max rows observed in the | 126 | + | Err string |
| 127 | - | // current turn so a streaming/editing tool never makes the lower | 127 | + | |
| 128 | - | // status/editor area jump upward when its preview shrinks or is | 128 | + | // liveBodyHigh tracks the tallest live-preview body height seen |
| 129 | - | // temporarily replaced by another phase. | 129 | + | // per tool-call id, so a streaming edit/write/bash box never |
| 130 | - | LiveToolMinRows int | 130 | + | // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only |
| 131 | - | LastLiveToolRows int | 131 | + | // consulted while a call has no Result; interactive mode prunes |
| 132 | - | StatusLine string | 132 | + | // the map when a turn ends. |
| 133 | - | Err string | 133 | + | liveBodyHigh map[string]int |
| 134 | 134 | ||||
| 135 | // ExpandAll forces every long tool result to render in full. | 135 | // ExpandAll forces every long tool result to render in full. | ||
| 136 | // Toggled from the tui by ctrl+o. When false, results longer than | 136 | // Toggled from the tui by ctrl+o. When false, results longer than | ||
| @@ -257,8 +257,17 @@ func (v *View) BuildLive(width int) []string { | |||||
| 257 | } | 257 | } | ||
| 258 | } | 258 | } | ||
| 259 | } | 259 | } | ||
| 260 | - | liveTools := v.renderLiveToolCalls(width, finalised, len(out) > 0) | 260 | + | insertedToolGap := false |
| 261 | - | out = append(out, liveTools...) | 261 | + | for _, tc := range v.ToolCalls { |
| 262 | + | if finalised[tc.ID] { | |||
| 263 | + | continue | |||
| 264 | + | } | |||
| 265 | + | if !insertedToolGap && len(out) > 0 { | |||
| 266 | + | out = append(out, "") | |||
| 267 | + | } | |||
| 268 | + | insertedToolGap = true | |||
| 269 | + | out = append(out, v.renderToolCall(tc, width)...) | |||
| 270 | + | } | |||
| 262 | if v.Err != "" { | 271 | if v.Err != "" { | ||
| 263 | out = append(out, v.renderErr(width)...) | 272 | out = append(out, v.renderErr(width)...) | ||
| 264 | // out = append(out, "") | 273 | // out = append(out, "") | ||
| @@ -302,6 +311,24 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 302 | if v.renderCache == nil { | 311 | if v.renderCache == nil { | ||
| 303 | v.renderCache = make(map[msgCacheKey][]string) | 312 | v.renderCache = make(map[msgCacheKey][]string) | ||
| 304 | } | 313 | } | ||
| 314 | + | if v.liveBodyHigh == nil { | |||
| 315 | + | v.liveBodyHigh = make(map[string]int) | |||
| 316 | + | } | |||
| 317 | + | // Drop high-water entries for calls that are gone or finalised so | |||
| 318 | + | // the reservation never leaks into a later, shorter tool box. | |||
| 319 | + | if len(v.liveBodyHigh) > 0 { | |||
| 320 | + | active := make(map[string]bool, len(v.ToolCalls)) | |||
| 321 | + | for _, tc := range v.ToolCalls { | |||
| 322 | + | if tc.Result == "" { | |||
| 323 | + | active[tc.ID] = true | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | for id := range v.liveBodyHigh { | |||
| 327 | + | if !active[id] { | |||
| 328 | + | delete(v.liveBodyHigh, id) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 305 | 332 | ||||
| 306 | // Pre-render every message (hits the cache for unchanged ones) so | 333 | // Pre-render every message (hits the cache for unchanged ones) so | ||
| 307 | // we can allocate `out` in a single shot with the exact capacity. | 334 | // we can allocate `out` in a single shot with the exact capacity. | ||
| @@ -404,9 +431,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 404 | } | 431 | } | ||
| 405 | } | 432 | } | ||
| 406 | } | 433 | } | ||
| 407 | - | liveTools := v.renderLiveToolCalls(width, finalised, false) | 434 | + | for _, tc := range v.ToolCalls { |
| 408 | - | out = append(out, liveTools...) | 435 | + | if finalised[tc.ID] { |
| 409 | - | if len(liveTools) > 0 { | 436 | + | continue |
| 437 | + | } | |||
| 438 | + | out = append(out, v.renderToolCall(tc, width)...) | |||
| 410 | out = append(out, "") | 439 | out = append(out, "") | ||
| 411 | } | 440 | } | ||
| 412 | if v.Err != "" { | 441 | if v.Err != "" { | ||
| @@ -705,70 +734,6 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 705 | return lines | 734 | return lines | ||
| 706 | } | 735 | } | ||
| 707 | 736 | ||||
| 708 | - | func (v *View) renderLiveToolCalls(width int, finalised map[string]bool, leadingGap bool) []string { | |||
| 709 | - | var out []string | |||
| 710 | - | insertedToolGap := false | |||
| 711 | - | for _, tc := range v.ToolCalls { | |||
| 712 | - | if finalised[tc.ID] { | |||
| 713 | - | continue | |||
| 714 | - | } | |||
| 715 | - | if !insertedToolGap && leadingGap { | |||
| 716 | - | out = append(out, "") | |||
| 717 | - | } | |||
| 718 | - | insertedToolGap = true | |||
| 719 | - | out = append(out, v.renderToolCall(tc, width)...) | |||
| 720 | - | } | |||
| 721 | - | v.LastLiveToolRows = len(out) | |||
| 722 | - | minRows := v.LiveToolMinRows | |||
| 723 | - | if minRows < v.LastLiveToolRows { | |||
| 724 | - | minRows = v.LastLiveToolRows | |||
| 725 | - | } | |||
| 726 | - | // Pad up to the reserved height so a live tool overlay never | |||
| 727 | - | // shrinks within a turn (which would yank the editor/status band | |||
| 728 | - | // upward and then push it back down on the next phase). The pad | |||
| 729 | - | // rows are injected *inside* the last open tool box, just above its | |||
| 730 | - | // bottom edge, so they are real box-side rows: trailing-blank | |||
| 731 | - | // trimming in the caller can't strip them and the box keeps a | |||
| 732 | - | // stable footprint. | |||
| 733 | - | if need := minRows - len(out); need > 0 { | |||
| 734 | - | out = v.padLiveToolBox(out, need, width) | |||
| 735 | - | } | |||
| 736 | - | return out | |||
| 737 | - | } | |||
| 738 | - | ||||
| 739 | - | // padLiveToolBox inserts `need` blank box-side rows just before the | |||
| 740 | - | // final bottom-edge row of out. If the last row isn't a box bottom | |||
| 741 | - | // edge (no open box to grow), it falls back to appending plain blank | |||
| 742 | - | // rows. Returns the grown slice. | |||
| 743 | - | func (v *View) padLiveToolBox(out []string, need, width int) []string { | |||
| 744 | - | bottomIdx := -1 | |||
| 745 | - | for i := len(out) - 1; i >= 0; i-- { | |||
| 746 | - | if strings.Contains(stripANSI(out[i]), "\u2514") { // "\u2514" = box bottom-left corner | |||
| 747 | - | bottomIdx = i | |||
| 748 | - | break | |||
| 749 | - | } | |||
| 750 | - | if strings.TrimSpace(stripANSI(out[i])) != "" { | |||
| 751 | - | break | |||
| 752 | - | } | |||
| 753 | - | } | |||
| 754 | - | if bottomIdx < 0 { | |||
| 755 | - | for need > 0 { | |||
| 756 | - | out = append(out, "") | |||
| 757 | - | need-- | |||
| 758 | - | } | |||
| 759 | - | return out | |||
| 760 | - | } | |||
| 761 | - | pad := make([]string, 0, need) | |||
| 762 | - | for i := 0; i < need; i++ { | |||
| 763 | - | pad = append(pad, toolBoxSide(v.Theme, "", width)) | |||
| 764 | - | } | |||
| 765 | - | grown := make([]string, 0, len(out)+need) | |||
| 766 | - | grown = append(grown, out[:bottomIdx]...) | |||
| 767 | - | grown = append(grown, pad...) | |||
| 768 | - | grown = append(grown, out[bottomIdx:]...) | |||
| 769 | - | return grown | |||
| 770 | - | } | |||
| 771 | - | ||||
| 772 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | 737 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | ||
| 773 | var lines []string | 738 | var lines []string | ||
| 774 | 739 | ||||
| @@ -796,6 +761,22 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 796 | // EvToolUseEnd and EvToolResult. | 761 | // EvToolUseEnd and EvToolResult. | ||
| 797 | if tc.Result == "" { | 762 | if tc.Result == "" { | ||
| 798 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | 763 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | ||
| 764 | + | // Pad the live preview up to the call's high-water height so | |||
| 765 | + | // it never shrinks mid-stream (e.g. between edit 1 and the | |||
| 766 | + | // start of edit 2). Padding sits inside the box as real | |||
| 767 | + | // box-side rows so the surrounding layout stays put. The | |||
| 768 | + | // high-water mark is per call id and is dropped the moment a | |||
| 769 | + | // Result lands (this branch only runs while Result == ""). | |||
| 770 | + | high := len(body) | |||
| 771 | + | if v.liveBodyHigh != nil { | |||
| 772 | + | if prev := v.liveBodyHigh[tc.ID]; prev > high { | |||
| 773 | + | high = prev | |||
| 774 | + | } | |||
| 775 | + | v.liveBodyHigh[tc.ID] = high | |||
| 776 | + | } | |||
| 777 | + | for len(body) < high { | |||
| 778 | + | body = append(body, toolBoxSide(v.Theme, "", width)) | |||
| 779 | + | } | |||
| 799 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | 780 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | ||
| 800 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | 781 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | ||
| 801 | lines = append(lines, body...) | 782 | lines = append(lines, body...) | ||
M packages/tui/view_tool_overlay_test.go +46 -22
| @@ -51,40 +51,64 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func TestLiveToolOverlayHeightDoesNotShrink(t *testing.T) { | |
| 54 | + | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | |
| 55 | + | v := View{Theme: Dark} | |
| 55 | 56 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |
| 56 | - | v := View{ | |
| 57 | - | Theme: Dark, | |
| 58 | - | ToolCalls: []ToolCallView{ | |
| 59 | - | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |
| 60 | - | }, | |
| 57 | + | v.ToolCalls = []ToolCallView{ | |
| 58 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |
| 61 | 59 | } | |
| 62 | - | tallRows := len(v.BuildLive(80)) | |
| 60 | + | tallRows := len(v.Build(80)) | |
| 63 | 61 | if tallRows == 0 { | |
| 64 | 62 | t.Fatal("expected rows for the tall command") | |
| 65 | 63 | } | |
| 66 | 64 | ||
| 67 | - | // Reserve the observed height, then switch to a shorter command | |
| 68 | - | // (e.g. the next phase of the same turn). The overlay must stay at | |
| 69 | - | // least as tall as before so the editor/status area never jumps up. | |
| 70 | - | v.LiveToolMinRows = tallRows | |
| 65 | + | // Same call id, but the streamed command shrinks (a transient | |
| 66 | + | // mid-stream state). The box must keep its high-water height so the | |
| 67 | + | // editor/status band below doesn't bounce. | |
| 68 | + | short := json.RawMessage(`{"command":"echo hi"}`) | |
| 69 | + | v.ToolCalls = []ToolCallView{ | |
| 70 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | |
| 71 | + | } | |
| 72 | + | if got := len(v.Build(80)); got < tallRows { | |
| 73 | + | t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) { | |
| 78 | + | v := View{Theme: Dark} | |
| 79 | + | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |
| 80 | + | v.ToolCalls = []ToolCallView{ | |
| 81 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |
| 82 | + | } | |
| 83 | + | tallRows := len(v.Build(80)) | |
| 84 | + | ||
| 85 | + | // A *different* tool call that is short must NOT inherit the tall | |
| 86 | + | // call's reservation; it renders at its own natural height. (This | |
| 87 | + | // is the regression that produced a giant empty box.) | |
| 71 | 88 | short := json.RawMessage(`{"command":"echo hi"}`) | |
| 72 | 89 | v.ToolCalls = []ToolCallView{ | |
| 73 | 90 | {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | |
| 74 | 91 | } | |
| 75 | - | shortBuild := v.BuildLive(80) | |
| 76 | - | if got := len(shortBuild); got < tallRows { | |
| 77 | - | t.Fatalf("live overlay shrank from %d to %d rows", tallRows, got) | |
| 92 | + | if got := len(v.Build(80)); got >= tallRows { | |
| 93 | + | t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows) | |
| 78 | 94 | } | |
| 79 | - | // The interactive caller strips trailing blank rows; the | |
| 80 | - | // reservation must survive that trim, so the pad rows have to be | |
| 81 | - | // non-blank box rows rather than trailing blanks. | |
| 82 | - | trimmed := shortBuild | |
| 83 | - | for len(trimmed) > 0 && strings.TrimSpace(stripANSI(trimmed[len(trimmed)-1])) == "" { | |
| 84 | - | trimmed = trimmed[:len(trimmed)-1] | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestLiveToolReservationDropsOnResult(t *testing.T) { | |
| 98 | + | v := View{Theme: Dark} | |
| 99 | + | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |
| 100 | + | v.ToolCalls = []ToolCallView{ | |
| 101 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |
| 102 | + | } | |
| 103 | + | tallRows := len(v.Build(80)) | |
| 104 | + | ||
| 105 | + | // Same id, now finalised with a tiny error result. The box must | |
| 106 | + | // collapse to the natural result height instead of staying padded. | |
| 107 | + | v.ToolCalls = []ToolCallView{ | |
| 108 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"}, | |
| 85 | 109 | } | |
| 86 | - | if len(trimmed) < tallRows { | |
| 87 | - | t.Fatalf("reservation lost to trailing-blank trim: %d rows after trim, want >= %d", len(trimmed), tallRows) | |
| 110 | + | if got := len(v.Build(80)); got >= tallRows { | |
| 111 | + | t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows) | |
| 88 | 112 | } | |
| 89 | 113 | } | |
| 90 | 114 | ||
| @@ -51,40 +51,64 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||||
| 51 | } | 51 | } | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | func TestLiveToolOverlayHeightDoesNotShrink(t *testing.T) { | 54 | + | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { |
| 55 | + | v := View{Theme: Dark} | |||
| 55 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | 56 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | ||
| 56 | - | v := View{ | 57 | + | v.ToolCalls = []ToolCallView{ |
| 57 | - | Theme: Dark, | 58 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, |
| 58 | - | ToolCalls: []ToolCallView{ | |||
| 59 | - | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |||
| 60 | - | }, | |||
| 61 | } | 59 | } | ||
| 62 | - | tallRows := len(v.BuildLive(80)) | 60 | + | tallRows := len(v.Build(80)) |
| 63 | if tallRows == 0 { | 61 | if tallRows == 0 { | ||
| 64 | t.Fatal("expected rows for the tall command") | 62 | t.Fatal("expected rows for the tall command") | ||
| 65 | } | 63 | } | ||
| 66 | 64 | ||||
| 67 | - | // Reserve the observed height, then switch to a shorter command | 65 | + | // Same call id, but the streamed command shrinks (a transient |
| 68 | - | // (e.g. the next phase of the same turn). The overlay must stay at | 66 | + | // mid-stream state). The box must keep its high-water height so the |
| 69 | - | // least as tall as before so the editor/status area never jumps up. | 67 | + | // editor/status band below doesn't bounce. |
| 70 | - | v.LiveToolMinRows = tallRows | 68 | + | short := json.RawMessage(`{"command":"echo hi"}`) |
| 69 | + | v.ToolCalls = []ToolCallView{ | |||
| 70 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | |||
| 71 | + | } | |||
| 72 | + | if got := len(v.Build(80)); got < tallRows { | |||
| 73 | + | t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) { | |||
| 78 | + | v := View{Theme: Dark} | |||
| 79 | + | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |||
| 80 | + | v.ToolCalls = []ToolCallView{ | |||
| 81 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |||
| 82 | + | } | |||
| 83 | + | tallRows := len(v.Build(80)) | |||
| 84 | + | ||||
| 85 | + | // A *different* tool call that is short must NOT inherit the tall | |||
| 86 | + | // call's reservation; it renders at its own natural height. (This | |||
| 87 | + | // is the regression that produced a giant empty box.) | |||
| 71 | short := json.RawMessage(`{"command":"echo hi"}`) | 88 | short := json.RawMessage(`{"command":"echo hi"}`) | ||
| 72 | v.ToolCalls = []ToolCallView{ | 89 | v.ToolCalls = []ToolCallView{ | ||
| 73 | {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | 90 | {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | ||
| 74 | } | 91 | } | ||
| 75 | - | shortBuild := v.BuildLive(80) | 92 | + | if got := len(v.Build(80)); got >= tallRows { |
| 76 | - | if got := len(shortBuild); got < tallRows { | 93 | + | t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows) |
| 77 | - | t.Fatalf("live overlay shrank from %d to %d rows", tallRows, got) | |||
| 78 | } | 94 | } | ||
| 79 | - | // The interactive caller strips trailing blank rows; the | 95 | + | } |
| 80 | - | // reservation must survive that trim, so the pad rows have to be | 96 | + | |
| 81 | - | // non-blank box rows rather than trailing blanks. | 97 | + | func TestLiveToolReservationDropsOnResult(t *testing.T) { |
| 82 | - | trimmed := shortBuild | 98 | + | v := View{Theme: Dark} |
| 83 | - | for len(trimmed) > 0 && strings.TrimSpace(stripANSI(trimmed[len(trimmed)-1])) == "" { | 99 | + | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) |
| 84 | - | trimmed = trimmed[:len(trimmed)-1] | 100 | + | v.ToolCalls = []ToolCallView{ |
| 101 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)}, | |||
| 102 | + | } | |||
| 103 | + | tallRows := len(v.Build(80)) | |||
| 104 | + | ||||
| 105 | + | // Same id, now finalised with a tiny error result. The box must | |||
| 106 | + | // collapse to the natural result height instead of staying padded. | |||
| 107 | + | v.ToolCalls = []ToolCallView{ | |||
| 108 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"}, | |||
| 85 | } | 109 | } | ||
| 86 | - | if len(trimmed) < tallRows { | 110 | + | if got := len(v.Build(80)); got >= tallRows { |
| 87 | - | t.Fatalf("reservation lost to trailing-blank trim: %d rows after trim, want >= %d", len(trimmed), tallRows) | 111 | + | t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows) |
| 88 | } | 112 | } | ||
| 89 | } | 113 | } | ||
| 90 | 114 | ||||