detached ec79818

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.
commit
ec79818512897853ae730e8b8afbca40f8d44c55
author
patriceckhart · · 2026-04-27 07:42 UTC
parent
5905729
signature
unsigned
refs
tag: v0.1.24
1 file · +35 -0
M internal/agent/modes/interactive.go +35 -0
@@ -192,6 +192,17 @@ type Interactive struct {
192192 scrollOffset int // rows from the bottom; 0 = pinned to latest
193193 prevScrollOffset int // last value redraw snapped against; tracks intent
194194
195+ // prevChatLen and prevChatCols track the chat buffer's size at the
196+ // last redraw so that when content grows below the user's viewport
197+ // while they're scrolled up reading history, we can bump
198+ // scrollOffset by exactly the growth and keep the visible content
199+ // pinned. Without this, every streamed line shifts the visible
200+ // window down through the buffer (because scrollOffset is measured
201+ // from the bottom) and the user's reading position drifts upward
202+ // and off the top.
203+ prevChatLen int
204+ prevChatCols int
205+
195206 // Messages typed while a turn is in flight. Each is delivered as
196207 // its own follow-up turn once the current one finishes. Rendered
197208 // above the status bar as "sliding in: ..." chips.
@@ -815,6 +826,30 @@ func (i *Interactive) redraw() {
815826 chatRows = 1
816827 }
817828
829+ // Auto-follow guard: when the user has scrolled up (scrollOffset
830+ // > 0) and the agent appends new content below the viewport while
831+ // they're reading, compensate so the visible content stays
832+ // anchored. scrollOffset is measured from the bottom of `chat`,
833+ // so without compensation a growing buffer pushes the window
834+ // downward through the content and the lines the user was
835+ // reading scroll off the top.
836+ //
837+ // Skip compensation when the terminal width changed (a resize
838+ // reflows the whole buffer and the line-count delta no longer
839+ // corresponds to appended content) and when scrollOffset is 0
840+ // (the user is following the tail and wants new content to push
841+ // the view down as usual).
842+ if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
843+ if delta := len(chat) - i.prevChatLen; delta != 0 {
844+ i.scrollOffset += delta
845+ if i.scrollOffset < 0 {
846+ i.scrollOffset = 0
847+ }
848+ }
849+ }
850+ i.prevChatLen = len(chat)
851+ i.prevChatCols = cols
852+
818853 // Apply scroll offset to the chat slice.
819854 maxOffset := len(chat) - chatRows
820855 if maxOffset < 0 {
@@ -192,6 +192,17 @@ type Interactive struct {
192 scrollOffset int // rows from the bottom; 0 = pinned to latest192 scrollOffset int // rows from the bottom; 0 = pinned to latest
193 prevScrollOffset int // last value redraw snapped against; tracks intent193 prevScrollOffset int // last value redraw snapped against; tracks intent
194 194
195+ // prevChatLen and prevChatCols track the chat buffer's size at the
196+ // last redraw so that when content grows below the user's viewport
197+ // while they're scrolled up reading history, we can bump
198+ // scrollOffset by exactly the growth and keep the visible content
199+ // pinned. Without this, every streamed line shifts the visible
200+ // window down through the buffer (because scrollOffset is measured
201+ // from the bottom) and the user's reading position drifts upward
202+ // and off the top.
203+ prevChatLen int
204+ prevChatCols int
205+
195 // Messages typed while a turn is in flight. Each is delivered as206 // Messages typed while a turn is in flight. Each is delivered as
196 // its own follow-up turn once the current one finishes. Rendered207 // its own follow-up turn once the current one finishes. Rendered
197 // above the status bar as "sliding in: ..." chips.208 // above the status bar as "sliding in: ..." chips.
@@ -815,6 +826,30 @@ func (i *Interactive) redraw() {
815 chatRows = 1826 chatRows = 1
816 }827 }
817 828
829+ // Auto-follow guard: when the user has scrolled up (scrollOffset
830+ // > 0) and the agent appends new content below the viewport while
831+ // they're reading, compensate so the visible content stays
832+ // anchored. scrollOffset is measured from the bottom of `chat`,
833+ // so without compensation a growing buffer pushes the window
834+ // downward through the content and the lines the user was
835+ // reading scroll off the top.
836+ //
837+ // Skip compensation when the terminal width changed (a resize
838+ // reflows the whole buffer and the line-count delta no longer
839+ // corresponds to appended content) and when scrollOffset is 0
840+ // (the user is following the tail and wants new content to push
841+ // the view down as usual).
842+ if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
843+ if delta := len(chat) - i.prevChatLen; delta != 0 {
844+ i.scrollOffset += delta
845+ if i.scrollOffset < 0 {
846+ i.scrollOffset = 0
847+ }
848+ }
849+ }
850+ i.prevChatLen = len(chat)
851+ i.prevChatCols = cols
852+
818 // Apply scroll offset to the chat slice.853 // Apply scroll offset to the chat slice.
819 maxOffset := len(chat) - chatRows854 maxOffset := len(chat) - chatRows
820 if maxOffset < 0 {855 if maxOffset < 0 {

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