interactive: keep viewport anchored when streaming below

scrollOffset is measured from the bottom of the chat buffer, so when
the agent appends new lines while the user has scrolled up to read
history, the visible window slides down through the buffer and the
content the user was reading drifts off the top.

Track the previous chat line count and column width across redraws.
While the user is in free-scroll (scrollOffset > 0) and the terminal
hasn't been resized, bump scrollOffset by the chat-length delta so
the visible content stays pinned. Compensation is skipped on resize
(line counts aren't comparable across reflows) and when following
the tail (scrollOffset == 0), where new content should keep pushing
the viewport as before.
This commit is contained in:
patriceckhart 2026-04-27 09:42:44 +02:00
parent 5905729776
commit ec79818512

View file

@ -192,6 +192,17 @@ type Interactive struct {
scrollOffset int // rows from the bottom; 0 = pinned to latest
prevScrollOffset int // last value redraw snapped against; tracks intent
// prevChatLen and prevChatCols track the chat buffer's size at the
// last redraw so that when content grows below the user's viewport
// while they're scrolled up reading history, we can bump
// scrollOffset by exactly the growth and keep the visible content
// pinned. Without this, every streamed line shifts the visible
// window down through the buffer (because scrollOffset is measured
// from the bottom) and the user's reading position drifts upward
// and off the top.
prevChatLen int
prevChatCols int
// Messages typed while a turn is in flight. Each is delivered as
// its own follow-up turn once the current one finishes. Rendered
// above the status bar as "sliding in: ..." chips.
@ -815,6 +826,30 @@ func (i *Interactive) redraw() {
chatRows = 1
}
// Auto-follow guard: when the user has scrolled up (scrollOffset
// > 0) and the agent appends new content below the viewport while
// they're reading, compensate so the visible content stays
// anchored. scrollOffset is measured from the bottom of `chat`,
// so without compensation a growing buffer pushes the window
// downward through the content and the lines the user was
// reading scroll off the top.
//
// Skip compensation when the terminal width changed (a resize
// reflows the whole buffer and the line-count delta no longer
// corresponds to appended content) and when scrollOffset is 0
// (the user is following the tail and wants new content to push
// the view down as usual).
if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
if delta := len(chat) - i.prevChatLen; delta != 0 {
i.scrollOffset += delta
if i.scrollOffset < 0 {
i.scrollOffset = 0
}
}
}
i.prevChatLen = len(chat)
i.prevChatCols = cols
// Apply scroll offset to the chat slice.
maxOffset := len(chat) - chatRows
if maxOffset < 0 {