mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
fix(tui): keep scroll position stable in resumed sessions
This commit is contained in:
parent
292bc58eb6
commit
0250ce1c48
2 changed files with 118 additions and 16 deletions
45
packages/agent/modes/anchor_scroll_test.go
Normal file
45
packages/agent/modes/anchor_scroll_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package modes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAnchorScrollOffsetKeepsTopVisibleRow(t *testing.T) {
|
||||||
|
// The user is scrolled up. The top visible row of the chat window is
|
||||||
|
// start = chatLen - offset - chatRows. After any redraw that grows
|
||||||
|
// the buffer and/or changes the viewport height, the SAME top row
|
||||||
|
// must remain visible.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
offset, prevLen, newLen, prevRows, newRows int
|
||||||
|
}{
|
||||||
|
{"agent appends streamed lines", 20, 200, 208, 30, 30},
|
||||||
|
{"bottom band grows (chatRows shrinks)", 20, 200, 200, 30, 26},
|
||||||
|
{"streamed text plus growing status band", 20, 200, 205, 30, 27},
|
||||||
|
{"buffer shrinks when streaming block finalises", 20, 200, 196, 30, 30},
|
||||||
|
{"both grow", 5, 100, 140, 20, 24},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
startBefore := c.prevLen - c.offset - c.prevRows
|
||||||
|
got := anchorScrollOffset(c.offset, c.prevLen, c.newLen, c.prevRows, c.newRows)
|
||||||
|
startAfter := c.newLen - got - c.newRows
|
||||||
|
if startAfter != startBefore {
|
||||||
|
t.Fatalf("top row drifted: before=%d after=%d (offset %d->%d)",
|
||||||
|
startBefore, startAfter, c.offset, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorScrollOffsetClampsToZero(t *testing.T) {
|
||||||
|
// A large negative adjustment (buffer shrank a lot) must clamp at 0
|
||||||
|
// rather than going negative.
|
||||||
|
if got := anchorScrollOffset(3, 200, 100, 30, 30); got != 0 {
|
||||||
|
t.Fatalf("offset = %d; want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorScrollOffsetClampsToLen(t *testing.T) {
|
||||||
|
if got := anchorScrollOffset(5, 10, 20, 100, 8); got > 20 {
|
||||||
|
t.Fatalf("offset = %d; want <= newLen 20", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -283,7 +283,6 @@ type Interactive struct {
|
||||||
// streamed + still pending) at the moment the tool starts, and
|
// streamed + still pending) at the moment the tool starts, and
|
||||||
// hold the block back until the pacer reaches it.
|
// hold the block back until the pacer reaches it.
|
||||||
toolGate map[string]int
|
toolGate map[string]int
|
||||||
liveToolRowsMax int
|
|
||||||
statusErr string
|
statusErr string
|
||||||
statusOK string
|
statusOK string
|
||||||
liveBlock []string // live streaming/tool progress rendered outside scrollback
|
liveBlock []string // live streaming/tool progress rendered outside scrollback
|
||||||
|
|
@ -306,6 +305,7 @@ type Interactive struct {
|
||||||
// and off the top.
|
// and off the top.
|
||||||
prevChatLen int
|
prevChatLen int
|
||||||
prevChatCols int
|
prevChatCols int
|
||||||
|
prevChatRows int
|
||||||
prevOverlayOpen bool
|
prevOverlayOpen bool
|
||||||
|
|
||||||
// chatCache stores the built transcript/status-note rows for idle
|
// chatCache stores the built transcript/status-note rows for idle
|
||||||
|
|
@ -856,10 +856,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
|
||||||
// the final assistant text — which looks like the summary came
|
// the final assistant text — which looks like the summary came
|
||||||
// "before" the tools.
|
// "before" the tools.
|
||||||
i.view.ToolCalls = i.view.ToolCalls[:0]
|
i.view.ToolCalls = i.view.ToolCalls[:0]
|
||||||
if !i.busy {
|
|
||||||
i.liveToolRowsMax = 0
|
|
||||||
}
|
|
||||||
i.view.LiveToolMinRows = i.liveToolRowsMax
|
|
||||||
if i.busy {
|
if i.busy {
|
||||||
for _, id := range i.toolOrder {
|
for _, id := range i.toolOrder {
|
||||||
// Deterministic ordering: a tool block stays hidden until
|
// Deterministic ordering: a tool block stays hidden until
|
||||||
|
|
@ -883,9 +879,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
|
||||||
// whole bottom band shrinking and shifting chat lines around.
|
// whole bottom band shrinking and shifting chat lines around.
|
||||||
i.liveBlock = nil
|
i.liveBlock = nil
|
||||||
chat := i.view.Build(cols)
|
chat := i.view.Build(cols)
|
||||||
if i.view.LastLiveToolRows > i.liveToolRowsMax {
|
|
||||||
i.liveToolRowsMax = i.view.LastLiveToolRows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Welcome banner: shown at the top of the chat area when there is
|
// Welcome banner: shown at the top of the chat area when there is
|
||||||
// no transcript yet. Disappears after the first message is sent.
|
// no transcript yet. Disappears after the first message is sent.
|
||||||
|
|
@ -995,12 +988,41 @@ func (i *Interactive) scrollBy(delta int) {
|
||||||
i.invalidate()
|
i.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchorScrollOffset keeps the user's reading position pinned when the
|
||||||
|
// chat buffer grows/shrinks or the viewport height changes between two
|
||||||
|
// redraws while they're scrolled up.
|
||||||
|
//
|
||||||
|
// scrollOffset is measured from the bottom of the chat buffer, so the
|
||||||
|
// top visible row is start = chatLen - scrollOffset - chatRows. To hold
|
||||||
|
// `start` constant we adjust the offset by the buffer-length delta minus
|
||||||
|
// the viewport-height delta. The result is clamped to [0, newLen] so a
|
||||||
|
// shrinking buffer can't push it negative.
|
||||||
|
func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
|
||||||
|
adj := (newLen - prevLen) - (newRows - prevRows)
|
||||||
|
offset += adj
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
if offset > newLen {
|
||||||
|
offset = newLen
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
// scrollToBottom pins the view to the latest content.
|
// scrollToBottom pins the view to the latest content.
|
||||||
func (i *Interactive) scrollToBottom() {
|
func (i *Interactive) scrollToBottom() {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
i.scrollOffset = 0
|
i.scrollOffset = 0
|
||||||
i.parkedTurn = 0
|
i.parkedTurn = 0
|
||||||
i.parkedTotal = 0
|
i.parkedTotal = 0
|
||||||
|
// Reset the auto-follow baseline. scrollToBottom is the resume /
|
||||||
|
// session-swap snap point, where the chat buffer changes length
|
||||||
|
// wholesale. Without zeroing these, the next render's follow guard
|
||||||
|
// compares the fresh transcript's length against a stale baseline
|
||||||
|
// and nudges scrollOffset, which reads as a viewport jump right
|
||||||
|
// after resume. See commit 43da5e5 for the same fix on new turns.
|
||||||
|
i.prevChatLen = 0
|
||||||
|
i.prevChatCols = 0
|
||||||
if i.rend != nil {
|
if i.rend != nil {
|
||||||
i.rend.Invalidate()
|
i.rend.Invalidate()
|
||||||
}
|
}
|
||||||
|
|
@ -1241,16 +1263,23 @@ func (i *Interactive) redraw() {
|
||||||
// corresponds to appended content) and when scrollOffset is 0
|
// corresponds to appended content) and when scrollOffset is 0
|
||||||
// (the user is following the tail and wants new content to push
|
// (the user is following the tail and wants new content to push
|
||||||
// the view down as usual).
|
// the view down as usual).
|
||||||
|
//
|
||||||
|
// The window the user sees starts at row
|
||||||
|
// start = len(chat) - scrollOffset - chatRows
|
||||||
|
// so to keep `start` fixed across a redraw we must offset by both
|
||||||
|
// the buffer growth (len delta) AND the viewport-height change
|
||||||
|
// (chatRows delta, e.g. the status band or sliding-in queue
|
||||||
|
// appearing during a turn). Compensating only for the len delta
|
||||||
|
// let a shrinking chatRows pull the window toward the tail, which
|
||||||
|
// read as the viewport jumping to the bottom whenever the agent
|
||||||
|
// streamed text or a tool call grew the bottom band.
|
||||||
if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
|
if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
|
||||||
if delta := len(chat) - i.prevChatLen; delta != 0 {
|
i.scrollOffset = anchorScrollOffset(i.scrollOffset,
|
||||||
i.scrollOffset += delta
|
i.prevChatLen, len(chat), i.prevChatRows, chatRows)
|
||||||
if i.scrollOffset < 0 {
|
|
||||||
i.scrollOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
i.prevChatLen = len(chat)
|
i.prevChatLen = len(chat)
|
||||||
i.prevChatCols = cols
|
i.prevChatCols = cols
|
||||||
|
i.prevChatRows = chatRows
|
||||||
|
|
||||||
// Apply scroll offset to the chat slice.
|
// Apply scroll offset to the chat slice.
|
||||||
maxOffset := len(chat) - chatRows
|
maxOffset := len(chat) - chatRows
|
||||||
|
|
@ -1265,6 +1294,7 @@ func (i *Interactive) redraw() {
|
||||||
// rebuild immediately so the same redraw shows the freshly-revealed
|
// rebuild immediately so the same redraw shows the freshly-revealed
|
||||||
// rows; otherwise the user would have to scroll again to see them.
|
// rows; otherwise the user would have to scroll again to see them.
|
||||||
if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
|
if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
|
||||||
|
prevLen := len(chat)
|
||||||
i.view.TailLimit += resumeTailExpandStep
|
i.view.TailLimit += resumeTailExpandStep
|
||||||
if i.view.TailLimit >= len(i.view.Messages) {
|
if i.view.TailLimit >= len(i.view.Messages) {
|
||||||
i.view.TailLimit = 0 // unbounded
|
i.view.TailLimit = 0 // unbounded
|
||||||
|
|
@ -1274,6 +1304,19 @@ func (i *Interactive) redraw() {
|
||||||
for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
|
for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
|
||||||
chat = chat[:len(chat)-1]
|
chat = chat[:len(chat)-1]
|
||||||
}
|
}
|
||||||
|
// Newly-revealed rows are older messages prepended at the top.
|
||||||
|
// scrollOffset counts rows from the bottom, so to keep the
|
||||||
|
// viewport visually anchored on the same content the user was
|
||||||
|
// looking at we shift it up by the number of rows added. Keep
|
||||||
|
// the auto-follow baseline (prevChatLen) in sync with the
|
||||||
|
// post-expansion length too, so the next render's follow guard
|
||||||
|
// doesn't see this growth as a synthetic delta and yank the
|
||||||
|
// viewport again.
|
||||||
|
if grew := len(chat) - prevLen; grew > 0 {
|
||||||
|
i.scrollOffset += grew
|
||||||
|
}
|
||||||
|
i.prevChatLen = len(chat)
|
||||||
|
i.prevChatCols = cols
|
||||||
maxOffset = len(chat) - chatRows
|
maxOffset = len(chat) - chatRows
|
||||||
if maxOffset < 0 {
|
if maxOffset < 0 {
|
||||||
maxOffset = 0
|
maxOffset = 0
|
||||||
|
|
@ -3886,6 +3929,11 @@ func (i *Interactive) applySessionSelection(path string) {
|
||||||
i.parkedTurn = 0
|
i.parkedTurn = 0
|
||||||
i.parkedTotal = 0
|
i.parkedTotal = 0
|
||||||
i.scrollOffset = 0
|
i.scrollOffset = 0
|
||||||
|
// Fresh transcript swapped in: drop the auto-follow baseline so
|
||||||
|
// the next render's follow guard doesn't see the wholesale
|
||||||
|
// length change as a delta and jump the viewport.
|
||||||
|
i.prevChatLen = 0
|
||||||
|
i.prevChatCols = 0
|
||||||
i.extNotes = nil
|
i.extNotes = nil
|
||||||
i.view.InvalidateRenderCache()
|
i.view.InvalidateRenderCache()
|
||||||
if i.agent != nil {
|
if i.agent != nil {
|
||||||
|
|
@ -4397,10 +4445,20 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
|
||||||
i.toolCalls = map[string]*tui.ToolCallView{}
|
i.toolCalls = map[string]*tui.ToolCallView{}
|
||||||
i.toolOrder = nil
|
i.toolOrder = nil
|
||||||
i.toolGate = map[string]int{}
|
i.toolGate = map[string]int{}
|
||||||
i.liveToolRowsMax = 0
|
|
||||||
i.shellBlock = nil // sending a prompt clears any parked shell-escape log
|
i.shellBlock = nil // sending a prompt clears any parked shell-escape log
|
||||||
i.extNotes = nil // ext notes are one-shot; a new prompt clears them
|
i.extNotes = nil // ext notes are one-shot; a new prompt clears them
|
||||||
i.scrollOffset = 0 // jump back to the bottom on new turn
|
i.scrollOffset = 0 // jump back to the bottom on new turn
|
||||||
|
// Lift the resume tail cap once the user starts interacting. The
|
||||||
|
// cap is purely a first-paint optimization (don't markdown the
|
||||||
|
// whole history before showing anything). Keeping it active during
|
||||||
|
// a turn makes the rendered chat a sliding window: appended
|
||||||
|
// messages push older ones off the TOP of the buffer, which the
|
||||||
|
// renderer must treat as a change above the viewport and repaint
|
||||||
|
// fully, snapping the terminal's native scrollback to the bottom on
|
||||||
|
// every streamed chunk. A fresh session has no cap (append-only),
|
||||||
|
// which is why the jump only shows up in resumed sessions. Dropping
|
||||||
|
// the cap here makes resumed turns append-only too.
|
||||||
|
i.view.TailLimit = 0
|
||||||
// Reset the auto-follow baseline so the very next render at
|
// Reset the auto-follow baseline so the very next render at
|
||||||
// interactive.go:1053 doesn't see a synthetic shrink between
|
// interactive.go:1053 doesn't see a synthetic shrink between
|
||||||
// "last frame had the previous turn's tool overlay" and
|
// "last frame had the previous turn's tool overlay" and
|
||||||
|
|
@ -4666,7 +4724,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
|
||||||
i.toolCalls = map[string]*tui.ToolCallView{}
|
i.toolCalls = map[string]*tui.ToolCallView{}
|
||||||
i.toolOrder = nil
|
i.toolOrder = nil
|
||||||
i.toolGate = map[string]int{}
|
i.toolGate = map[string]int{}
|
||||||
i.liveToolRowsMax = 0
|
|
||||||
case core.EvTextDelta:
|
case core.EvTextDelta:
|
||||||
// Buffer into streamPending; the paintPace ticker drains
|
// Buffer into streamPending; the paintPace ticker drains
|
||||||
// it into i.streaming a few runes at a time for a smooth
|
// it into i.streaming a few runes at a time for a smooth
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue