12 KiB
Scrollback issues: analysis and test evidence
Covers GitHub issue #205 ("Scrollback in terminal not working", jonocodes, shell mode, Android + macOS desktop) and the follow-up comment on it from mtiller (Firefox on macOS, "scrolling backward to see agent output"). Related closed issue: #154 (fixed in 1.3.3).
Status: analysis only, nothing implemented. Measured against the live 1.11.2 instance on
2026-08-06 with throwaway zz-* shell sessions (all deleted afterwards; the user's w*
sessions were never touched).
TL;DR
Five distinct problems, not one. #205 is fully explained by finding 1; findings 2 and 3 are independent and hit every mode including Claude, and are the likely substance of the "similar issue" follow-up.
| # | Problem | Modes affected | Severity | Confirmed |
|---|---|---|---|---|
| 1 | xterm parked in the alternate buffer for the whole session, so there is no scrollback at all and the wheel is translated into Up/Down arrow keys | shell, opencode, antigravity |
High | Reproduced end to end |
| 2 | Bursty output silently destroys a screenful of the browser's scrollback and adds ~1 row | all | High | Measured |
| 3 | Tab switch collapses scrollback to roughly one screen (full=1 fires once per page load) |
all | Medium | Measured |
| 4 | deltaMode is never read, so Firefox scrolls ~4x slower per notch |
all, Firefox | Low | Static, needs reporter data |
| 5 | Remote SSH Claude cases get no claude --version probe, so wheel forwarding silently stays off (residual #154) |
claude + remote |
Medium | Static |
Finding 1: shell / opencode / antigravity are stuck in xterm's alternate buffer
Root cause
The local tmux client (the tmux attach that node-pty spawns) emits smcup as its very
first bytes on attach. Captured from a real PTY:
b'\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2J\x1b[?12l\x1b[?25h\x1b[?1000l...'
^^^^^^^^^^ enter alternate screen ^^^^^ application cursor keys ON
Session._handleTerminalOutput() strips \x1b[?1049h from the live stream, but only when
isAltScreenStripMode(mode) is true, and that is claude | codex | gemini only
(src/session.ts:179). For shell, opencode and antigravity the sequence reaches the
browser verbatim and xterm switches to the alternate buffer, where:
-
buffer.active.type === 'alternate'andbaseYis pinned at 0, so there is no scrollback to reach.terminal.scrollLines()is a no-op, which is why touch scrolling on Android "does nothing". -
xterm's own wheel listener takes over. From the vendored bundle (
src/web/public/vendor/xterm.min.js):if (!this.buffer.hasScrollback) { if (ev.deltaY === 0) return false; if (coreMouseService.consumeWheelEvent(...) === 0) return this.cancel(ev, true); const seq = ESC + (decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); coreService.triggerDataEvent(seq, true); return this.cancel(ev, true); }tmux also set
\x1b[?1h, so the emitted sequence is\x1bOA, i.e. Up arrow, straight into the shell's readline. That is exactly the reported "the mouse wheel scrolls back through previous commands, like pressing up". -
cancel(ev, true)callspreventDefault()andstopPropagation(), and xterm's listener sits onterminal.element(a child of Codeman's container). So Codeman's own container wheel handler,_shouldForwardWheelToAppand_wheelScrollLinesincluded, is never reached for these modes. That whole path is dead code for shell.
Reproduction (live instance, real browser)
Create a shell session with the page already open, print 150 lines, then dispatch 8 wheel-up
events over .xterm-screen:
t+1500 after shell start {"type":"alternate","length":35,"baseY":0}
t+3000 after shell start {"type":"alternate","length":35,"baseY":0}
after 150 live lines {"type":"alternate","length":35,"baseY":0}
WHEEL on live shell: {"ptyBytes":["OA","OA","OA","OA",
"OA","OA","OA","OA"],
"before":0,"after":0,"type":"alternate"}
Both reported symptoms, one root cause.
Why it looks intermittent
The alternate-screen sequence only ever reaches the browser through the live stream at attach. Neither replay path carries it:
?full=1returnscapture-paneoutput (source: mux-full-history), verified 0 hits for\x1b[?1049h.?tail=returns the visible pane frame (source: mux-visible), also 0 hits; the shell byte buffer was empty in every probe._resetTerminalForReplay()callsterminal.reset(), which returns xterm to the normal buffer.
So: watching a shell from creation leaves you in the alternate buffer until you reload or
switch tabs, at which point it silently starts working again. Then the next PTY attach (a
restart, or the auto-reattach in selectSession()) puts you back.
Is stripping safe for shell? Probably yes when tmux-backed, and the current code comment is wrong about why
src/session.ts:1404 says "shell must keep the alt screen for vim/less/htop". For a
tmux-backed shell that reasoning does not hold: tmux is a full terminal emulator and never
forwards a pane's alternate-screen toggles to its client, it repaints instead. Measured per
phase on a real attach:
| phase | bytes | ?1049h |
?1049l |
?47/1047 |
|---|---|---|---|---|
| attach | 772 | 1 | 0 | 0 |
seq 1 60 echo |
1402 | 0 | 0 | 0 |
less open / end / quit |
284 / 230 / 321 | 0 | 0 | 0 |
vim open / quit |
2200 / 646 | 0 | 0 | 0 |
vim and less inside tmux emit zero alternate-screen sequences to the client.
The caveat that does matter: startShell() falls back to a direct PTY with no tmux when
mux creation fails (src/session.ts:1961, this._useMux = false). In that path the inner
app's own ?1049h does reach xterm, and a blanket strip would break vim/less/htop for real.
Any fix has to be conditional on _useMux, which is known server-side.
Second caveat: stripping alone buys less than it looks like, because of finding 2. It fixes
the wheel (no more phantom Up arrows) and it makes the full=1 replay reachable, but live
output still will not accumulate.
Finding 2: bursty output silently overwrites a screenful of browser scrollback
Independent of the alternate buffer, and it hits Claude sessions too.
tmux decides per flush whether to emit real linefeeds (which push rows into the outer terminal's scrollback) or to repaint the pane rectangle with cursor addressing (which overwrites the visible rows in place). When output outpaces its flush interval it coalesces into a repaint, and one screenful of the browser's history is destroyed.
Measured on one session, same page, rows = 36:
| step | baseY |
rows containing SEED | BURST | SLOW |
|---|---|---|---|---|
after ?full=1 replay (120 seeded lines) |
86 | 120 | 0 | 0 |
| after 60 lines emitted as fast as possible | 87 (+1) | 86 (-34) | 35 | 0 |
after 60 lines at ~16/s (sleep 0.06) |
148 (+61) | 86 | 35 | 60 |
The burst added one row of scrollback and ate 34 rows of existing history. The slow run behaved correctly. So "I printed a bunch of lines and now I cannot scroll back" reproduces without the alternate buffer being involved at all, and it is rate dependent, which is exactly the kind of thing that reads as random flakiness.
Consequence: the browser's scrollback is effectively frozen at whatever the last ?full=1
replay produced, minus a screen per burst. tmux's own history is fine throughout
(history_size kept growing, history-limit 2000), so the data is never actually lost
server-side, it just never reaches the browser again until a reload.
Finding 3: switching tabs collapses a session's scrollback
_initialFullBufferLoad is true for the first buffer load after a page load only
(app.js:4374). Everything after that uses ?tail=, which returns byte history plus the
visible pane frame. Worse, the snapshot restore path deliberately throws away the restored
xterm snapshot (which does carry scrollback) and replaces it with that frame
(app.js:4316-4328 plus needsRewrite).
Measured, switching away from session A and back:
A: initial full=1 load {"len":152,"baseY":116,"AAA":150}
A: after switch away and back {"len": 87,"baseY": 51,"AAA": 59}
150 lines of history down to 59. Note also that the page's single full=1 is consumed by
whichever session auto-selects at load, so every other tab starts life with one frame of
history.
Finding 4: deltaMode is never read (Firefox)
grep -rn "deltaMode" src/web/public packages returns nothing. _wheelScrollLines()
(terminal-ui.js:2818) treats deltaY as pixels unconditionally:
return Math.round(delta / 25) || (delta > 0 ? 1 : -1);
Chrome/WebKit report deltaMode: 0 with deltaY around 100 to 120 px per notch, so about 4
to 5 lines. Firefox reports deltaMode: 1 (DOM_DELTA_LINE) with deltaY around 3, so
Math.round(3/25) === 0 and the || ±1 fallback yields 1 line per notch, roughly 4x
slower. In Claude mode the same value caps the forwarded SGR report at 1 tick per event
instead of 4, so the transcript crawls too.
This is sluggishness, not breakage, so it is a plausible but unproven contributor to the
mtiller report. No Firefox build is installed under ~/.cache/ms-playwright (chromium and
webkit only), so this was not measured. Worth asking the reporter for deltaMode / deltaY
from a live wheel event before acting on it.
Finding 5: remote SSH Claude cases still have no version probe
src/session.ts:1490 deliberately skips the deterministic claude --version probe for
remote sessions and defers to the startup-banner scrape, which the same comment block
describes as unreliable ("newer Claude Code builds don't print the banner and resumed sessions
never show it"). That is precisely the condition #154 was filed for: cliVersion empty means
_shouldForwardWheelToApp() returns false, wheel forwarding is off, and the user is left with
local scrollback that (per finding 2) does not accumulate.
Local and Docker Claude sessions are fine; verified all 7 live sessions report
cliVersion=2.1.223, so the 1.3.3 fix is still working there.
Candidate directions (not decided)
Roughly in order of value per unit of risk.
-
Extend the alternate-screen strip to tmux-backed
shell/opencode/antigravity. Gate on_useMuxso the direct-PTY fallback keeps vim/less/htop working. Kills the phantom Up arrows and makes replayed history reachable.isAltScreenStripMode()currently takes onlymode, so it would need the mux flag threaded in, andtest/claude-scrollback-strip.test.ts:16-17plustest/antigravity-mode.test.ts:116pin the current answers and would need updating. -
Re-pull
?full=1when the user scrolls to the top of the buffer. Directly addresses findings 2 and 3 with machinery that already exists and is already proven to return complete history (200/200 lines in the probe). Needs a guard against refetch storms. -
Stop discarding the xterm snapshot on tab switch, or request
full=1on the first load per session rather than per page. Cheaper partial fix for finding 3 alone. -
Read
ev.deltaModein_wheelScrollLines()and normalise line/page deltas to lines. Small, self-contained, worth doing regardless of whether it is mtiller's actual bug. -
Probe the CLI version over SSH for remote Claude cases, mirroring the deferred in-container probe that Docker cases already use.
Option 1 alone does not fix #205's "print a bunch of lines then scroll" complaint; that needs 2 as well.
Reproduction assets
Scripts used, in the session scratchpad
(/tmp/claude-1000/-home-arkon-default-claudeman/597ffc9f-.../scratchpad/):
ptycap.py/ptycap2.py: PTY-level capture of the tmux client stream, per phase counts of alternate-screen and mouse-tracking sequences.sim.mjs: replays a captured stream through@xterm/headlesswith and without the strip.browser-test*.mjs: Playwright against the live instance, reportsbuffer.active.type,baseY, row content and the exact bytes xterm sends to the PTY on a wheel event.
@xterm/headless was installed with npm i --no-save, so package.json and the lockfile are
untouched.