Skip to content

fix(terminal): scrollback round 2 for #205 (re-pull downgrade guard, PageUp fallback, CLI version probe retry) - #227

Merged
Ark0N merged 2 commits into
masterfrom
fix/scrollback-205-round2
Aug 7, 2026
Merged

fix(terminal): scrollback round 2 for #205 (re-pull downgrade guard, PageUp fallback, CLI version probe retry)#227
Ark0N merged 2 commits into
masterfrom
fix/scrollback-205-round2

Conversation

@Ark0N

@Ark0NArk0N commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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_size 0 measured), so xterm's buffer holds only replayed repaint frames.

ReportCause
Firefox/macOS: wheel does nothing, while Fn+Up (PageUp) pages back through intact textthe forwarding gate evaluated false, so the wheel scrolled a buffer with nothing in it
iPhone: history goes back a little, repeats blocks, gets worse the further up you gothe scroll-to-top full=1 re-pull reset the terminal and rewrote it from a one-frame capture

That 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._maybeRefetchFullHistory resets 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 -J re-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 stored null on any exception and guarded on !== undefined, so a single timed-out or PATH-starved probe at the first Claude session start left cliVersion undefined 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 spawning claude.

4. The terminalWheelLocalScrollback footgun 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:

[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade
(mode=claude, cliVersion=2.1.224, localScrollbackOptOut=false, mouseTracking=none, localScrollbackRows=299)

This thread is two rounds deep on questions that line answers directly.

Verification

Unit: test/terminal-scroll-routing.test.ts (14) and test/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:

  • opt-out OFF, Claude session: still forwards real SGR reports to the PTY (\x1b[<64;…M)
  • opt-out ON, hollow buffer: sends real \x1b[5~ / \x1b[6~ where the wheel previously did nothing
  • shell session: tab switch collapses the buffer 401 rows to 44, scrolling to the top pulls all 401 back, no refusal logged
  • Claude session seeded with 341 rows: same gesture, guard on preserves all 341; guard disabled in the page collapses it to 42 and wipes every seeded row, which is the reported iPhone symptom reproduced and then fixed

Still 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.

…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>
@Ark0N
Ark0N merged commit aed1e59 into masterAug 7, 2026
2 checks passed
@Ark0N
Ark0N deleted the fix/scrollback-205-round2 branch August 7, 2026 23:36
@DodgyBadger

Copy link
Copy Markdown

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
Chrome
Windows 11

@Ark0N

Ark0N commented Aug 8, 2026

Copy link
Copy Markdown
OwnerAuthor

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 [scroll] line that appears when you wheel over the Codex tab? That confirms the routing in one line. Fix coming either way.

@DodgyBadger

Copy link
Copy Markdown

[scroll] a638c35e-c1d0-4435-acb4-3562ad64f948 → forward-sgr (mode=codex, cliVersion=unknown, localScrollbackOptOut=false, mouseTracking=none, localScrollbackRows=967)

@Ark0N

Ark0N commented Aug 9, 2026

Copy link
Copy Markdown
OwnerAuthor

That line nails it. forward-sgr with localScrollbackRows=967 means Codeman was handing your wheel to Codex, which is exactly why the scrollbar still worked: it was scrolling the 967 rows the wheel was being kept away from.

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.

Ark0N pushed a commit that referenced this pull request Aug 9, 2026
…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>
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.

3 participants

@Ark0N@DodgyBadger@claude