detached 292bc58

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 {
122122 Streaming string // current assistant text delta
123123 StreamingActive bool
124124 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
134134
135135 // ExpandAll forces every long tool result to render in full.
136136 // Toggled from the tui by ctrl+o. When false, results longer than
@@ -257,8 +257,17 @@ func (v *View) BuildLive(width int) []string {
257257 }
258258 }
259259 }
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+ }
262271 if v.Err != "" {
263272 out = append(out, v.renderErr(width)...)
264273 // out = append(out, "")
@@ -302,6 +311,24 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
302311 if v.renderCache == nil {
303312 v.renderCache = make(map[msgCacheKey][]string)
304313 }
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+ }
305332
306333 // Pre-render every message (hits the cache for unchanged ones) so
307334 // we can allocate `out` in a single shot with the exact capacity.
@@ -404,9 +431,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
404431 }
405432 }
406433 }
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)...)
410439 out = append(out, "")
411440 }
412441 if v.Err != "" {
@@ -705,70 +734,6 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
705734 return lines
706735 }
707736
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-
772737 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
773738 var lines []string
774739
@@ -796,6 +761,22 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
796761 // EvToolUseEnd and EvToolResult.
797762 if tc.Result == "" {
798763 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+ }
799780 lines = append(lines, toolBoxTop(v.Theme, label, width))
800781 lines = append(lines, toolBoxSide(v.Theme, "", width))
801782 lines = append(lines, body...)
@@ -122,15 +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- // LiveToolMinRows reserves vertical space for live tool overlays.125+ StatusLine string
126- // Interactive mode feeds this with the max rows observed in the126+ Err string
127- // current turn so a streaming/editing tool never makes the lower127+
128- // status/editor area jump upward when its preview shrinks or is128+ // 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 int130+ // shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only
131- LastLiveToolRows int131+ // consulted while a call has no Result; interactive mode prunes
132- StatusLine string132+ // the map when a turn ends.
133- Err string133+ 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 than136 // 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) so333 // 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 lines734 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 []string738 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) {
5151 }
5252 }
5353
54-func TestLiveToolOverlayHeightDoesNotShrink(t *testing.T) {
54+func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
55+ v := View{Theme: Dark}
5556 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)},
6159 }
62- tallRows := len(v.BuildLive(80))
60+ tallRows := len(v.Build(80))
6361 if tallRows == 0 {
6462 t.Fatal("expected rows for the tall command")
6563 }
6664
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.)
7188 short := json.RawMessage(`{"command":"echo hi"}`)
7289 v.ToolCalls = []ToolCallView{
7390 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
7491 }
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)
7894 }
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"},
85109 }
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)
88112 }
89113 }
90114
@@ -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 command65+ // Same call id, but the streamed command shrinks (a transient
68- // (e.g. the next phase of the same turn). The overlay must stay at66+ // 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 = tallRows68+ 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; the95+}
80- // reservation must survive that trim, so the pad rows have to be96+
81- // non-blank box rows rather than trailing blanks.97+func TestLiveToolReservationDropsOnResult(t *testing.T) {
82- trimmed := shortBuild98+ 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close