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 { | |||
| 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 | |
| 287 | 286 | statusErr string | |
| 288 | 287 | statusOK string | |
| 289 | 288 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| @@ -306,6 +305,7 @@ type Interactive struct { | |||
| 306 | 305 | // and off the top. | |
| 307 | 306 | prevChatLen int | |
| 308 | 307 | prevChatCols int | |
| 308 | + | prevChatRows int | |
| 309 | 309 | prevOverlayOpen bool | |
| 310 | 310 | ||
| 311 | 311 | // chatCache stores the built transcript/status-note rows for idle | |
| @@ -856,10 +856,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 856 | 856 | // the final assistant text — which looks like the summary came | |
| 857 | 857 | // "before" the tools. | |
| 858 | 858 | i.view.ToolCalls = i.view.ToolCalls[:0] | |
| 859 | - | if !i.busy { | |
| 860 | - | i.liveToolRowsMax = 0 | |
| 861 | - | } | |
| 862 | - | i.view.LiveToolMinRows = i.liveToolRowsMax | |
| 863 | 859 | if i.busy { | |
| 864 | 860 | for _, id := range i.toolOrder { | |
| 865 | 861 | // Deterministic ordering: a tool block stays hidden until | |
| @@ -883,9 +879,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 883 | 879 | // whole bottom band shrinking and shifting chat lines around. | |
| 884 | 880 | i.liveBlock = nil | |
| 885 | 881 | chat := i.view.Build(cols) | |
| 886 | - | if i.view.LastLiveToolRows > i.liveToolRowsMax { | |
| 887 | - | i.liveToolRowsMax = i.view.LastLiveToolRows | |
| 888 | - | } | |
| 889 | 882 | ||
| 890 | 883 | // Welcome banner: shown at the top of the chat area when there is | |
| 891 | 884 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -995,12 +988,41 @@ func (i *Interactive) scrollBy(delta int) { | |||
| 995 | 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 | 1012 | // scrollToBottom pins the view to the latest content. | |
| 999 | 1013 | func (i *Interactive) scrollToBottom() { | |
| 1000 | 1014 | i.mu.Lock() | |
| 1001 | 1015 | i.scrollOffset = 0 | |
| 1002 | 1016 | i.parkedTurn = 0 | |
| 1003 | 1017 | 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 | 1026 | if i.rend != nil { | |
| 1005 | 1027 | i.rend.Invalidate() | |
| 1006 | 1028 | } | |
| @@ -1241,16 +1263,23 @@ func (i *Interactive) redraw() { | |||
| 1241 | 1263 | // corresponds to appended content) and when scrollOffset is 0 | |
| 1242 | 1264 | // (the user is following the tail and wants new content to push | |
| 1243 | 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 | 1276 | 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) | |
| 1251 | 1279 | } | |
| 1252 | 1280 | i.prevChatLen = len(chat) | |
| 1253 | 1281 | i.prevChatCols = cols | |
| 1282 | + | i.prevChatRows = chatRows | |
| 1254 | 1283 | ||
| 1255 | 1284 | // Apply scroll offset to the chat slice. | |
| 1256 | 1285 | maxOffset := len(chat) - chatRows | |
| @@ -1265,6 +1294,7 @@ func (i *Interactive) redraw() { | |||
| 1265 | 1294 | // rebuild immediately so the same redraw shows the freshly-revealed | |
| 1266 | 1295 | // rows; otherwise the user would have to scroll again to see them. | |
| 1267 | 1296 | if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) { | |
| 1297 | + | prevLen := len(chat) | |
| 1268 | 1298 | i.view.TailLimit += resumeTailExpandStep | |
| 1269 | 1299 | if i.view.TailLimit >= len(i.view.Messages) { | |
| 1270 | 1300 | i.view.TailLimit = 0 // unbounded | |
| @@ -1274,6 +1304,19 @@ func (i *Interactive) redraw() { | |||
| 1274 | 1304 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 1275 | 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 | 1320 | maxOffset = len(chat) - chatRows | |
| 1278 | 1321 | if maxOffset < 0 { | |
| 1279 | 1322 | maxOffset = 0 | |
| @@ -3886,6 +3929,11 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 3886 | 3929 | i.parkedTurn = 0 | |
| 3887 | 3930 | i.parkedTotal = 0 | |
| 3888 | 3931 | 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 | 3937 | i.extNotes = nil | |
| 3890 | 3938 | i.view.InvalidateRenderCache() | |
| 3891 | 3939 | if i.agent != nil { | |
| @@ -4397,10 +4445,20 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 4397 | 4445 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4398 | 4446 | i.toolOrder = nil | |
| 4399 | 4447 | i.toolGate = map[string]int{} | |
| 4400 | - | i.liveToolRowsMax = 0 | |
| 4401 | 4448 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 4402 | 4449 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 4403 | 4450 | 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 | 4462 | // Reset the auto-follow baseline so the very next render at | |
| 4405 | 4463 | // interactive.go:1053 doesn't see a synthetic shrink between | |
| 4406 | 4464 | // "last frame had the previous turn's tool overlay" and | |
| @@ -4666,7 +4724,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4666 | 4724 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4667 | 4725 | i.toolOrder = nil | |
| 4668 | 4726 | i.toolGate = map[string]int{} | |
| 4669 | - | i.liveToolRowsMax = 0 | |
| 4670 | 4727 | case core.EvTextDelta: | |
| 4671 | 4728 | // Buffer into streamPending; the paintPace ticker drains | |
| 4672 | 4729 | // 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, 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 | |||
| 287 | statusErr string | 286 | statusErr string | ||
| 288 | statusOK string | 287 | statusOK string | ||
| 289 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 288 | 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 int | 306 | prevChatLen int | ||
| 308 | prevChatCols int | 307 | prevChatCols int | ||
| 308 | + | prevChatRows int | |||
| 309 | prevOverlayOpen bool | 309 | prevOverlayOpen bool | ||
| 310 | 310 | ||||
| 311 | // chatCache stores the built transcript/status-note rows for idle | 311 | // 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 came | 856 | // 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 until | 861 | // 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 = nil | 880 | 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 is | 883 | // 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 = 0 | 1015 | i.scrollOffset = 0 | ||
| 1002 | i.parkedTurn = 0 | 1016 | i.parkedTurn = 0 | ||
| 1003 | i.parkedTotal = 0 | 1017 | 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 0 | 1263 | // corresponds to appended content) and when scrollOffset is 0 | ||
| 1242 | // (the user is following the tail and wants new content to push | 1264 | // (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 += delta | 1278 | + | 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 = cols | 1281 | 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) - chatRows | 1285 | maxOffset := len(chat) - chatRows | ||
| @@ -1265,6 +1294,7 @@ func (i *Interactive) redraw() { | |||||
| 1265 | // rebuild immediately so the same redraw shows the freshly-revealed | 1294 | // 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 += resumeTailExpandStep | 1298 | 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 // unbounded | 1300 | 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) - chatRows | 1320 | maxOffset = len(chat) - chatRows | ||
| 1278 | if maxOffset < 0 { | 1321 | if maxOffset < 0 { | ||
| 1279 | maxOffset = 0 | 1322 | maxOffset = 0 | ||
| @@ -3886,6 +3929,11 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 3886 | i.parkedTurn = 0 | 3929 | i.parkedTurn = 0 | ||
| 3887 | i.parkedTotal = 0 | 3930 | i.parkedTotal = 0 | ||
| 3888 | i.scrollOffset = 0 | 3931 | 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 = nil | 3937 | 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 = nil | 4446 | 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 log | 4448 | 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 them | 4449 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 4403 | i.scrollOffset = 0 // jump back to the bottom on new turn | 4450 | 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 at | 4462 | // Reset the auto-follow baseline so the very next render at | ||
| 4405 | // interactive.go:1053 doesn't see a synthetic shrink between | 4463 | // interactive.go:1053 doesn't see a synthetic shrink between | ||
| 4406 | // "last frame had the previous turn's tool overlay" and | 4464 | // "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 = nil | 4725 | 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 drains | 4728 | // Buffer into streamPending; the paintPace ticker drains | ||
| 4672 | // it into i.streaming a few runes at a time for a smooth | 4729 | // it into i.streaming a few runes at a time for a smooth | ||