detached 31437dd

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 {
283283 // streamed + still pending) at the moment the tool starts, and
284284 // hold the block back until the pacer reaches it.
285285 toolGate map[string]int
286+ liveToolRowsMax int
286287 statusErr string
287288 statusOK string
288289 liveBlock []string // live streaming/tool progress rendered outside scrollback
@@ -855,6 +856,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
855856 // the final assistant text — which looks like the summary came
856857 // "before" the tools.
857858 i.view.ToolCalls = i.view.ToolCalls[:0]
859+ if !i.busy {
860+ i.liveToolRowsMax = 0
861+ }
862+ i.view.LiveToolMinRows = i.liveToolRowsMax
858863 if i.busy {
859864 for _, id := range i.toolOrder {
860865 // Deterministic ordering: a tool block stays hidden until
@@ -878,6 +883,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
878883 // whole bottom band shrinking and shifting chat lines around.
879884 i.liveBlock = nil
880885 chat := i.view.Build(cols)
886+ if i.view.LastLiveToolRows > i.liveToolRowsMax {
887+ i.liveToolRowsMax = i.view.LastLiveToolRows
888+ }
881889
882890 // Welcome banner: shown at the top of the chat area when there is
883891 // no transcript yet. Disappears after the first message is sent.
@@ -4389,6 +4397,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
43894397 i.toolCalls = map[string]*tui.ToolCallView{}
43904398 i.toolOrder = nil
43914399 i.toolGate = map[string]int{}
4400+ i.liveToolRowsMax = 0
43924401 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
43934402 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
43944403 i.scrollOffset = 0 // jump back to the bottom on new turn
@@ -4657,6 +4666,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
46574666 i.toolCalls = map[string]*tui.ToolCallView{}
46584667 i.toolOrder = nil
46594668 i.toolGate = map[string]int{}
4669+ i.liveToolRowsMax = 0
46604670 case core.EvTextDelta:
46614671 // Buffer into streamPending; the paintPace ticker drains
46624672 // 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, and283 // 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]int285 toolGate map[string]int
286+ liveToolRowsMax int
286 statusErr string287 statusErr string
287 statusOK string288 statusOK string
288 liveBlock []string // live streaming/tool progress rendered outside scrollback289 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 came856 // 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 until865 // 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 = nil884 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 is890 // 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 = nil4398 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 log4401 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 them4402 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
4394 i.scrollOffset = 0 // jump back to the bottom on new turn4403 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 = nil4667 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 drains4671 // Buffer into streamPending; the paintPace ticker drains
4662 // it into i.streaming a few runes at a time for a smooth4672 // 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 {
122122 Streaming string // current assistant text delta
123123 StreamingActive bool
124124 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
127134
128135 // ExpandAll forces every long tool result to render in full.
129136 // Toggled from the tui by ctrl+o. When false, results longer than
@@ -250,18 +257,8 @@ func (v *View) BuildLive(width int) []string {
250257 }
251258 }
252259 }
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...)
265262 if v.Err != "" {
266263 out = append(out, v.renderErr(width)...)
267264 // out = append(out, "")
@@ -407,11 +404,9 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
407404 }
408405 }
409406 }
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 {
415410 out = append(out, "")
416411 }
417412 if v.Err != "" {
@@ -710,6 +705,70 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
710705 return lines
711706 }
712707
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+
713772 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
714773 var lines []string
715774
@@ -122,8 +122,15 @@ type View struct {
122 Streaming string // current assistant text delta122 Streaming string // current assistant text delta
123 StreamingActive bool123 StreamingActive bool
124 ToolCalls []ToolCallView // tool calls in flight or completed124 ToolCalls []ToolCallView // tool calls in flight or completed
125- StatusLine string125+ // LiveToolMinRows reserves vertical space for live tool overlays.
126- Err string126+ // 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 than136 // 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 := false260+ 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- continue409+ 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 lines705 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 []string773 var lines []string
715 774
M packages/tui/view_tool_overlay_test.go +37 -0
@@ -51,6 +51,43 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
5151 }
5252 }
5353
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+
5491 func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
5592 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
5693 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{

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 477

↑↓ moveenter openesc close