fix(terminal): scrollback round 2 for #205 (re-pull downgrade guard, PageUp fallback, CLI version probe retry) - #227
Conversation
…page the CLI when local scrollback is hollow (#205) The 1.12.0 retest on #205 reported it still broken in two shapes: a wheel that did nothing at all on Firefox/macOS (while Fn+Up paged back through intact text), and iPhone history that went back a little, repeated blocks and got worse the further up it went. Both come from a Claude pane's LOCAL buffer being hollow: tmux keeps no history for a repaint-mode pane (history_size 0), so xterm holds only replayed repaint frames. 1. The scroll-to-top full=1 re-pull now refuses a DOWNGRADE. It resets the terminal and rewrites it from the capture, which is a win when tmux holds more than the browser, but for a repaint-mode pane that capture is roughly ONE frame and the rewrite deleted history mid-scroll. Measured A/B on a live pane, same gesture: guard off collapses 341 rows to 42, guard on preserves all 341. _replayWouldShrinkBuffer() estimates the capture's rendered rows (escapes stripped, capture-pane -J re-wrapping accounted for) and skips the rewrite when it is more than one screen short; a refused session's cooldown goes from 4s to 60s so a hollow pane stops re-fetching megabytes. 2. A false forwarding gate on a Claude session no longer means a dead gesture. Under a triple guard (claude mode, gate false, baseY 0), wheel and touch travel becomes coalesced PageUp/PageDown through the same 40ms queue as the SGR reports, at half a screen of travel per page key. Shift is excluded: it keeps meaning "local scrollback". 3. getClaudeCliVersion() no longer caches FAILURE. It stored null on any exception and guarded on !== undefined, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted, which fits a report of breakage on phone, tablet and laptop at once. Success is still cached for the process lifetime; failures retry with a 1/2/4 up to 15min backoff, and the policy is a pure function so the semantics are testable without spawning claude. 4. The terminalWheelLocalScrollback footgun is handled by pairing rather than scoping: the setting keeps meaning exactly what it says, and fix 2 catches the case where "local" is empty. The App Settings tooltip now says to leave it off for Claude/Codex sessions. 5. _logScrollRouting() prints one line per session per distinct decision: forward-sgr / page-keys / local-scrollback / repull-refused-downgrade, with mode, cliVersion, the opt-out state, mouse tracking and local scrollback depth. #205 ran two rounds of remote guesswork over questions that line answers directly. Verified end to end against a real isolated instance (own data dir and tmux socket) with real wheel events: forwarding still sends SGR reports, the opt-out now sends real PageUp/PageDown where the wheel was dead, a tab-switch collapse (401 rows to 44) is still fully recovered by the re-pull (back to 401), and a seeded 341-row Claude buffer survives the same gesture that destroys it with the guard disabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reworded tooltip promised the PageUp/PageDown fallback for Claude and Codex alike, but _localScrollbackIsHollow() gates it to claude mode only (codex page-key handling is unverified, as the routing tests note). A codex user reading the old text would flip the setting expecting a rescue and get a dead wheel instead. Say plainly that Codex has no fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DodgyBadger
commented
Aug 8, 2026
This seems to be broken still for Codex. Mouse wheel scrolling works fine in a shell or web tab, but not in Codex. Dragging the scrollbar works fine so easy enough workaround for now. Codex latest |
Ark0N
commented
Aug 8, 2026
Thanks for the report! The scrollbar detail is actually the useful part: it means your local history is fine and the wheel is being routed to Codex itself, which apparently ignores it in current builds. Could you open the browser console (F12) and paste the |
DodgyBadger
commented
Aug 9, 2026
[scroll] a638c35e-c1d0-4435-acb4-3562ad64f948 → forward-sgr (mode=codex, cliVersion=unknown, localScrollbackOptOut=false, mouseTracking=none, localScrollbackRows=967) |
Ark0N
commented
Aug 9, 2026
That line nails it. I re-measured against codex-cli 0.147.0 in a bare tmux. Codex never turns mouse tracking on, runs an inline viewport, and pushes its transcript straight into the terminal's own scrollback. SGR wheel reports written to its pane change nothing at all, so every tick was being eaten. Forwarding is right for Claude and simply wrong for Codex. Fixed: only Claude 2.1.187+ forwards the wheel now, Codex scrolls local scrollback like shell sessions do. Verified in a browser against a live Codex session, wheel-up walks all the way back to the Codex banner. Ships in the next release, and thanks for pasting the console line, it turned this from guesswork into a measurement. |
…orts DodgyBadger reported a completely dead wheel in codex tabs (#227 comment) while the scrollbar drag worked, and the [scroll] line confirmed the branch: forward-sgr with 967 rows of healthy local scrollback unused. Measured against codex-cli 0.147.0 in a bare tmux: codex never enables mouse tracking (mouse_any_flag=0), runs an inline viewport (alternate_on=0) and pushes its transcript into the terminal's own scrollback (history_size grows), and SGR wheel reports written to its pane change nothing at all. Hand-encoded SGR taps are no-ops too, so they stay (harmless), which means click-to-position is merely unavailable there rather than damaging. _shouldForwardWheelToApp now returns true for claude >= 2.1.187 and nothing else; codex falls to the local-scrollback path like shell/gemini/opencode, which is the same history the scrollbar drag was already reaching. The claude-only PageUp fallback is untouched. Verified in Chromium against a live codex session on an isolated instance: routing logs local-scrollback, the viewport moves 39 -> 4 and zero bytes go to the PTY. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 on #205. The retest on 1.12.0 came back broken in two shapes, and both trace to the same thing: a Claude pane's local scrollback is hollow. tmux keeps no history for a repaint-mode pane (
history_size0 measured), so xterm's buffer holds only replayed repaint frames.full=1re-pull reset the terminal and rewrote it from a one-frame captureThat Fn+Up works is the load-bearing clue: Claude's own history and the PTY input path were both fine the whole time.
What changed
1. The re-pull refuses a downgrade.
_maybeRefetchFullHistoryresets and rewrites from the capture, which is a straight win when tmux holds more than the browser (bursty-repaint and tab-switch loss, what it was built for) and destructive when it does not._replayWouldShrinkBuffer()estimates the capture's rendered rows (escape sequences stripped,capture-pane -Jre-wrapping accounted for) and skips the rewrite when that is more than one screen short of what xterm already holds. The one-screen tolerance is deliberate: both sides are estimates, so only a clear downgrade is refused. A refused session's cooldown goes from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up.2. A false gate no longer means a dead gesture. Under a triple guard (claude mode, gate false,
baseY === 0), wheel and touch travel becomes coalesced\x1b[5~/\x1b[6~through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). Shift is excluded on purpose: it keeps meaning "local scrollback". Sessions with real local history are untouched.3.
getClaudeCliVersion()no longer caches FAILURE. It storednullon any exception and guarded on!== undefined, so a single timed-out or PATH-starved probe at the first Claude session start leftcliVersionundefined for every Claude session until the server restarted, which silently disables wheel-forwarding. That fits a report of breakage on phone, tablet and laptop at once: a server-side cause, not a browser one. Success is still cached for the process lifetime; failures retry with a 1/2/4 up to 15min backoff. The cache policy is now a pure function, so the retry semantics are unit-testable without spawningclaude.4. The
terminalWheelLocalScrollbackfootgun is paired, not scoped. For a repaint-mode CLI that setting pins the wheel to an empty buffer, so it can convert a working wheel into a dead one. Scoping it away from those modes would silently override an explicit user choice, so fix 2 catches it instead and the setting keeps meaning what it says. The App Settings tooltip now says to leave it off for Claude/Codex.5. The routing decision is logged. One console line per session per distinct decision:
This thread is two rounds deep on questions that line answers directly.
Verification
Unit:
test/terminal-scroll-routing.test.ts(14) andtest/claude-cli-version-cache.test.ts(6), plus the full sweep,npm run test:ci= 4003 passed / 12 skipped.End to end against a real isolated instance (own data dir + tmux socket, throwaway sessions deleted afterwards) driven with real
page.mouse.wheel()events:\x1b[<64;…M)\x1b[5~/\x1b[6~where the wheel previously did nothingStill open
Whether the reporter's Claude Code is genuinely older than 2.1.187, and whether the iPhone was running stale JS. The new diagnostic makes both self-reporting: the retest ask becomes "open the console and paste the
[scroll]line".No changeset here on purpose, so the maintainer's COM flow writes the release notes.