fix(terminal): prevent scroll-to-top race during fast output#11102
fix(terminal): prevent scroll-to-top race during fast output#11102astex wants to merge 6 commits intoEugeny:masterfrom
Conversation
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>
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>
|
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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. |
|
@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>
|
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. |
|
Can't wait to have this merged 🙏 |
|
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.
|

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-inscrollToBottom(replacing it with a no-op to manage scroll manually), but the resize handler callsfit()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.onScrollonly 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 useonScrollto detect user scroll-up intent will falsely trigger during fast output, becauseviewportYbriefly lags behindbaseYas new lines arrive. Additionally, during fast outputviewportYtransiently equalsbaseYduring xterm's internal write processing, which means any code checkingisAtBottom()(includingonScrollhandlers and resize reconciliation) can falsely re-pin the viewport.The fix tracks a
pinnedToBottomflag with careful rules about what can change it:pinnedToBottomis set tofalsesynchronously on wheel-up/PageUp, before therequestAnimationFramecallback, so that writes arriving before the next frame don't yank the viewport back down.onScrollhandler — during fast output,viewportYtransiently equalsbaseYduring xterm's internal processing, which would falsely re-pin. Re-pinning happens only through wheel/keyboard RAF callbacks, explicitscrollToBottom()calls, or scroll API methods.wasPinnedsnapshot in write — the write method capturespinnedToBottombeforeflowControl.write()so that transient state changes during the async write don't affect the scroll decision.fit(); if unpinned, it restores the previousviewportY.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
Fixes #10648