From dfa43928af5b6ef4722027ea5689dd6d72cd1c38 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 04:33:15 +0200 Subject: [PATCH] docs: record the scrollback analysis and its measurements for #205 --- docs/scrollback-issues-analysis.md | 255 +++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/scrollback-issues-analysis.md diff --git a/docs/scrollback-issues-analysis.md b/docs/scrollback-issues-analysis.md new file mode 100644 index 00000000..c8d7cd27 --- /dev/null +++ b/docs/scrollback-issues-analysis.md @@ -0,0 +1,255 @@ +# 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: + +1. `buffer.active.type === 'alternate'` and `baseY` is 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". +2. xterm's own wheel listener takes over. From the vendored bundle + (`src/web/public/vendor/xterm.min.js`): + + ```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". + +3. `cancel(ev, true)` calls `preventDefault()` **and `stopPropagation()`**, and xterm's + listener sits on `terminal.element` (a child of Codeman's container). So Codeman's own + container wheel handler, `_shouldForwardWheelToApp` and `_wheelScrollLines` included, 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=1` returns `capture-pane` output (`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()` calls `terminal.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: + +```js +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. + +1. **Extend the alternate-screen strip to tmux-backed `shell` / `opencode` / `antigravity`.** + Gate on `_useMux` so the direct-PTY fallback keeps vim/less/htop working. Kills the phantom + Up arrows and makes replayed history reachable. `isAltScreenStripMode()` currently takes + only `mode`, so it would need the mux flag threaded in, and + `test/claude-scrollback-strip.test.ts:16-17` plus `test/antigravity-mode.test.ts:116` pin + the current answers and would need updating. + +2. **Re-pull `?full=1` when 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. + +3. **Stop discarding the xterm snapshot on tab switch**, or request `full=1` on the first load + per session rather than per page. Cheaper partial fix for finding 3 alone. + +4. **Read `ev.deltaMode`** in `_wheelScrollLines()` and normalise line/page deltas to lines. + Small, self-contained, worth doing regardless of whether it is mtiller's actual bug. + +5. **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/headless` with and without the strip. +- `browser-test*.mjs`: Playwright against the live instance, reports `buffer.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.