Skip to content

fix(terminal): prevent scroll-to-top race during fast output#11102

Open
astex wants to merge 6 commits intoEugeny:masterfrom
astex:fix/scroll-to-top-race
Open

fix(terminal): prevent scroll-to-top race during fast output#11102
astex wants to merge 6 commits intoEugeny:masterfrom
astex:fix/scroll-to-top-race

Conversation

@astex
Copy link
Copy Markdown

@astex astex commented Mar 20, 2026

During fast terminal output (e.g. streaming from Claude Code or other AI tools), the viewport randomly jumps to the top of the scrollback buffer. This makes the terminal unusable during long-running commands that produce rapid output.

The root cause is a race between xterm.js's content-driven scroll and the ResizeObserver-triggered fitAddon.fit(). Tabby disables xterm's built-in scrollToBottom (replacing it with a no-op to manage scroll manually), but the resize handler calls fit() without preserving or restoring the viewport position. During fast output, fit() can reset the viewport to line 0, and since auto-scroll is disabled, nothing brings it back.

A subtlety that makes this worse: xterm.onScroll only fires for content-driven scrolling (new lines added to the buffer), not for user-initiated wheel or keyboard scrolling (xterm.js #3864, #3201). Any attempt to use onScroll to detect user scroll-up intent will falsely trigger during fast output, because viewportY briefly lags behind baseY as new lines arrive. Additionally, during fast output viewportY transiently equals baseY during xterm's internal write processing, which means any code checking isAtBottom() (including onScroll handlers and resize reconciliation) can falsely re-pin the viewport.

The fix tracks a pinnedToBottom flag with careful rules about what can change it:

  • Wheel and keyboard events (capture phase) are the only things that can unpin, since they represent actual user scroll intent. Capture phase is required because xterm.js handles events on its internal viewport element and stops propagation.
  • Immediate unpin on scroll-uppinnedToBottom is set to false synchronously on wheel-up/PageUp, before the requestAnimationFrame callback, so that writes arriving before the next frame don't yank the viewport back down.
  • No onScroll handler — during fast output, viewportY transiently equals baseY during xterm's internal processing, which would falsely re-pin. Re-pinning happens only through wheel/keyboard RAF callbacks, explicit scrollToBottom() calls, or scroll API methods.
  • wasPinned snapshot in write — the write method captures pinnedToBottom before flowControl.write() so that transient state changes during the async write don't affect the scroll decision.
  • Resize handler saves and restores scroll position: if pinned, it scrolls to bottom after fit(); if unpinned, it restores the previous viewportY.
  • Scroll API methods (scrollToTop, scrollPages, scrollLines, scrollToBottom) update pinned state consistently.

The same class of bug was independently identified and fixed in Fleet, another xterm.js-based terminal.

Tested

  • Fast output while pinned to bottom — viewport stays at bottom
  • Fast output while scrolled up — viewport holds position, does not jump to top or bottom
  • Resize during fast output — viewport does not jump to line 0
  • Re-pin on scroll to bottom — scrolling back to bottom during output re-pins and follows new output
  • Scroll API (Ctrl+Home, Ctrl+End, PageUp/PageDown) — works normally
  • Idle terminal scrolling — no regressions

Fixes #10648

xterm.js disables its built-in auto-scroll (scrollToBottom replaced with
a no-op), but the resize handler calls fitAddon.fit() without preserving
scroll position. During fast output (e.g. Claude Code streaming),
fit() can reset the viewport to line 0.

Track a pinnedToBottom flag. onScroll (content-driven only, per xterm.js
Eugeny#3864/Eugeny#3201) can only re-pin when at bottom, never unpin — during fast
output viewportY briefly lags baseY. Only wheel and keyboard events
unpin. The resize handler now saves/restores scroll position when
unpinned, and scrolls to bottom when pinned. Writes also scroll to
bottom when pinned, replacing the disabled auto-scroll.

Fixes Eugeny#10648

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@astex astex marked this pull request as draft March 20, 2026 00:38
astex and others added 2 commits March 19, 2026 20:41
The viewport-to-0 jump also occurs during xterm.write() with fast
output, not only during fit(). Since scrollToBottom is patched to a
no-op, xterm can't self-correct. Save and restore viewportY after
each write when the user has scrolled away from bottom.

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

wdjwxh commented Mar 20, 2026

finally , cant wait to merge

- Switch wheel/keyboard listeners to capture phase — xterm.js handles
  events on its internal viewport element and stops propagation, so
  bubbling listeners on the host element never fired
- Immediately unpin on scroll-up (wheel/keyboard) before RAF, so writes
  arriving before the next animation frame don't yank viewport back down
- Remove onScroll handler — during fast output, viewportY transiently
  equals baseY during xterm's internal processing, causing false re-pins
- Remove resize handler reconciliation for the same reason
- Snapshot pinnedToBottom before flowControl.write() so mid-write
  transient state changes don't affect the scroll decision

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@astex astex marked this pull request as ready for review March 20, 2026 02:46
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@astex
Copy link
Copy Markdown
Author

astex commented Mar 20, 2026

finally , cant wait to merge

IKR?

Seems to work empirically. I've reasoned through each of the hacks that Claude threw together here, understand them, and think they are reasonable. And after staring at the code a bit, I don't see any obvious cleaner approach (besides maybe give up on the workarounds entirely and just lean on xterm's scroll behavior).

But, it really feels like there should be a cleaner approach to this.

@astex
Copy link
Copy Markdown
Author

astex commented Mar 20, 2026

@Eugeny I'm not sure what I need to do to request review.

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

wdjwxh commented Mar 23, 2026

I just hang in the startup screen..
image

@zhepoch
Copy link
Copy Markdown

zhepoch commented Mar 25, 2026

Hi @Eugeny, did you have some time to review this PR? I think it can resolve the Claude code scroll-to-top problem. I absolutely love Tabby, but using Claude codes in it is so inconvenient.

@jose-cabral
Copy link
Copy Markdown

Can't wait to have this merged 🙏

@astex
Copy link
Copy Markdown
Author

astex commented Apr 1, 2026

Alright. Well the fix has taken long enough to get reviewed that I've rolled my own terminal emulator with a specific focus on agentic work with claude. https://github.com/astex/mandelbot

I'll keep tabs on this in case anything changes.

GitHub
Contribute to astex/mandelbot development by creating an account on GitHub.

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.

Claude Code scrolling issues

4 participants