Skip to content

fix: preserve scroll position when new output arrives#150

Open
sauyon wants to merge 2 commits into
coder:mainfrom
sauyon:sauyon/preserve-scroll-position
Open

fix: preserve scroll position when new output arrives#150
sauyon wants to merge 2 commits into
coder:mainfrom
sauyon:sauyon/preserve-scroll-position

Conversation

@sauyon
Copy link
Copy Markdown

@sauyon sauyon commented Apr 5, 2026

Adjust viewportY by the scrollback delta so the viewport stays locked on the same content while output streams below. Clamp to the current scrollback length in case old lines are dropped by the buffer limit.

When the user has scrolled up to review earlier output, keep the
viewport locked on the same content as new lines arrive instead of
snapping back to the bottom.

Save the scrollback length before each write and adjust `viewportY`
by the delta afterward.  Clamp to the current scrollback length in
case old lines are dropped by the buffer limit.

When the user is already at the bottom (`viewportY === 0`), the
adjustment is skipped and the viewport naturally follows new output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@gogakoreli
Copy link
Copy Markdown

Cross-validated against lib/terminal.ts@9606b0e. Mechanics are correct, but the new branch breaks the emitter contract every other viewportY mutator follows.

scrollEmitter.fire() not called on the new branch. scrollToBottom, scrollToTop, scrollToLine, scrollLines all follow the pattern viewportY = X; scrollEmitter.fire(viewportY) (L931, L955, L966, L984, L1011, L1053). The new code at L590-594 silently mutates viewportY when delta > 0 (the common case this PR fixes — new lines pushing content down while scrolled up). Subscribers of the public onScroll: IEvent<number> (L96) won't see those updates and will drift.

showScrollbar() also lost. Old scrollToBottom calls it on any viewportY change (L935-937); with SCROLLBAR_HIDE_DELAY_MS = 1500 it's the user's affordance for "not at the live edge." Post-patch, a user scrolled up while output streams correctly stays anchored, but the scrollbar fades after 1.5s — no visible signal they're behind.

One change folds both fixes in:

if (savedViewportY > 0) {
  const newScrollback = this.wasmTerm!.getScrollbackLength();
  const delta = Math.max(0, newScrollback - savedScrollback);
  const newViewportY = Math.min(savedViewportY + delta, newScrollback);
  if (newViewportY !== savedViewportY) {
    this.viewportY = newViewportY;
    this.scrollEmitter.fire(this.viewportY);
    if (newScrollback > 0) this.showScrollbar();
  }
}

Also worth a unit test pinning the scrollback-eviction clamp — Math.min(..., newScrollback) is doing real work when scrollbackLimit drops old lines mid-stream and there's no coverage for it today.

@sauyon
Copy link
Copy Markdown
Author

sauyon commented May 15, 2026

Accepted most of this, just moved the new max guard to newViewportY since that feels a bit more correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants