detached 0250ce1

fix(tui): keep scroll position stable in resumed sessions

commit
0250ce1c4820a2e34c8164af397617a7fee94eca
author
patriceckhart · · 2026-06-19 16:09 UTC
parent
292bc58
signature
unsigned
refs
tag: v0.2.40
2 files · +118 -16
A packages/agent/modes/anchor_scroll_test.go +45 -0
@@ -0,0 +1,45 @@
1+package modes
2+
3+import "testing"
4+
5+func TestAnchorScrollOffsetKeepsTopVisibleRow(t *testing.T) {
6+ // The user is scrolled up. The top visible row of the chat window is
7+ // start = chatLen - offset - chatRows. After any redraw that grows
8+ // the buffer and/or changes the viewport height, the SAME top row
9+ // must remain visible.
10+ cases := []struct {
11+ name string
12+ offset, prevLen, newLen, prevRows, newRows int
13+ }{
14+ {"agent appends streamed lines", 20, 200, 208, 30, 30},
15+ {"bottom band grows (chatRows shrinks)", 20, 200, 200, 30, 26},
16+ {"streamed text plus growing status band", 20, 200, 205, 30, 27},
17+ {"buffer shrinks when streaming block finalises", 20, 200, 196, 30, 30},
18+ {"both grow", 5, 100, 140, 20, 24},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.name, func(t *testing.T) {
22+ startBefore := c.prevLen - c.offset - c.prevRows
23+ got := anchorScrollOffset(c.offset, c.prevLen, c.newLen, c.prevRows, c.newRows)
24+ startAfter := c.newLen - got - c.newRows
25+ if startAfter != startBefore {
26+ t.Fatalf("top row drifted: before=%d after=%d (offset %d->%d)",
27+ startBefore, startAfter, c.offset, got)
28+ }
29+ })
30+ }
31+}
32+
33+func TestAnchorScrollOffsetClampsToZero(t *testing.T) {
34+ // A large negative adjustment (buffer shrank a lot) must clamp at 0
35+ // rather than going negative.
36+ if got := anchorScrollOffset(3, 200, 100, 30, 30); got != 0 {
37+ t.Fatalf("offset = %d; want 0", got)
38+ }
39+}
40+
41+func TestAnchorScrollOffsetClampsToLen(t *testing.T) {
42+ if got := anchorScrollOffset(5, 10, 20, 100, 8); got > 20 {
43+ t.Fatalf("offset = %d; want <= newLen 20", got)
44+ }
45+}
@@ -0,0 +1,45 @@
1+package modes
2+
3+import "testing"
4+
5+func TestAnchorScrollOffsetKeepsTopVisibleRow(t *testing.T) {
6+ // The user is scrolled up. The top visible row of the chat window is
7+ // start = chatLen - offset - chatRows. After any redraw that grows
8+ // the buffer and/or changes the viewport height, the SAME top row
9+ // must remain visible.
10+ cases := []struct {
11+ name string
12+ offset, prevLen, newLen, prevRows, newRows int
13+ }{
14+ {"agent appends streamed lines", 20, 200, 208, 30, 30},
15+ {"bottom band grows (chatRows shrinks)", 20, 200, 200, 30, 26},
16+ {"streamed text plus growing status band", 20, 200, 205, 30, 27},
17+ {"buffer shrinks when streaming block finalises", 20, 200, 196, 30, 30},
18+ {"both grow", 5, 100, 140, 20, 24},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.name, func(t *testing.T) {
22+ startBefore := c.prevLen - c.offset - c.prevRows
23+ got := anchorScrollOffset(c.offset, c.prevLen, c.newLen, c.prevRows, c.newRows)
24+ startAfter := c.newLen - got - c.newRows
25+ if startAfter != startBefore {
26+ t.Fatalf("top row drifted: before=%d after=%d (offset %d->%d)",
27+ startBefore, startAfter, c.offset, got)
28+ }
29+ })
30+ }
31+}
32+
33+func TestAnchorScrollOffsetClampsToZero(t *testing.T) {
34+ // A large negative adjustment (buffer shrank a lot) must clamp at 0
35+ // rather than going negative.
36+ if got := anchorScrollOffset(3, 200, 100, 30, 30); got != 0 {
37+ t.Fatalf("offset = %d; want 0", got)
38+ }
39+}
40+
41+func TestAnchorScrollOffsetClampsToLen(t *testing.T) {
42+ if got := anchorScrollOffset(5, 10, 20, 100, 8); got > 20 {
43+ t.Fatalf("offset = %d; want <= newLen 20", got)
44+ }
45+}
M packages/agent/modes/interactive.go +73 -16
@@ -283,7 +283,6 @@ 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
287286 statusErr string
288287 statusOK string
289288 liveBlock []string // live streaming/tool progress rendered outside scrollback
@@ -306,6 +305,7 @@ type Interactive struct {
306305 // and off the top.
307306 prevChatLen int
308307 prevChatCols int
308+ prevChatRows int
309309 prevOverlayOpen bool
310310
311311 // chatCache stores the built transcript/status-note rows for idle
@@ -856,10 +856,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
856856 // the final assistant text — which looks like the summary came
857857 // "before" the tools.
858858 i.view.ToolCalls = i.view.ToolCalls[:0]
859- if !i.busy {
860- i.liveToolRowsMax = 0
861- }
862- i.view.LiveToolMinRows = i.liveToolRowsMax
863859 if i.busy {
864860 for _, id := range i.toolOrder {
865861 // Deterministic ordering: a tool block stays hidden until
@@ -883,9 +879,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
883879 // whole bottom band shrinking and shifting chat lines around.
884880 i.liveBlock = nil
885881 chat := i.view.Build(cols)
886- if i.view.LastLiveToolRows > i.liveToolRowsMax {
887- i.liveToolRowsMax = i.view.LastLiveToolRows
888- }
889882
890883 // Welcome banner: shown at the top of the chat area when there is
891884 // no transcript yet. Disappears after the first message is sent.
@@ -995,12 +988,41 @@ func (i *Interactive) scrollBy(delta int) {
995988 i.invalidate()
996989 }
997990
991+// anchorScrollOffset keeps the user's reading position pinned when the
992+// chat buffer grows/shrinks or the viewport height changes between two
993+// redraws while they're scrolled up.
994+//
995+// scrollOffset is measured from the bottom of the chat buffer, so the
996+// top visible row is start = chatLen - scrollOffset - chatRows. To hold
997+// `start` constant we adjust the offset by the buffer-length delta minus
998+// the viewport-height delta. The result is clamped to [0, newLen] so a
999+// shrinking buffer can't push it negative.
1000+func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1001+ adj := (newLen - prevLen) - (newRows - prevRows)
1002+ offset += adj
1003+ if offset < 0 {
1004+ offset = 0
1005+ }
1006+ if offset > newLen {
1007+ offset = newLen
1008+ }
1009+ return offset
1010+}
1011+
9981012 // scrollToBottom pins the view to the latest content.
9991013 func (i *Interactive) scrollToBottom() {
10001014 i.mu.Lock()
10011015 i.scrollOffset = 0
10021016 i.parkedTurn = 0
10031017 i.parkedTotal = 0
1018+ // Reset the auto-follow baseline. scrollToBottom is the resume /
1019+ // session-swap snap point, where the chat buffer changes length
1020+ // wholesale. Without zeroing these, the next render's follow guard
1021+ // compares the fresh transcript's length against a stale baseline
1022+ // and nudges scrollOffset, which reads as a viewport jump right
1023+ // after resume. See commit 43da5e5 for the same fix on new turns.
1024+ i.prevChatLen = 0
1025+ i.prevChatCols = 0
10041026 if i.rend != nil {
10051027 i.rend.Invalidate()
10061028 }
@@ -1241,16 +1263,23 @@ func (i *Interactive) redraw() {
12411263 // corresponds to appended content) and when scrollOffset is 0
12421264 // (the user is following the tail and wants new content to push
12431265 // the view down as usual).
1266+ //
1267+ // The window the user sees starts at row
1268+ // start = len(chat) - scrollOffset - chatRows
1269+ // so to keep `start` fixed across a redraw we must offset by both
1270+ // the buffer growth (len delta) AND the viewport-height change
1271+ // (chatRows delta, e.g. the status band or sliding-in queue
1272+ // appearing during a turn). Compensating only for the len delta
1273+ // let a shrinking chatRows pull the window toward the tail, which
1274+ // read as the viewport jumping to the bottom whenever the agent
1275+ // streamed text or a tool call grew the bottom band.
12441276 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1245- if delta := len(chat) - i.prevChatLen; delta != 0 {
1246- i.scrollOffset += delta
1247- if i.scrollOffset < 0 {
1248- i.scrollOffset = 0
1249- }
1250- }
1277+ i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1278+ i.prevChatLen, len(chat), i.prevChatRows, chatRows)
12511279 }
12521280 i.prevChatLen = len(chat)
12531281 i.prevChatCols = cols
1282+ i.prevChatRows = chatRows
12541283
12551284 // Apply scroll offset to the chat slice.
12561285 maxOffset := len(chat) - chatRows
@@ -1265,6 +1294,7 @@ func (i *Interactive) redraw() {
12651294 // rebuild immediately so the same redraw shows the freshly-revealed
12661295 // rows; otherwise the user would have to scroll again to see them.
12671296 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1297+ prevLen := len(chat)
12681298 i.view.TailLimit += resumeTailExpandStep
12691299 if i.view.TailLimit >= len(i.view.Messages) {
12701300 i.view.TailLimit = 0 // unbounded
@@ -1274,6 +1304,19 @@ func (i *Interactive) redraw() {
12741304 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
12751305 chat = chat[:len(chat)-1]
12761306 }
1307+ // Newly-revealed rows are older messages prepended at the top.
1308+ // scrollOffset counts rows from the bottom, so to keep the
1309+ // viewport visually anchored on the same content the user was
1310+ // looking at we shift it up by the number of rows added. Keep
1311+ // the auto-follow baseline (prevChatLen) in sync with the
1312+ // post-expansion length too, so the next render's follow guard
1313+ // doesn't see this growth as a synthetic delta and yank the
1314+ // viewport again.
1315+ if grew := len(chat) - prevLen; grew > 0 {
1316+ i.scrollOffset += grew
1317+ }
1318+ i.prevChatLen = len(chat)
1319+ i.prevChatCols = cols
12771320 maxOffset = len(chat) - chatRows
12781321 if maxOffset < 0 {
12791322 maxOffset = 0
@@ -3886,6 +3929,11 @@ func (i *Interactive) applySessionSelection(path string) {
38863929 i.parkedTurn = 0
38873930 i.parkedTotal = 0
38883931 i.scrollOffset = 0
3932+ // Fresh transcript swapped in: drop the auto-follow baseline so
3933+ // the next render's follow guard doesn't see the wholesale
3934+ // length change as a delta and jump the viewport.
3935+ i.prevChatLen = 0
3936+ i.prevChatCols = 0
38893937 i.extNotes = nil
38903938 i.view.InvalidateRenderCache()
38913939 if i.agent != nil {
@@ -4397,10 +4445,20 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
43974445 i.toolCalls = map[string]*tui.ToolCallView{}
43984446 i.toolOrder = nil
43994447 i.toolGate = map[string]int{}
4400- i.liveToolRowsMax = 0
44014448 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
44024449 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
44034450 i.scrollOffset = 0 // jump back to the bottom on new turn
4451+ // Lift the resume tail cap once the user starts interacting. The
4452+ // cap is purely a first-paint optimization (don't markdown the
4453+ // whole history before showing anything). Keeping it active during
4454+ // a turn makes the rendered chat a sliding window: appended
4455+ // messages push older ones off the TOP of the buffer, which the
4456+ // renderer must treat as a change above the viewport and repaint
4457+ // fully, snapping the terminal's native scrollback to the bottom on
4458+ // every streamed chunk. A fresh session has no cap (append-only),
4459+ // which is why the jump only shows up in resumed sessions. Dropping
4460+ // the cap here makes resumed turns append-only too.
4461+ i.view.TailLimit = 0
44044462 // Reset the auto-follow baseline so the very next render at
44054463 // interactive.go:1053 doesn't see a synthetic shrink between
44064464 // "last frame had the previous turn's tool overlay" and
@@ -4666,7 +4724,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
46664724 i.toolCalls = map[string]*tui.ToolCallView{}
46674725 i.toolOrder = nil
46684726 i.toolGate = map[string]int{}
4669- i.liveToolRowsMax = 0
46704727 case core.EvTextDelta:
46714728 // Buffer into streamPending; the paintPace ticker drains
46724729 // it into i.streaming a few runes at a time for a smooth
@@ -283,7 +283,6 @@ 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
287 statusErr string286 statusErr string
288 statusOK string287 statusOK string
289 liveBlock []string // live streaming/tool progress rendered outside scrollback288 liveBlock []string // live streaming/tool progress rendered outside scrollback
@@ -306,6 +305,7 @@ type Interactive struct {
306 // and off the top.305 // and off the top.
307 prevChatLen int306 prevChatLen int
308 prevChatCols int307 prevChatCols int
308+ prevChatRows int
309 prevOverlayOpen bool309 prevOverlayOpen bool
310 310
311 // chatCache stores the built transcript/status-note rows for idle311 // chatCache stores the built transcript/status-note rows for idle
@@ -856,10 +856,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
856 // the final assistant text — which looks like the summary came856 // the final assistant text — which looks like the summary came
857 // "before" the tools.857 // "before" the tools.
858 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
863 if i.busy {859 if i.busy {
864 for _, id := range i.toolOrder {860 for _, id := range i.toolOrder {
865 // Deterministic ordering: a tool block stays hidden until861 // Deterministic ordering: a tool block stays hidden until
@@ -883,9 +879,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
883 // whole bottom band shrinking and shifting chat lines around.879 // whole bottom band shrinking and shifting chat lines around.
884 i.liveBlock = nil880 i.liveBlock = nil
885 chat := i.view.Build(cols)881 chat := i.view.Build(cols)
886- if i.view.LastLiveToolRows > i.liveToolRowsMax {
887- i.liveToolRowsMax = i.view.LastLiveToolRows
888- }
889 882
890 // Welcome banner: shown at the top of the chat area when there is883 // Welcome banner: shown at the top of the chat area when there is
891 // no transcript yet. Disappears after the first message is sent.884 // no transcript yet. Disappears after the first message is sent.
@@ -995,12 +988,41 @@ func (i *Interactive) scrollBy(delta int) {
995 i.invalidate()988 i.invalidate()
996 }989 }
997 990
991+// anchorScrollOffset keeps the user's reading position pinned when the
992+// chat buffer grows/shrinks or the viewport height changes between two
993+// redraws while they're scrolled up.
994+//
995+// scrollOffset is measured from the bottom of the chat buffer, so the
996+// top visible row is start = chatLen - scrollOffset - chatRows. To hold
997+// `start` constant we adjust the offset by the buffer-length delta minus
998+// the viewport-height delta. The result is clamped to [0, newLen] so a
999+// shrinking buffer can't push it negative.
1000+func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1001+ adj := (newLen - prevLen) - (newRows - prevRows)
1002+ offset += adj
1003+ if offset < 0 {
1004+ offset = 0
1005+ }
1006+ if offset > newLen {
1007+ offset = newLen
1008+ }
1009+ return offset
1010+}
1011+
998 // scrollToBottom pins the view to the latest content.1012 // scrollToBottom pins the view to the latest content.
999 func (i *Interactive) scrollToBottom() {1013 func (i *Interactive) scrollToBottom() {
1000 i.mu.Lock()1014 i.mu.Lock()
1001 i.scrollOffset = 01015 i.scrollOffset = 0
1002 i.parkedTurn = 01016 i.parkedTurn = 0
1003 i.parkedTotal = 01017 i.parkedTotal = 0
1018+ // Reset the auto-follow baseline. scrollToBottom is the resume /
1019+ // session-swap snap point, where the chat buffer changes length
1020+ // wholesale. Without zeroing these, the next render's follow guard
1021+ // compares the fresh transcript's length against a stale baseline
1022+ // and nudges scrollOffset, which reads as a viewport jump right
1023+ // after resume. See commit 43da5e5 for the same fix on new turns.
1024+ i.prevChatLen = 0
1025+ i.prevChatCols = 0
1004 if i.rend != nil {1026 if i.rend != nil {
1005 i.rend.Invalidate()1027 i.rend.Invalidate()
1006 }1028 }
@@ -1241,16 +1263,23 @@ func (i *Interactive) redraw() {
1241 // corresponds to appended content) and when scrollOffset is 01263 // corresponds to appended content) and when scrollOffset is 0
1242 // (the user is following the tail and wants new content to push1264 // (the user is following the tail and wants new content to push
1243 // the view down as usual).1265 // the view down as usual).
1266+ //
1267+ // The window the user sees starts at row
1268+ // start = len(chat) - scrollOffset - chatRows
1269+ // so to keep `start` fixed across a redraw we must offset by both
1270+ // the buffer growth (len delta) AND the viewport-height change
1271+ // (chatRows delta, e.g. the status band or sliding-in queue
1272+ // appearing during a turn). Compensating only for the len delta
1273+ // let a shrinking chatRows pull the window toward the tail, which
1274+ // read as the viewport jumping to the bottom whenever the agent
1275+ // streamed text or a tool call grew the bottom band.
1244 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {1276 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1245- if delta := len(chat) - i.prevChatLen; delta != 0 {1277+ i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1246- i.scrollOffset += delta1278+ i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1247- if i.scrollOffset < 0 {
1248- i.scrollOffset = 0
1249- }
1250- }
1251 }1279 }
1252 i.prevChatLen = len(chat)1280 i.prevChatLen = len(chat)
1253 i.prevChatCols = cols1281 i.prevChatCols = cols
1282+ i.prevChatRows = chatRows
1254 1283
1255 // Apply scroll offset to the chat slice.1284 // Apply scroll offset to the chat slice.
1256 maxOffset := len(chat) - chatRows1285 maxOffset := len(chat) - chatRows
@@ -1265,6 +1294,7 @@ func (i *Interactive) redraw() {
1265 // rebuild immediately so the same redraw shows the freshly-revealed1294 // rebuild immediately so the same redraw shows the freshly-revealed
1266 // rows; otherwise the user would have to scroll again to see them.1295 // rows; otherwise the user would have to scroll again to see them.
1267 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {1296 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1297+ prevLen := len(chat)
1268 i.view.TailLimit += resumeTailExpandStep1298 i.view.TailLimit += resumeTailExpandStep
1269 if i.view.TailLimit >= len(i.view.Messages) {1299 if i.view.TailLimit >= len(i.view.Messages) {
1270 i.view.TailLimit = 0 // unbounded1300 i.view.TailLimit = 0 // unbounded
@@ -1274,6 +1304,19 @@ func (i *Interactive) redraw() {
1274 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {1304 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1275 chat = chat[:len(chat)-1]1305 chat = chat[:len(chat)-1]
1276 }1306 }
1307+ // Newly-revealed rows are older messages prepended at the top.
1308+ // scrollOffset counts rows from the bottom, so to keep the
1309+ // viewport visually anchored on the same content the user was
1310+ // looking at we shift it up by the number of rows added. Keep
1311+ // the auto-follow baseline (prevChatLen) in sync with the
1312+ // post-expansion length too, so the next render's follow guard
1313+ // doesn't see this growth as a synthetic delta and yank the
1314+ // viewport again.
1315+ if grew := len(chat) - prevLen; grew > 0 {
1316+ i.scrollOffset += grew
1317+ }
1318+ i.prevChatLen = len(chat)
1319+ i.prevChatCols = cols
1277 maxOffset = len(chat) - chatRows1320 maxOffset = len(chat) - chatRows
1278 if maxOffset < 0 {1321 if maxOffset < 0 {
1279 maxOffset = 01322 maxOffset = 0
@@ -3886,6 +3929,11 @@ func (i *Interactive) applySessionSelection(path string) {
3886 i.parkedTurn = 03929 i.parkedTurn = 0
3887 i.parkedTotal = 03930 i.parkedTotal = 0
3888 i.scrollOffset = 03931 i.scrollOffset = 0
3932+ // Fresh transcript swapped in: drop the auto-follow baseline so
3933+ // the next render's follow guard doesn't see the wholesale
3934+ // length change as a delta and jump the viewport.
3935+ i.prevChatLen = 0
3936+ i.prevChatCols = 0
3889 i.extNotes = nil3937 i.extNotes = nil
3890 i.view.InvalidateRenderCache()3938 i.view.InvalidateRenderCache()
3891 if i.agent != nil {3939 if i.agent != nil {
@@ -4397,10 +4445,20 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
4397 i.toolCalls = map[string]*tui.ToolCallView{}4445 i.toolCalls = map[string]*tui.ToolCallView{}
4398 i.toolOrder = nil4446 i.toolOrder = nil
4399 i.toolGate = map[string]int{}4447 i.toolGate = map[string]int{}
4400- i.liveToolRowsMax = 0
4401 i.shellBlock = nil // sending a prompt clears any parked shell-escape log4448 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4402 i.extNotes = nil // ext notes are one-shot; a new prompt clears them4449 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
4403 i.scrollOffset = 0 // jump back to the bottom on new turn4450 i.scrollOffset = 0 // jump back to the bottom on new turn
4451+ // Lift the resume tail cap once the user starts interacting. The
4452+ // cap is purely a first-paint optimization (don't markdown the
4453+ // whole history before showing anything). Keeping it active during
4454+ // a turn makes the rendered chat a sliding window: appended
4455+ // messages push older ones off the TOP of the buffer, which the
4456+ // renderer must treat as a change above the viewport and repaint
4457+ // fully, snapping the terminal's native scrollback to the bottom on
4458+ // every streamed chunk. A fresh session has no cap (append-only),
4459+ // which is why the jump only shows up in resumed sessions. Dropping
4460+ // the cap here makes resumed turns append-only too.
4461+ i.view.TailLimit = 0
4404 // Reset the auto-follow baseline so the very next render at4462 // Reset the auto-follow baseline so the very next render at
4405 // interactive.go:1053 doesn't see a synthetic shrink between4463 // interactive.go:1053 doesn't see a synthetic shrink between
4406 // "last frame had the previous turn's tool overlay" and4464 // "last frame had the previous turn's tool overlay" and
@@ -4666,7 +4724,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4666 i.toolCalls = map[string]*tui.ToolCallView{}4724 i.toolCalls = map[string]*tui.ToolCallView{}
4667 i.toolOrder = nil4725 i.toolOrder = nil
4668 i.toolGate = map[string]int{}4726 i.toolGate = map[string]int{}
4669- i.liveToolRowsMax = 0
4670 case core.EvTextDelta:4727 case core.EvTextDelta:
4671 // Buffer into streamPending; the paintPace ticker drains4728 // Buffer into streamPending; the paintPace ticker drains
4672 // it into i.streaming a few runes at a time for a smooth4729 // it into i.streaming a few runes at a time for a smooth

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