fix(tui): reserve live tool overlay height to stop band jump
- commit
- 31437ddf2b8c8eac271c34be0595a7f1b7731355
- author
- patriceckhart · · 2026-06-19 15:41 UTC
- parent
- 8cd6818
- signature
- unsigned
3 files · +125 -19
M packages/agent/modes/interactive.go +10 -0
| @@ -283,6 +283,7 @@ type Interactive struct { | |||
| 283 | 283 | // streamed + still pending) at the moment the tool starts, and | |
| 284 | 284 | // hold the block back until the pacer reaches it. | |
| 285 | 285 | toolGate map[string]int | |
| 286 | + | liveToolRowsMax int | |
| 286 | 287 | statusErr string | |
| 287 | 288 | statusOK string | |
| 288 | 289 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| @@ -855,6 +856,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 855 | 856 | // the final assistant text — which looks like the summary came | |
| 856 | 857 | // "before" the tools. | |
| 857 | 858 | i.view.ToolCalls = i.view.ToolCalls[:0] | |
| 859 | + | if !i.busy { | |
| 860 | + | i.liveToolRowsMax = 0 | |
| 861 | + | } | |
| 862 | + | i.view.LiveToolMinRows = i.liveToolRowsMax | |
| 858 | 863 | if i.busy { | |
| 859 | 864 | for _, id := range i.toolOrder { | |
| 860 | 865 | // Deterministic ordering: a tool block stays hidden until | |
| @@ -878,6 +883,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 878 | 883 | // whole bottom band shrinking and shifting chat lines around. | |
| 879 | 884 | i.liveBlock = nil | |
| 880 | 885 | chat := i.view.Build(cols) | |
| 886 | + | if i.view.LastLiveToolRows > i.liveToolRowsMax { | |
| 887 | + | i.liveToolRowsMax = i.view.LastLiveToolRows | |
| 888 | + | } | |
| 881 | 889 | ||
| 882 | 890 | // Welcome banner: shown at the top of the chat area when there is | |
| 883 | 891 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -4389,6 +4397,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 4389 | 4397 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4390 | 4398 | i.toolOrder = nil | |
| 4391 | 4399 | i.toolGate = map[string]int{} | |
| 4400 | + | i.liveToolRowsMax = 0 | |
| 4392 | 4401 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 4393 | 4402 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 4394 | 4403 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| @@ -4657,6 +4666,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4657 | 4666 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4658 | 4667 | i.toolOrder = nil | |
| 4659 | 4668 | i.toolGate = map[string]int{} | |
| 4669 | + | i.liveToolRowsMax = 0 | |
| 4660 | 4670 | case core.EvTextDelta: | |
| 4661 | 4671 | // Buffer into streamPending; the paintPace ticker drains | |
| 4662 | 4672 | // it into i.streaming a few runes at a time for a smooth | |
| @@ -283,6 +283,7 @@ type Interactive struct { | |||||
| 283 | // streamed + still pending) at the moment the tool starts, and | 283 | // streamed + still pending) at the moment the tool starts, and | ||
| 284 | // hold the block back until the pacer reaches it. | 284 | // hold the block back until the pacer reaches it. | ||
| 285 | toolGate map[string]int | 285 | toolGate map[string]int | ||
| 286 | + | liveToolRowsMax int | |||
| 286 | statusErr string | 287 | statusErr string | ||
| 287 | statusOK string | 288 | statusOK string | ||
| 288 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 289 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| @@ -855,6 +856,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 855 | // the final assistant text — which looks like the summary came | 856 | // the final assistant text — which looks like the summary came | ||
| 856 | // "before" the tools. | 857 | // "before" the tools. | ||
| 857 | i.view.ToolCalls = i.view.ToolCalls[:0] | 858 | i.view.ToolCalls = i.view.ToolCalls[:0] | ||
| 859 | + | if !i.busy { | |||
| 860 | + | i.liveToolRowsMax = 0 | |||
| 861 | + | } | |||
| 862 | + | i.view.LiveToolMinRows = i.liveToolRowsMax | |||
| 858 | if i.busy { | 863 | if i.busy { | ||
| 859 | for _, id := range i.toolOrder { | 864 | for _, id := range i.toolOrder { | ||
| 860 | // Deterministic ordering: a tool block stays hidden until | 865 | // Deterministic ordering: a tool block stays hidden until | ||
| @@ -878,6 +883,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 878 | // whole bottom band shrinking and shifting chat lines around. | 883 | // whole bottom band shrinking and shifting chat lines around. | ||
| 879 | i.liveBlock = nil | 884 | i.liveBlock = nil | ||
| 880 | chat := i.view.Build(cols) | 885 | chat := i.view.Build(cols) | ||
| 886 | + | if i.view.LastLiveToolRows > i.liveToolRowsMax { | |||
| 887 | + | i.liveToolRowsMax = i.view.LastLiveToolRows | |||
| 888 | + | } | |||
| 881 | 889 | ||||
| 882 | // Welcome banner: shown at the top of the chat area when there is | 890 | // Welcome banner: shown at the top of the chat area when there is | ||
| 883 | // no transcript yet. Disappears after the first message is sent. | 891 | // no transcript yet. Disappears after the first message is sent. | ||
| @@ -4389,6 +4397,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 4389 | i.toolCalls = map[string]*tui.ToolCallView{} | 4397 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4390 | i.toolOrder = nil | 4398 | i.toolOrder = nil | ||
| 4391 | i.toolGate = map[string]int{} | 4399 | i.toolGate = map[string]int{} | ||
| 4400 | + | i.liveToolRowsMax = 0 | |||
| 4392 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | 4401 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | ||
| 4393 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | 4402 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 4394 | i.scrollOffset = 0 // jump back to the bottom on new turn | 4403 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| @@ -4657,6 +4666,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 4657 | i.toolCalls = map[string]*tui.ToolCallView{} | 4666 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4658 | i.toolOrder = nil | 4667 | i.toolOrder = nil | ||
| 4659 | i.toolGate = map[string]int{} | 4668 | i.toolGate = map[string]int{} | ||
| 4669 | + | i.liveToolRowsMax = 0 | |||
| 4660 | case core.EvTextDelta: | 4670 | case core.EvTextDelta: | ||
| 4661 | // Buffer into streamPending; the paintPace ticker drains | 4671 | // Buffer into streamPending; the paintPace ticker drains | ||
| 4662 | // it into i.streaming a few runes at a time for a smooth | 4672 | // it into i.streaming a few runes at a time for a smooth | ||
M packages/tui/view.go +78 -19
| @@ -122,8 +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 | - | StatusLine string | |
| 126 | - | Err string | |
| 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 | |
| 127 | 134 | ||
| 128 | 135 | // ExpandAll forces every long tool result to render in full. | |
| 129 | 136 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| @@ -250,18 +257,8 @@ func (v *View) BuildLive(width int) []string { | |||
| 250 | 257 | } | |
| 251 | 258 | } | |
| 252 | 259 | } | |
| 253 | - | insertedToolGap := false | |
| 254 | - | for _, tc := range v.ToolCalls { | |
| 255 | - | if finalised[tc.ID] { | |
| 256 | - | continue | |
| 257 | - | } | |
| 258 | - | if !insertedToolGap && len(out) > 0 { | |
| 259 | - | out = append(out, "") | |
| 260 | - | } | |
| 261 | - | insertedToolGap = true | |
| 262 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 263 | - | // out = append(out, "") | |
| 264 | - | } | |
| 260 | + | liveTools := v.renderLiveToolCalls(width, finalised, len(out) > 0) | |
| 261 | + | out = append(out, liveTools...) | |
| 265 | 262 | if v.Err != "" { | |
| 266 | 263 | out = append(out, v.renderErr(width)...) | |
| 267 | 264 | // out = append(out, "") | |
| @@ -407,11 +404,9 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 407 | 404 | } | |
| 408 | 405 | } | |
| 409 | 406 | } | |
| 410 | - | for _, tc := range v.ToolCalls { | |
| 411 | - | if finalised[tc.ID] { | |
| 412 | - | continue | |
| 413 | - | } | |
| 414 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 407 | + | liveTools := v.renderLiveToolCalls(width, finalised, false) | |
| 408 | + | out = append(out, liveTools...) | |
| 409 | + | if len(liveTools) > 0 { | |
| 415 | 410 | out = append(out, "") | |
| 416 | 411 | } | |
| 417 | 412 | if v.Err != "" { | |
| @@ -710,6 +705,70 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 710 | 705 | return lines | |
| 711 | 706 | } | |
| 712 | 707 | ||
| 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 | + | ||
| 713 | 772 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| 714 | 773 | var lines []string | |
| 715 | 774 | ||
| @@ -122,8 +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 | - | StatusLine string | 125 | + | // LiveToolMinRows reserves vertical space for live tool overlays. |
| 126 | - | Err string | 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 | |||
| 127 | 134 | ||||
| 128 | // ExpandAll forces every long tool result to render in full. | 135 | // ExpandAll forces every long tool result to render in full. | ||
| 129 | // 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 | ||
| @@ -250,18 +257,8 @@ func (v *View) BuildLive(width int) []string { | |||||
| 250 | } | 257 | } | ||
| 251 | } | 258 | } | ||
| 252 | } | 259 | } | ||
| 253 | - | insertedToolGap := false | 260 | + | liveTools := v.renderLiveToolCalls(width, finalised, len(out) > 0) |
| 254 | - | for _, tc := range v.ToolCalls { | 261 | + | out = append(out, liveTools...) |
| 255 | - | if finalised[tc.ID] { | |||
| 256 | - | continue | |||
| 257 | - | } | |||
| 258 | - | if !insertedToolGap && len(out) > 0 { | |||
| 259 | - | out = append(out, "") | |||
| 260 | - | } | |||
| 261 | - | insertedToolGap = true | |||
| 262 | - | out = append(out, v.renderToolCall(tc, width)...) | |||
| 263 | - | // out = append(out, "") | |||
| 264 | - | } | |||
| 265 | if v.Err != "" { | 262 | if v.Err != "" { | ||
| 266 | out = append(out, v.renderErr(width)...) | 263 | out = append(out, v.renderErr(width)...) | ||
| 267 | // out = append(out, "") | 264 | // out = append(out, "") | ||
| @@ -407,11 +404,9 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 407 | } | 404 | } | ||
| 408 | } | 405 | } | ||
| 409 | } | 406 | } | ||
| 410 | - | for _, tc := range v.ToolCalls { | 407 | + | liveTools := v.renderLiveToolCalls(width, finalised, false) |
| 411 | - | if finalised[tc.ID] { | 408 | + | out = append(out, liveTools...) |
| 412 | - | continue | 409 | + | if len(liveTools) > 0 { |
| 413 | - | } | |||
| 414 | - | out = append(out, v.renderToolCall(tc, width)...) | |||
| 415 | out = append(out, "") | 410 | out = append(out, "") | ||
| 416 | } | 411 | } | ||
| 417 | if v.Err != "" { | 412 | if v.Err != "" { | ||
| @@ -710,6 +705,70 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 710 | return lines | 705 | return lines | ||
| 711 | } | 706 | } | ||
| 712 | 707 | ||||
| 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 | + | ||||
| 713 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | 772 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | ||
| 714 | var lines []string | 773 | var lines []string | ||
| 715 | 774 | ||||
M packages/tui/view_tool_overlay_test.go +37 -0
| @@ -51,6 +51,43 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | func TestLiveToolOverlayHeightDoesNotShrink(t *testing.T) { | |
| 55 | + | 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 | + | }, | |
| 61 | + | } | |
| 62 | + | tallRows := len(v.BuildLive(80)) | |
| 63 | + | if tallRows == 0 { | |
| 64 | + | t.Fatal("expected rows for the tall command") | |
| 65 | + | } | |
| 66 | + | ||
| 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 | |
| 71 | + | short := json.RawMessage(`{"command":"echo hi"}`) | |
| 72 | + | v.ToolCalls = []ToolCallView{ | |
| 73 | + | {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | |
| 74 | + | } | |
| 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) | |
| 78 | + | } | |
| 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] | |
| 85 | + | } | |
| 86 | + | if len(trimmed) < tallRows { | |
| 87 | + | t.Fatalf("reservation lost to trailing-blank trim: %d rows after trim, want >= %d", len(trimmed), tallRows) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 54 | 91 | func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) { | |
| 55 | 92 | args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`) | |
| 56 | 93 | v := View{ | |
| @@ -51,6 +51,43 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||||
| 51 | } | 51 | } | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | func TestLiveToolOverlayHeightDoesNotShrink(t *testing.T) { | |||
| 55 | + | 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 | + | }, | |||
| 61 | + | } | |||
| 62 | + | tallRows := len(v.BuildLive(80)) | |||
| 63 | + | if tallRows == 0 { | |||
| 64 | + | t.Fatal("expected rows for the tall command") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 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 | |||
| 71 | + | short := json.RawMessage(`{"command":"echo hi"}`) | |||
| 72 | + | v.ToolCalls = []ToolCallView{ | |||
| 73 | + | {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)}, | |||
| 74 | + | } | |||
| 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) | |||
| 78 | + | } | |||
| 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] | |||
| 85 | + | } | |||
| 86 | + | if len(trimmed) < tallRows { | |||
| 87 | + | t.Fatalf("reservation lost to trailing-blank trim: %d rows after trim, want >= %d", len(trimmed), tallRows) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 54 | func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) { | 91 | func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) { | ||
| 55 | args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`) | 92 | args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`) | ||
| 56 | v := View{ | 93 | v := View{ | ||