mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
fix(terminal): stop the scroll-to-top re-pull from deleting history, 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>
This commit is contained in:
@@ -58,7 +58,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Full-scrollback replay
|
||||
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`.
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`.
|
||||
|
||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||
|
||||
@@ -66,6 +66,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into 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 is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, 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 (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||
|
||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||
|
||||
**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts.
|
||||
|
||||
### Run launch synchronization
|
||||
|
||||
@@ -25,6 +25,144 @@ What shipped vs. what this doc proposed:
|
||||
- **Finding 5 (remote probe)**: implemented (`probeRemoteCliVersion` over ssh, deferred at
|
||||
session start, same login-shell wrapper as the launch).
|
||||
|
||||
## RETEST FAILED (2026-08-07, after v1.12.0 shipped) — analysis round 2
|
||||
|
||||
mtiller retested on 1.12.0 and reports it is NOT fixed (issue #205 comment, 2026-08-07 12:12 UTC;
|
||||
issue reopened same day with clarifying questions: mouse vs trackpad, Shift+scroll behavior,
|
||||
Claude vs shell session on the phone, and an iOS full-tab-kill to rule out stale JS). Two
|
||||
failure signatures, now analyzed against the SHIPPED 1.12.0 code (not the pre-fix code):
|
||||
|
||||
1. **iPhone Safari (Claude session assumed)**: touch scrollback goes back only a limited
|
||||
amount and sometimes REPEATS blocks of text; unreliable.
|
||||
2. **Firefox on macOS (mouse)**: wheel does NOTHING at all, while Fn+Up (= PageUp) pages back
|
||||
through INTACT text.
|
||||
|
||||
### Ruled out by code reading
|
||||
|
||||
- deltaMode mishandling: `_wheelScrollLinesFloat` normalizes line/page/pixel units correctly;
|
||||
a Firefox line-mode notch yields ±3 lines. Not the bug.
|
||||
- Ephemeral transport: `_sendInputEphemeral` (app.js) has a POST fallback when WS is down.
|
||||
- Service worker: sw.js is network-first with cache fallback; it serves stale JS only when the
|
||||
fetch FAILS (flaky mobile connection can do this — relevant to "unreliable" on the phone,
|
||||
and the fixed `CACHE_NAME = 'codeman-v1'` never invalidates that offline copy).
|
||||
|
||||
### The load-bearing observation: PageUp works, the wheel does not
|
||||
|
||||
Fn+Up is a KEYBOARD event: xterm encodes PageUp and Claude pages its own transcript (intact
|
||||
text proves Claude-side history is fine and the PTY input path is fine). The wheel path is the
|
||||
capture-phase handler, and for a Claude session it has exactly two branches:
|
||||
|
||||
- **Forwarding branch** (`_shouldForwardWheelToApp` true): snap-to-bottom + SGR reports. If
|
||||
this branch ran, the user would see the same paging motion Fn+Up produces. They see nothing.
|
||||
- **Local branch** (gate false): `_smoothScrollBy` over xterm's local buffer. For a Claude
|
||||
pane in repaint mode, tmux keeps `history_size≈0`, so `?full=1` returns roughly one frame:
|
||||
the local buffer is structurally HOLLOW, the top-of-buffer re-pull recovers nothing, and the
|
||||
wheel looks completely dead. **This matches every observed detail on Firefox.**
|
||||
|
||||
So the working hypothesis is that mtiller's sessions evaluate the gate FALSE. The gate
|
||||
(`_shouldForwardWheelToApp`) has exactly four false-paths worth checking, in likelihood order:
|
||||
|
||||
1. **`terminalWheelLocalScrollback` opt-out is ON.** Plausible: a user whose scrolling was
|
||||
broken on 1.11.x may well have toggled "Wheel scrolls local history" while trying to fix
|
||||
it. On 1.12.0 that setting now routes the wheel to a hollow local buffer = dead wheel on
|
||||
desktop AND the stale-repaint-frames experience on the phone (see below). Ask, or check
|
||||
what the setting does on their export.
|
||||
2. **`cliVersion` missing — CONFIRMED BUG, independent of whether it is mtiller's**:
|
||||
`getClaudeCliVersion()` (utils/claude-cli-resolver.ts:124-148) caches its result
|
||||
process-wide including FAILURE: on any exception it sets `_claudeVersion = null`, and the
|
||||
guard is `!== undefined`, so a single failed/timed-out probe (5s `EXEC_TIMEOUT_MS`; PATH
|
||||
under systemd/launchd; transient fs hiccup) at the FIRST Claude session start disables
|
||||
wheel forwarding for every Claude session until the server restarts. Fix: cache success
|
||||
permanently, but let failure retry (retry on next call, or a short negative-cache TTL).
|
||||
Note that mtiller sees identical breakage on phone + iPad + laptop, which points at a
|
||||
SERVER-side/session-side cause exactly like this (cliVersion is shared by all devices)
|
||||
rather than anything browser-specific.
|
||||
3. **Claude Code genuinely < 2.1.187** on their machine: gate false BY DESIGN, but the
|
||||
resulting UX is a dead-end (no local history to fall back on).
|
||||
4. mouseTrackingMode non-none (a DECSET leaked past the strip, e.g. emitted before attach or
|
||||
split across chunks in a way the carry missed): would also kill the container handler via
|
||||
the early return. Least likely, checkable via `terminal.modes.mouseTrackingMode` in console.
|
||||
|
||||
### The iPhone symptoms fit the same gate-false story
|
||||
|
||||
Touch with gate false = local `scrollLines()` over whatever repaint frames accumulated:
|
||||
"repeats blocks of text" is literally what a buffer of successive overlapping repaint frames
|
||||
looks like; "limited amount" is its thinness; "unreliable" is burst-dependence (finding 2)
|
||||
PLUS the new re-pull being actively DESTRUCTIVE for repaint panes: `_maybeRefetchFullHistory`
|
||||
does `_resetTerminalForReplay()` then writes the fetched capture, and when that capture is
|
||||
one frame (Claude pane, `history_size≈0`) it REPLACES a multi-frame buffer with less than the
|
||||
user had, mid-scroll. Stale pre-1.12 JS on the phone (suspended Safari tab) remains possible
|
||||
until they confirm the tab kill.
|
||||
|
||||
### Fix directions, ranked
|
||||
|
||||
1. **Make the re-pull refuse downgrades** (`_maybeRefetchFullHistory`, app.js): if the fetched
|
||||
capture would yield FEWER buffer rows than currently present, skip the reset+rewrite and
|
||||
keep the richer buffer (optionally cache-mark the session "re-pull useless"). Small, safe,
|
||||
kills the "got worse after scrolling to top" class. Consider skipping the re-pull entirely
|
||||
for forwarding-capable modes where tmux keeps no history.
|
||||
2. **Rescue the gate-false Claude dead-end with PageUp forwarding**: when mode is `claude`,
|
||||
the gate is false, AND the local buffer has no scrollback (`baseY === 0`), translate wheel
|
||||
lines into coalesced PageUp/PageDown key sends (mtiller just proved Claude pages correctly
|
||||
on PageUp even on their version). Zero regression risk under that triple guard: sessions
|
||||
with real local history keep local scrolling; only the currently-dead path changes.
|
||||
Caveat: older Claude menus may react to PageUp; acceptable against "completely dead".
|
||||
3. **Audit `getClaudeCliVersion()` failure caching** (utils/claude-cli-resolver.ts): a cached
|
||||
empty probe must retry (with backoff), not poison the process.
|
||||
4. **Guard the opt-out setting's footgun**: if `terminalWheelLocalScrollback` is ON for a
|
||||
repaint-mode CLI session, local history is hollow; either scope the setting's effect to
|
||||
modes with real local scrollback, or pair it with fix 2's PageUp fallback so it still
|
||||
scrolls SOMETHING.
|
||||
5. **Add a one-line gate diagnostic**: log (once per session, console) WHY the wheel chose
|
||||
local vs forward: `{mode, cliVersion, optOut, trackingMode}`. The #205 thread is now two
|
||||
rounds deep on guesswork a single console line would have answered.
|
||||
|
||||
### What shipped for round 2 (branch `fix/scrollback-205-round2`)
|
||||
|
||||
All five directions above, implemented as ranked:
|
||||
|
||||
1. **Downgrade guard** — `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the rows a
|
||||
capture will occupy (ANSI stripped, `capture-pane -J` re-wrapping accounted for) and
|
||||
`_maybeRefetchFullHistory` (app.js) skips the reset+rewrite when that is more than one
|
||||
screen short of what xterm already holds. A refused session goes on
|
||||
`_fullHistoryRepullUseless`, which raises its re-pull cooldown from 4s to 60s so a hollow
|
||||
pane stops re-fetching. Measured A/B on a live Claude pane, same gesture, same buffer:
|
||||
guard off → 341 rows collapse to 42 and every seeded row is gone; guard on → 341 rows
|
||||
preserved. The tab-switch recovery it must not break still runs (shell buffer 401 → 44 on
|
||||
a tab switch → 401 again after scrolling to the top).
|
||||
2. **PageUp/PageDown fallback** — `_maybePageCliTranscript()` translates wheel/touch travel
|
||||
into coalesced `\x1b[5~` / `\x1b[6~` under the triple guard (claude mode, forwarding gate
|
||||
false, `baseY === 0`), through the same 40ms queue as the SGR reports. Half a screen of
|
||||
travel per page: the page key always jumps a whole screen, and a 1:1 mapping was
|
||||
unusably slow with a discrete wheel. Shift is excluded — it keeps meaning "local
|
||||
scrollback". Verified live: opt-out ON on a Claude session sends real PageUp/PageDown to
|
||||
the PTY where the wheel previously did nothing.
|
||||
3. **Probe caching** — `getClaudeCliVersion()` no longer caches failure. Success is kept for
|
||||
the process lifetime; a failed probe retries with a 1/2/4…15min backoff. The cache policy
|
||||
is a pure function (`resolveClaudeCliVersion`) so the retry semantics are unit-testable
|
||||
without spawning `claude`. The VITEST short-circuit now records nothing, where before it
|
||||
wrote a permanent null.
|
||||
4. **Opt-out footgun** — handled by pairing rather than by scoping: the setting keeps meaning
|
||||
exactly what it says (the wheel goes local), and fix 2 catches the case where "local" is
|
||||
empty. Scoping the setting away from repaint-mode CLIs would have silently overridden an
|
||||
explicit user choice. The App Settings tooltip now says to leave it off for Claude/Codex.
|
||||
5. **Diagnostic** — `_logScrollRouting()` prints one line per session per distinct decision:
|
||||
`[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…,
|
||||
cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. That
|
||||
single line answers every open question in the list below.
|
||||
|
||||
Still unanswered by code alone: whether mtiller's Claude Code is genuinely older than
|
||||
2.1.187 (false-path 3), and whether the iPhone was running stale JS. The diagnostic makes
|
||||
both self-reporting, so the retest ask is now "open the console and paste the `[scroll]` line".
|
||||
|
||||
### What to get from mtiller (some already asked)
|
||||
|
||||
- Shift+scroll behavior on Firefox (distinguishes hollow-local from handler-not-firing).
|
||||
- `claude --version` on the Mac (decides false-paths 2 vs 3).
|
||||
- App Settings → Input → "Wheel scrolls local history" state (false-path 1).
|
||||
- iPhone: Claude or shell session, and whether a full tab kill changes anything.
|
||||
- Browser console: `app.terminalUi?.terminal?.modes?.mouseTrackingMode` (false-path 4).
|
||||
|
||||
Original plan follows.
|
||||
|
||||
## Reports
|
||||
|
||||
Reference in New Issue
Block a user