Skip to content

fix: terminal scrollback overhaul for shell and CLI sessions (#205) - #223

Merged
Ark0N merged 9 commits into
masterfrom
fix/scrollback-shell-alt-screen
Aug 7, 2026
Merged

fix: terminal scrollback overhaul for shell and CLI sessions (#205)#223
Ark0N merged 9 commits into
masterfrom
fix/scrollback-shell-alt-screen

Conversation

@Ark0N

@Ark0NArk0N commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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):

  1. Shell/opencode/antigravity sessions had no scrollback at all. The tmux CLIENT emits smcup as 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.
  2. Bursty output destroys browser scrollback in every mode. tmux coalesces fast output into pane repaints instead of linefeeds: a 60-line burst added 1 row of browser history and destroyed 34 (measured), while tmux's own history stayed complete.
  3. Tab switches collapsed history to one frame. The single full=1 replay per page load was consumed by whichever tab auto-selected.
  4. Firefox scrolled ~4x too slowly (deltaMode line units read as pixels).
  5. Remote SSH Claude sessions never got a CLI version probe, silently disabling wheel forwarding (residual Can't scroll back through terminal history on macOS trackpad (Claude sessions, 1.3.0) #154).

What this changes

  • Narrow alt-screen strip for tmux-backed shell/opencode/antigravity (isMuxAltScreenOnlyStripMode): strips ONLY tmux's own client smcup, keeps 3J and mouse DECSETs. tmux never forwards a pane program's alt-screen toggles (measured: vim/less emit zero to the client), and the useMux gate keeps direct-PTY fallback sessions untouched so vim/less/htop stay correct there. Applied on the live path and the replay path.
  • Per-session 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.
  • Wheel AND touch forward to the CLI transcript for codex / claude >= 2.1.187 at any scroll position (snap to bottom first, 40ms coalescing, tick cap). The old viewport-at-bottom gate pinned users to a buffer of stale repaint frames and let the CLI's input box scroll off screen; touch previously always scrolled locally, which was the mobile half of the bug. Shift+wheel and the terminalWheelLocalScrollback setting still pin local scrollback.
  • deltaMode normalization 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.
  • Remote CLI version probe over ssh (probeRemoteCliVersion), deferred at session start, using the same buildSshConnectionArgs + 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 in docs/architecture-invariants.md and 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).
  • E2E with Playwright against a live beta instance, driven by trusted events (page.mouse.wheel, CDP touch), asserting on xterm buffer state and real PTY bytes:
    • Shell session (10/10): normal buffer at attach, wheel emits zero arrow keys, wheel-up at top re-pulls all 300 test lines, less enters/leaves cleanly, reload replays into the normal buffer.
    • Claude session (6/6): version probe lands, desktop wheel and touch drags in both directions forward SGR reports with the viewport pinned to the live screen.

… 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

Ark0N commented Aug 7, 2026

Copy link
Copy Markdown
OwnerAuthor

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>
@Ark0N
Ark0N merged commit 6f1ff17 into masterAug 7, 2026
2 checks passed
@Ark0N
Ark0N deleted the fix/scrollback-shell-alt-screen branch August 7, 2026 11:51
Sign up for freeto 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.

Scrollback in terminal not working

2 participants

@Ark0N@claude