fix: terminal scrollback overhaul for shell and CLI sessions (#205) - #223
Conversation
… repaints Four fixes for the scrollback reports in #205 (plus its follow-up comment). 1. tmux-backed shell/opencode/antigravity sessions were parked in xterm's ALTERNATE buffer for their whole life. The tmux CLIENT emits smcup (\x1b[?1049h) as its first bytes on attach, and the existing strip is gated to claude/codex/gemini, so it reached the browser verbatim. In the alternate buffer baseY is pinned at 0 (no scrollback, so touch scrolling is a no-op) and xterm's own wheel handler translates the wheel into \x1bOA cursor keys, which readline receives as shell history navigation. Both reported symptoms, one sequence. isMuxAltScreenOnlyStripMode() now strips that toggle for those modes, but ONLY under tmux (the direct-PTY fallback still needs a program's own alt screen) and ONLY the alt-screen toggle: 3J from a user's `clear` and the mouse DECSETs a pane's htop/vim rely on are left alone. Safe because tmux never forwards a pane's alt-screen toggles to its client, it repaints; captured from a real attach, vim/less/htop emit zero. 2. "Load more history" on scroll-to-top. xterm's buffer is only ever a window onto tmux's history, and tmux repaints the pane rectangle instead of emitting linefeeds whenever output outpaces its flush, OVERWRITING already-rendered scrollback. Measured: a 60-line burst added 1 row and destroyed 34, while the same 60 lines emitted slowly added all 60. Scrolling up at the top now re-pulls the full tmux scrollback and holds the user's place. Verified end to end: 42 rendered rows -> 213, recovering all 150+60 printed lines. 3. The full-scrollback replay was gated on a single "first load after page load" flag, which whichever session auto-selected consumed, so every other tab started with one visible frame. Now tracked per session. 4. _wheelScrollLines ignored ev.deltaMode, so Firefox (DOM_DELTA_LINE, deltaY 3 per notch) scrolled one line where Chrome scrolls four or five, and capped the forwarded SGR report at one tick. Line and page deltas are now converted, and a pure horizontal swipe no longer falls through to a phantom -1. Analysis and measurements: docs/scrollback-issues-analysis.md
…e wheel Reported against the beta: scrolling up in a Claude session drags the prompt box and status line up the screen along with everything else, and only once the local buffer hits its top does the CLI's own history start moving. _shouldForwardWheelToApp() gated forwarding on the viewport being at the buffer bottom, so that leaving the bottom handed the wheel back to local scrollback and both histories stayed reachable. Two things make that the wrong default: - A repaint-mode CLI keeps no terminal scrollback of its own (tmux reports history_size=0 for a Claude pane), so xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling those locally moves the CLI's pinned furniture and shows stale frames underneath. - scrollToLastNonEmptyLine() parks the viewport `rows - 2` above the last non-empty row, so any session with trailing blank rows was left off-bottom and every later wheel event went local without the user ever scrolling. Forward unconditionally for the verified modes instead, and snap the viewport back to the bottom before encoding the report (SGR coordinates address the live screen, and forwarding while the user stares at stale scrollback looks dead). Shift+wheel and the "Wheel scrolls local history" opt-out still reach local scrollback. Verified against a real Claude 2.1.223 session: wheel-up scrolls its transcript back 48 lines (rows showing 85-92 -> 37-44) while the input box, separator and status line stay fixed at the bottom.
Touch drags and flick momentum on forwarding-capable sessions (codex, claude >= 2.1.187) now go to the CLI as coalesced SGR wheel reports via the shared _forwardScrollToApp helper, exactly like the desktop wheel: snap the viewport home first, then encode. Before this, every phone or tablet swipe scrolled the local buffer of stale repaint frames and dragged the CLI's pinned input box off the screen (the mobile half of issue #205). The _shouldForwardWheelToApp gate is shared, so the local-scrollback opt-out setting and the CLI version gate apply to touch exactly as they do to the wheel; shell and other local modes keep the existing local touch scrolling and the scroll-to-top history re-pull. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remote Claude sessions were the one backend left relying on the startup-banner scrape for cliVersion (the unreliable path #154 was filed for: newer Claude Code builds print no banner and resumed sessions never do), so wheel/touch forwarding silently stayed off for them. Mirror the docker approach: a deferred best-effort probe at session start, running claude --version on the remote host through the same buildSshConnectionArgs + login-shell wrapper as the real launch, parsing the first semver in stdout (an interactive login shell may echo rc-file noise around it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update the full-scrollback replay invariant (per-session full=1 Set plus the scroll-to-top re-pull), add a new invariants section covering the two strip flavors and the wheel/touch forwarding rules, sync the CLAUDE.md Key Patterns bullets, and commit the fix plan with a status header describing what shipped and where it deliberately diverged (narrow strip plus re-pull instead of tmux mouse on; viewport-at-bottom gate dropped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eaf after reset Measured on the live instance: xterm's vscode-style viewport scroller consumes wheel events itself whenever it believes a scrollbar exists (preventDefault + stopPropagation, attachCustomWheelEventHandler is not consulted), so Codeman's bubble-phase handler never fired once local scrollback existed. Forwarding, the deltaMode conversion and the top-of-buffer history re-pull were all silently dead exactly on the sessions that had history, which is the 'input box scrolls up then it fights and hangs' report. Worse, that scroller's dimensions go stale after terminal.reset(): following a tab switch or full-history replay it neither scrolls nor propagates, which is the 'works at first, breaks after reload and tab switch' report. The container wheel listener now runs in capture phase, stops propagation, and scrolls locally through buffer-level scrollLines(), which keeps working after resets. Mouse-tracking sessions and the alternate buffer (direct-PTY vim/less) are passed through untouched so xterm's encoder and alt-scroll arrow conversion keep owning those. Verified end to end against the beta: 9/9 matrix checks including the exact reported flows (claude wheel with scrollback present stays pinned and forwards, shell reaches full history by wheel alone, reload then tab switch then back still works, SSE reconnect survives, Shift+wheel stays local), plus the two prior E2E suites re-passing 10/10 and 6/6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ark0N
commented
Aug 7, 2026
Follow-up commit c067167 after beta testing surfaced two more failure modes (thanks for the exact repro: reload, switch tab, come back; and the input box scrolling up then fighting): Measured against the live instance, xterm's vscode-style viewport scroller consumes wheel events itself whenever it believes a scrollbar exists, does not consult attachCustomWheelEventHandler, and goes deaf after terminal.reset(): following a tab switch or full-history replay it neither scrolls nor propagates. So the bubble-phase container handler never ran once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull were all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all. The wheel listener now runs in capture phase, stops propagation, and scrolls locally through buffer-level scrollLines(), which keeps working after resets. Mouse-tracking sessions and the alternate buffer (direct-PTY vim/less) are passed through untouched. Re-verified end to end on the beta: 9/9 matrix checks covering the reported flows (claude wheel with scrollback present stays pinned and forwards; shell reaches full history by wheel alone; reload then tab switch then back still works; SSE reconnect survives; Shift+wheel stays local), plus the two earlier E2E suites re-passing 10/10 and 6/6. |
The capture-phase handler owns local scrolling (xterm's smooth scroller is bypassed for the stale-dimensions reasons documented there), which made every notch an instant multi-line jump. Wheel deltas now accumulate into a pending line count drained ~35% per animation frame with a one-line floor, so scrolling glides and extra notches mid-glide read as acceleration. Pending momentum is dropped on session switch so it never scrolls the tab the user just switched to. Verified on the beta: a 20-line notch eases over 9 frames to an exact landing, and the 9-check scroll matrix still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two smoothness refinements on the local wheel path: the drain factor drops from 35% to 22% per frame, so the first frame of a notch takes a smaller step and the glide lasts longer; and local scrolling accumulates FRACTIONAL lines (_wheelScrollLinesFloat) instead of rounding every event, so a slow macOS trackpad drag no longer snaps a whole line per tiny delta (the old ±1 fallback made slow drags scroll faster than the finger). Sub-line residuals stay pending until further input crosses a whole line. Forwarded SGR ticks keep the rounded integer path. Probe: a 20-line notch now glides through 14 positions to an exact landing; the 9-check scroll matrix still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes#205.
What was broken
Five distinct problems hid behind one issue (full evidence in
docs/scrollback-issues-analysis.md, measured against the live 1.11.2 instance):smcupas its first bytes at attach, parking xterm in the alternate buffer for the whole session: touch scrolling no-ops (Android symptom) and xterm converts the wheel into Up/Down arrows, so the shell cycles command history (desktop symptom). Both reported symptoms, one sequence.full=1replay per page load was consumed by whichever tab auto-selected.deltaModeline units read as pixels).What this changes
isMuxAltScreenOnlyStripMode): strips ONLY tmux's own client smcup, keeps3Jand mouse DECSETs. tmux never forwards a pane program's alt-screen toggles (measured: vim/less emit zero to the client), and theuseMuxgate keeps direct-PTY fallback sessions untouched so vim/less/htop stay correct there. Applied on the live path and the replay path.full=1(Set instead of one-shot flag) plus an on-demand history re-pull: scrolling up at the top of the buffer re-fetches the complete tmux scrollback (4s cooldown, in-flight and tab-switch guards, viewport position held), recovering everything repaints or tab switches ate.terminalWheelLocalScrollbacksetting still pin local scrollback.deltaModenormalization in_wheelScrollLines()(line/page/pixel units), keeping the Can't scroll back through terminal history on macOS trackpad (Claude sessions, 1.3.0) #154 Shift-axis trap.probeRemoteCliVersion), deferred at session start, using the samebuildSshConnectionArgs+ login-shell wrapper as the real launch.The original diagnosis and the divergence from its recommended approach are recorded in
docs/scrollback-fix-plan.md; invariants updated indocs/architecture-invariants.mdand CLAUDE.md.Testing
npm run test:ci: 198 files, 3983 tests, all passing (includes new unit tests for the strip flavors, deltaMode conversion, forwarded-scroll snap, and the ssh probe command builder).page.mouse.wheel, CDP touch), asserting on xterm buffer state and real PTY bytes: