diff --git a/CLAUDE.md b/CLAUDE.md index 099a5196..30791c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,9 +206,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2bb8c74d..c05eb265 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 -` 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 -` 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] → 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 diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index acc21f3d..fa267814 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -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] → 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 diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 3a945d0c..6e8f1dda 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -108,12 +108,100 @@ export function getAugmentedPath(): string { return _augmentedPath; } -/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */ -let _claudeVersion: string | null | undefined = undefined; +/** + * Cache state for the `claude --version` probe. + * + * `version` is only ever set from a SUCCESSFUL probe and then kept for the + * process lifetime (the binary can't change under a running server without a + * restart). Failures are tracked separately so they expire. + */ +export interface ClaudeVersionProbeState { + /** Successful probe result; `undefined` until one succeeds. */ + version?: string; + /** Consecutive failed probes (drives the retry backoff). */ + failures: number; + /** Timestamp of the most recent failed probe. */ + lastFailureAt: number; +} + +/** First retry window after a failed probe. */ +const VERSION_PROBE_BASE_RETRY_MS = 60_000; +/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */ +const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000; + +/** + * How long to wait before re-probing after `failures` consecutive failures: + * 1min, 2min, 4min… capped at 15min. Exported for tests. + */ +export function claudeVersionRetryDelayMs(failures: number): number { + if (failures <= 0) return 0; + return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS); +} + +/** + * Cache policy for the version probe, pure apart from the `state` it mutates + * and the injected `probe` (exported so tests can drive it with a fake clock). + * + * Success is cached forever; FAILURE is not. That asymmetry is the fix for a + * real shipped bug: the old cache stored `null` on any exception and guarded on + * `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a + * PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST + * Claude session start left `cliVersion` undefined for EVERY Claude session + * until the server restarted. An undefined `cliVersion` silently disables + * wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`), + * which is the only route to history in repaint mode: a dead wheel on every + * device at once, matching the issue #205 retest reports. + * + * Retries back off so a genuinely absent binary still can't spawn a probe per + * session start. + */ +export function resolveClaudeCliVersion( + state: ClaudeVersionProbeState, + now: number, + probe: () => string | null +): string | null { + if (state.version !== undefined) return state.version; + if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null; + + let version: string | null = null; + try { + version = probe(); + } catch { + version = null; + } + + if (version) { + state.version = version; + state.failures = 0; + state.lastFailureAt = 0; + return version; + } + state.failures += 1; + state.lastFailureAt = now; + return null; +} + +const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 }; + +/** One `claude --version` run. Throws on spawn/timeout failure. */ +function probeClaudeCliVersion(): string | null { + const dir = findClaudeDir(); + const bin = dir ? join(dir, 'claude') : 'claude'; + // execFileSync (no shell) — the resolved path may contain spaces, and there + // is no untrusted input, but avoid a shell either way. + const out = execFileSync(bin, ['--version'], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + env: { ...process.env, PATH: getAugmentedPath() }, + }); + const match = out.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; +} /** * Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it - * can't be determined. Runs `claude --version` once and caches the result. + * can't be determined. Runs `claude --version` at most once per successful + * resolution; failed probes retry with backoff (see `resolveClaudeCliVersion`). * * This is a deterministic alternative to scraping the interactive startup * banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't @@ -122,28 +210,10 @@ let _claudeVersion: string | null | undefined = undefined; * gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154). */ export function getClaudeCliVersion(): string | null { - if (_claudeVersion !== undefined) return _claudeVersion; // Keep the test suite hermetic — never spawn a real `claude` subprocess under // vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set - // it on the session directly. - if (process.env.VITEST) { - _claudeVersion = null; - return _claudeVersion; - } - try { - const dir = findClaudeDir(); - const bin = dir ? join(dir, 'claude') : 'claude'; - // execFileSync (no shell) — the resolved path may contain spaces, and there - // is no untrusted input, but avoid a shell either way. - const out = execFileSync(bin, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - env: { ...process.env, PATH: getAugmentedPath() }, - }); - const match = out.match(/(\d+\.\d+\.\d+)/); - _claudeVersion = match ? match[1] : null; - } catch { - _claudeVersion = null; - } - return _claudeVersion; + // it on the session directly. Deliberately does NOT touch the cache state: + // recording a phantom failure here would be the very poisoning this fixes. + if (process.env.VITEST) return null; + return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 6566d2ab..c94248bf 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -519,6 +519,10 @@ class CodemanApp { // Cooldown per session for the scroll-to-top "load more history" re-pull. this._fullHistoryRepullAt = new Map(); // Map this._fullHistoryRepullInFlight = false; + // Sessions whose last re-pull came back THINNER than the live buffer (a + // repaint-mode CLI pane, where tmux keeps no history of its own). The pull is + // refused for those and retried far more slowly — see _maybeRefetchFullHistory. + this._fullHistoryRepullUseless = new Set(); this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -4121,6 +4125,13 @@ class CodemanApp { * On demand rather than automatic because that capture is unbounded-ish work: at * the default history limit it can be megabytes, which is fine to pay when the * user is explicitly reaching for history and not fine on every tab switch. + * + * NEVER a downgrade: for a repaint-mode CLI pane tmux keeps no history of its + * own, so the capture can be THINNER than what xterm already holds and the + * reset+rewrite below would delete history mid-scroll. `_replayWouldShrinkBuffer` + * (terminal-ui.js) is the guard, and a session that produced one useless re-pull + * gets a much longer cooldown so a hollow pane stops re-fetching megabytes on + * every scroll-up (issue #205, round 2). */ async _maybeRefetchFullHistory() { const sessionId = this.activeSessionId; @@ -4129,7 +4140,8 @@ class CodemanApp { const now = Date.now(); // Momentum scrolling fires this dozens of times per flick, and a burst of new // output is the normal reason to want a re-pull, so cooldown rather than latch. - if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return; + const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000; + if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return; this._fullHistoryRepullAt.set(sessionId, now); this._fullHistoryRepullInFlight = true; try { @@ -4138,6 +4150,12 @@ class CodemanApp { // Bail on a tab switch mid-fetch: writing here would paint another session's // history into the terminal the user is now looking at. if (!buffer || this.activeSessionId !== sessionId) return; + if (this._replayWouldShrinkBuffer(buffer)) { + (this._fullHistoryRepullUseless ||= new Set()).add(sessionId); + this._logScrollRouting?.('repull-refused-downgrade'); + return; + } + this._fullHistoryRepullUseless?.delete(sessionId); const rowsBefore = this.terminal.buffer.active.length; this._resetTerminalForReplay(); await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); diff --git a/src/web/public/index.html b/src/web/public/index.html index d72a1698..db24032e 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1322,7 +1322,7 @@
Input
-
+
Wheel Scrolls Local History Plain wheel/trackpad pages the terminal scrollback diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 99065d03..42eca8c4 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -23,6 +23,27 @@ // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; + // Escape sequences occupy no terminal cells, so they must come out before a + // captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC, + // CSI, charset designators and the short escapes tmux emits; deliberately + // approximate — this feeds a size comparison, not a renderer. + // eslint-disable-next-line no-control-regex + const REPLAY_ESCAPE_RE = + /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g; + // PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll + // gesture for a repaint-mode CLI whose local buffer holds no scrollback + // (_maybePageCliTranscript). + const KEY_PAGE_UP = '\x1b[5~'; + const KEY_PAGE_DOWN = '\x1b[6~'; + // Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a + // screen rather than a full one: the page key always jumps a whole screen, so + // a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse + // wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting + // the finger is the right trade against a gesture that otherwise does nothing. + const PAGE_KEY_SCREEN_FRACTION = 0.5; + // Bound on page keys emitted from one gesture batch, mirroring the SGR tick + // cap: a fling must not build a backlog that keeps paging after it stops. + const PAGE_KEY_MAX_PER_BATCH = 3; function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); @@ -62,6 +83,11 @@ shouldSuppressTerminalQueryResponse, USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, + REPLAY_ESCAPE_RE, + KEY_PAGE_UP, + KEY_PAGE_DOWN, + PAGE_KEY_SCREEN_FRACTION, + PAGE_KEY_MAX_PER_BATCH, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -452,6 +478,7 @@ Object.assign(CodemanApp.prototype, { ev.preventDefault(); ev.stopPropagation(); if (this._shouldForwardWheelToApp(ev)) { + this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } @@ -459,6 +486,10 @@ Object.assign(CodemanApp.prototype, { // a stream of tiny pixel deltas, and rounding each one to a whole line // (the ±1 fallback) made slow drags scroll faster than the finger. const lines = this._wheelScrollLinesFloat(ev); + // …unless there is no local scrollback to scroll, in which case page the + // CLI's own transcript instead of doing nothing (_maybePageCliTranscript). + if (this._maybePageCliTranscript(ev, lines)) return; + this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this._smoothScrollBy(lines); }, @@ -476,7 +507,10 @@ Object.assign(CodemanApp.prototype, { // drags the CLI's pinned input box off the screen (issue #205's mobile // half). Same gate, so Shift has no touch analog but the local-scrollback // opt-out setting and the CLI-version gate apply to touch exactly as they - // do to the wheel. + // do to the wheel — including the PageUp/PageDown fallback the wheel uses + // when that gate is false and there is no local scrollback to scroll + // (_maybePageCliTranscript), which is what keeps a swipe from being a + // complete no-op on a phone. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; let touchLastX = 0; @@ -498,7 +532,7 @@ Object.assign(CodemanApp.prototype, { // Flick momentum keeps feeding the CLI's transcript from the last // touch point; the 40ms coalescer batches the per-frame reports. this._forwardScrollToApp(touchLastX, touchLastY, lines); - } else { + } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); } @@ -566,8 +600,10 @@ Object.assign(CodemanApp.prototype, { const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { if (this._shouldForwardWheelToApp({ shiftKey: false })) { + this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); - } else { + } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { + this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); @@ -2079,6 +2115,54 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); }, + /** + * Rows a `?full=1` capture will occupy once written into xterm. + * + * tmux joins wrapped rows in that capture (`capture-pane -J`), so a long + * logical line re-wraps into several xterm rows on write and a bare newline + * count would undershoot; escape sequences occupy no cells and come out + * first. Approximate by construction (it ignores double-width glyphs), which + * is fine: the only consumer is a coarse size comparison + * (_replayWouldShrinkBuffer), and it runs once per cooldown-guarded re-pull. + */ + _estimateReplayRows(text, cols) { + if (typeof text !== 'string' || !text) return 0; + const width = cols > 0 ? cols : 80; + const plain = text.replace(window.CodemanTerminalInput.REPLAY_ESCAPE_RE, ''); + let rows = 0; + for (const line of plain.split('\n')) { + const cells = line.endsWith('\r') ? line.length - 1 : line.length; + rows += cells > width ? Math.ceil(cells / width) : 1; + } + return rows; + }, + + /** + * DOWNGRADE GUARD for the scroll-to-top re-pull (issue #205, round 2). + * + * `_maybeRefetchFullHistory` resets the terminal and rewrites it from the + * capture, which is a straight win when tmux holds more than the browser — + * the burst-repaint and tab-switch losses it was built for. But a repaint-mode + * CLI pane keeps NO tmux history of its own (`history_size≈0` measured for a + * Claude pane), so there the capture is roughly ONE frame while xterm may hold + * hundreds of rows of replayed frames. Rewriting then DESTROYS history + * mid-scroll: exactly the "goes back a limited amount, repeats blocks, gets + * worse when I reach the top" report from the 1.12.0 retest. + * + * So refuse when the capture is smaller, with a one-screen tolerance because + * both sides are estimates: `buffer.active.length` includes the blank rows + * below the last line, and _estimateReplayRows can only approximate wrapping. + * Only a capture that is worse by more than a full screen counts as a + * downgrade, which leaves every genuine recovery case untouched. + */ + _replayWouldShrinkBuffer(capture) { + const term = this.terminal; + const rowsNow = term?.buffer?.active?.length || 0; + if (!rowsNow) return false; + const screen = term?.rows || 24; + return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow; + }, + /** * Ease-out smooth scrolling for the local wheel path. The capture-phase * wheel handler owns local scrolling (xterm's own smooth scroller is @@ -2969,6 +3053,16 @@ Object.assign(CodemanApp.prototype, { // plain wheel to xterm's own scrollback like pre-#144, for users who prefer // it over forwarding the wheel to the CLI's transcript (issue #154). Cheap — // loadAppSettingsFromStorage() is cache-backed. + // + // FOOTGUN, and why it is handled downstream rather than here: for a + // repaint-mode CLI that local scrollback is EMPTY (tmux keeps no history for + // the pane), so this setting can silently convert a working wheel into a + // dead one — a plausible reading of the #205 retest, where a user whose + // scrolling was broken on 1.11.x may well have flipped it while hunting for + // a fix. Scoping the setting away from those modes would be the other + // option, but it would override an explicit user choice; instead the caller + // falls through to _maybePageCliTranscript, so the gesture still pages the + // CLI's transcript and the setting keeps meaning exactly what it says. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = this.terminal?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; @@ -3012,13 +3106,105 @@ Object.assign(CodemanApp.prototype, { if (!pos) return; const btn = lines < 0 ? 64 : 65; const ticks = Math.min(Math.abs(lines), 5); + this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks)); + }, + + /** + * Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR + * wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a + * tmux send-keys server-side, so per-event writes would spawn a process storm + * on a single flick; the queue is bounded so a wild scroll can't build a + * backlog that keeps scrolling after the finger stops. + */ + _queueScrollBytes(data) { + if (!data || !this.activeSessionId) return; const queued = this._wheelSgrQueue || ''; if (queued.length > 512) return; - this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); + this._wheelSgrQueue = queued + data; if (this._wheelSgrFlushTimer) return; this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40); }, + /** + * True when this session's LOCAL scrollback is structurally empty: a Claude + * pane in repaint mode, where tmux reports `history_size≈0` and every frame + * overwrites the last, so xterm's normal buffer never grows past one screen + * (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture + * is routed — the "wheel does nothing at all" half of the #205 retest. + */ + _localScrollbackIsHollow() { + const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + if (mode !== 'claude') return false; + const buf = this.terminal?.buffer?.active; + if (!buf || buf.type === 'alternate') return false; + return (buf.baseY || 0) === 0; + }, + + /** + * LAST-RESORT scroll for a hollow local buffer: translate gesture lines into + * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. + * + * The rescue path for every way `_shouldForwardWheelToApp` can come back false + * on a Claude session that has no local history to fall back on: the CLI + * version probe failed or is genuinely older than 2.1.187, or the user turned + * on "Wheel scrolls local history" (which pins the wheel to a buffer that, + * for a repaint-mode CLI, is empty — the setting's footgun). Before this, all + * of those produced a completely dead gesture; the #205 reporter proved the + * keyboard route works by paging back through intact text with Fn+Up. + * + * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with + * real local scrollback is never touched. Shift is excluded on purpose: it is + * the explicit "give me local scrollback" gesture and must keep that meaning. + * + * @returns true when the gesture was consumed here (the caller must not also + * scroll locally). + */ + _maybePageCliTranscript(ev, lines) { + if (!lines || ev?.shiftKey || !this.activeSessionId) return false; + if (!this._localScrollbackIsHollow()) return false; + // Leftover travel belongs to the tab it was made on. + if (this._pageKeySession !== this.activeSessionId) { + this._pageKeySession = this.activeSessionId; + this._pageKeyPending = 0; + } + const tuning = window.CodemanTerminalInput; + const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); + const pending = (this._pageKeyPending || 0) + lines; + const pages = Math.trunc(pending / perPage); + this._pageKeyPending = pending - pages * perPage; + if (pages) { + const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN; + this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH))); + } + this._logScrollRouting('page-keys'); + return true; + }, + + /** + * One line in the console saying WHY a scroll gesture went where it went. + * + * Issue #205 ran two rounds of remote guesswork — is the CLI version probe + * empty, is the opt-out setting on, did a mouse DECSET leak past the strip? — + * that this single log answers directly. Logged once per session per distinct + * decision, so a steady gesture stays silent and a CHANGE (e.g. the version + * arriving late and flipping the route) still prints. + */ + _logScrollRouting(decision) { + const sessionId = this.activeSessionId || '(none)'; + const session = this.sessions?.get(sessionId); + const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback; + const tracking = this.terminal?.modes?.mouseTrackingMode || 'none'; + const baseY = this.terminal?.buffer?.active?.baseY ?? -1; + const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`; + if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map(); + if (this._scrollRoutingLogged.get(sessionId) === signature) return; + this._scrollRoutingLogged.set(sessionId, signature); + console.log( + `[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` + + `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})` + ); + }, + _flushWheelSgrQueue() { this._wheelSgrFlushTimer = null; const data = this._wheelSgrQueue; diff --git a/test/claude-cli-version-cache.test.ts b/test/claude-cli-version-cache.test.ts new file mode 100644 index 00000000..0b4bbdbd --- /dev/null +++ b/test/claude-cli-version-cache.test.ts @@ -0,0 +1,109 @@ +/** + * Issue #205, round 2: `getClaudeCliVersion()` used to cache FAILURE forever. + * + * It stored `null` on any exception and guarded on `!== undefined`, so a single + * failed probe — the 5s exec timeout, a PATH-starved systemd/launchd + * environment, a transient fs hiccup — at the first Claude session start left + * `cliVersion` undefined for every Claude session until the server restarted. + * An undefined `cliVersion` silently disables wheel-forwarding to Claude's own + * transcript (`_shouldForwardWheelToApp`), which is the only route to history + * for a repaint-mode pane: a dead wheel on every device at once, which is what + * the reporter described (phone + iPad + laptop all broken together points at a + * SERVER-side cause, not a browser one). + * + * The probe itself can't run under vitest (it would spawn a real `claude`), so + * these drive the cache policy directly with an injected probe and clock. + */ +import { describe, expect, it, vi } from 'vitest'; +import { + claudeVersionRetryDelayMs, + getClaudeCliVersion, + resolveClaudeCliVersion, + type ClaudeVersionProbeState, +} from '../src/utils/claude-cli-resolver.js'; + +const freshState = (): ClaudeVersionProbeState => ({ failures: 0, lastFailureAt: 0 }); + +describe('claude --version probe caching', () => { + it('probes once on success and never spawns again', () => { + const state = freshState(); + const probe = vi.fn(() => '2.1.223'); + + expect(resolveClaudeCliVersion(state, 1_000, probe)).toBe('2.1.223'); + expect(resolveClaudeCliVersion(state, 2_000, probe)).toBe('2.1.223'); + expect(resolveClaudeCliVersion(state, 9_999_999, probe)).toBe('2.1.223'); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('RETRIES after a failed probe instead of poisoning the process', () => { + const state = freshState(); + const probe = vi + .fn<() => string | null>() + .mockImplementationOnce(() => { + throw new Error('spawn claude ETIMEDOUT'); // the shipped failure mode + }) + .mockImplementationOnce(() => '2.1.223'); + + // First session start: probe blows up, no version. + expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull(); + // Immediately after, the negative cache holds — no probe storm. + expect(resolveClaudeCliVersion(state, 30_000, probe)).toBeNull(); + expect(probe).toHaveBeenCalledTimes(1); + + // Once the retry window elapses, the next session start probes again and + // wheel-forwarding comes back without a server restart. + expect(resolveClaudeCliVersion(state, 61_000, probe)).toBe('2.1.223'); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('treats an unparseable version like a failure (retryable, not cached)', () => { + const state = freshState(); + const probe = vi.fn<() => string | null>(() => null); // e.g. output without a x.y.z + + expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull(); + expect(resolveClaudeCliVersion(state, 61_000, probe)).toBeNull(); + expect(probe).toHaveBeenCalledTimes(2); + expect(state.version).toBeUndefined(); // nothing cached as "known bad" + }); + + it('clears the failure streak once a probe succeeds', () => { + const state = freshState(); + const probe = vi + .fn<() => string | null>() + .mockImplementationOnce(() => null) + .mockImplementationOnce(() => '2.1.223'); + + resolveClaudeCliVersion(state, 1_000, probe); + expect(state.failures).toBe(1); + resolveClaudeCliVersion(state, 61_000, probe); + expect(state.failures).toBe(0); + expect(state.lastFailureAt).toBe(0); + }); + + it('backs off so a genuinely missing binary cannot probe on every session start', () => { + expect(claudeVersionRetryDelayMs(0)).toBe(0); + expect(claudeVersionRetryDelayMs(1)).toBe(60_000); + expect(claudeVersionRetryDelayMs(2)).toBe(120_000); + expect(claudeVersionRetryDelayMs(3)).toBe(240_000); + // Capped, so it keeps retrying forever without ever spinning. + expect(claudeVersionRetryDelayMs(50)).toBe(15 * 60_000); + + const state = freshState(); + const probe = vi.fn<() => string | null>(() => null); + resolveClaudeCliVersion(state, 0, probe); // failure 1 → retry at 60s + resolveClaudeCliVersion(state, 30_000, probe); // still inside the window + expect(probe).toHaveBeenCalledTimes(1); + resolveClaudeCliVersion(state, 60_000, probe); // failure 2 → retry at 120s + resolveClaudeCliVersion(state, 119_000, probe); + expect(probe).toHaveBeenCalledTimes(2); + resolveClaudeCliVersion(state, 180_001, probe); + expect(probe).toHaveBeenCalledTimes(3); + }); + + it('stays hermetic under vitest without recording a phantom failure', () => { + // The guard returns before the probe, and — unlike the old code, which wrote + // null into the cache here — leaves the cache untouched. + expect(getClaudeCliVersion()).toBeNull(); + expect(getClaudeCliVersion()).toBeNull(); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts new file mode 100644 index 00000000..16b0b3b3 --- /dev/null +++ b/test/terminal-scroll-routing.test.ts @@ -0,0 +1,240 @@ +/** + * Issue #205, round 2: the 1.12.0 retest still reported unusable scrollback — + * a completely dead wheel on Firefox/macOS (while Fn+Up paged back through + * intact text), and history on iPhone that went back a little, repeated blocks + * and got worse the further up it went. + * + * Both signatures come from a Claude pane's LOCAL buffer being hollow. tmux + * keeps no history for a repaint-mode pane (`history_size≈0`), so: + * - any gesture routed to local scrollback scrolls nothing, and + * - the scroll-to-top `?full=1` re-pull replaces a multi-frame buffer with a + * single captured frame, deleting history mid-scroll. + * + * These cover the two guards that fix it: `_replayWouldShrinkBuffer` (refuse a + * downgrading re-pull) and `_maybePageCliTranscript` (page the CLI's own + * transcript when there is nothing local to scroll), plus the diagnostic that + * makes the routing decision visible instead of guessable. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +function loadTerminalUiHarness() { + const CodemanApp = function CodemanApp(this: any) {}; + const logs: string[] = []; + const context = vm.createContext({ + window: {}, + CodemanApp, + console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => 1_000 }, + requestAnimationFrame: (_fn: () => void) => 1, + setTimeout: (_fn: () => void) => 1, + Blob: function Blob() {}, + URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, + Worker: function Worker(this: any) { + this.postMessage = () => {}; + }, + MobileDetection: { isTouchDevice: () => true }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + + const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + vm.runInContext(code, context, { filename: 'terminal-ui.js' }); + return { app: new (CodemanApp as any)(), logs }; +} + +/** A Claude session whose local buffer holds exactly one screen (baseY 0). */ +function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) { + const { app, logs } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]); + app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); + app.terminal = { + cols: 80, + rows: overrides.rows ?? 36, + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, + }; + return { app, sent, logs }; +} + +describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { + it('estimates replayed rows from wrapped, escape-laden capture text', () => { + const { app } = loadTerminalUiHarness(); + + expect(app._estimateReplayRows('a\r\nb\r\nc', 80)).toBe(3); + // SGR colour runs occupy no cells, so they must not inflate the estimate. + expect(app._estimateReplayRows('\x1b[38;5;196mred\x1b[0m\r\nplain', 80)).toBe(2); + // capture-pane -J joins wrapped rows, so a long logical line re-wraps on + // write — counting newlines alone would undershoot by 2 rows here. + expect(app._estimateReplayRows('x'.repeat(25), 10)).toBe(3); + expect(app._estimateReplayRows('', 80)).toBe(0); + expect(app._estimateReplayRows(undefined, 80)).toBe(0); + }); + + it('refuses a capture that would leave LESS history than the terminal holds', () => { + const { app } = loadTerminalUiHarness(); + app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 300 } } }; + + // Claude pane: tmux has no history, so the capture is one frame while xterm + // holds hundreds of replayed rows. Rewriting would delete them mid-scroll. + const oneFrame = Array.from({ length: 36 }, (_, i) => `frame line ${i}`).join('\r\n'); + expect(app._replayWouldShrinkBuffer(oneFrame)).toBe(true); + + // Shell pane after a burst/tab-switch collapse: tmux really does hold more. + const realHistory = Array.from({ length: 800 }, (_, i) => `history ${i}`).join('\r\n'); + expect(app._replayWouldShrinkBuffer(realHistory)).toBe(false); + }); + + it('tolerates a one-screen shortfall so ordinary recoveries still replay', () => { + const { app } = loadTerminalUiHarness(); + // buffer.active.length counts the blank rows under the last line and the row + // estimate can only approximate wrapping, so a near-tie must NOT read as a + // downgrade — only a capture worse by more than a full screen does. + app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 120 } } }; + expect(app._replayWouldShrinkBuffer(Array.from({ length: 100 }, () => 'x').join('\r\n'))).toBe(false); + expect(app._replayWouldShrinkBuffer(Array.from({ length: 40 }, () => 'x').join('\r\n'))).toBe(true); + }); + + it('never refuses when the terminal has no buffer to protect', () => { + const { app } = loadTerminalUiHarness(); + app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 0 } } }; + expect(app._replayWouldShrinkBuffer('anything')).toBe(false); + }); + + it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const start = source.indexOf('async _maybeRefetchFullHistory()'); + const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start); + const reset = source.indexOf('this._resetTerminalForReplay()', start); + + expect(start).toBeGreaterThan(-1); + expect(guard).toBeGreaterThan(start); + expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite + // A hollow pane must also stop re-fetching megabytes on every scroll-up. + expect(source).toContain('this._fullHistoryRepullUseless'); + expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000'); + }); +}); + +describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => { + it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => { + const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false + + // Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp. + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + // Downward travel pages back toward the live screen. + app._maybePageCliTranscript({ shiftKey: false }, 18); + app._flushWheelSgrQueue(); + expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); + }); + + it('accumulates sub-page travel instead of dropping or over-sending it', () => { + const { app, sent } = hollowClaudeApp(); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet + + app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + }); + + it('caps the keys one gesture batch can emit', () => { + const { app, sent } = hollowClaudeApp(); + + app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]); + }); + + it('leaves every session that has real local scrollback alone', () => { + const { app } = hollowClaudeApp(); + + // Shift is the explicit "give me local scrollback" gesture — never paged. + expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false); + + // A buffer with history scrolls locally, as before. + app.terminal.buffer.active.baseY = 120; + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app.terminal.buffer.active.baseY = 0; + + // Non-Claude modes keep their existing behavior (shell scrolls tmux history + // through the alt-screen strip; codex/gemini page keys are unverified). + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + + // An alternate-screen pane belongs to xterm's own alt-scroll handling. + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal.buffer.active.type = 'alternate'; + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + }); + + it('rescues the local-scrollback opt-out footgun instead of silently dying', () => { + // "Wheel scrolls local history" ON pins the wheel to a buffer that, for a + // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix + // on 1.11.x would have ended up with a completely dead wheel on 1.12.0. + const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward… + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + }); + + it('drops travel accumulated on another tab', () => { + const { app, sent } = hollowClaudeApp(); + + app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page + app.activeSessionId = 'sess-2'; + app.sessions.set('sess-2', { mode: 'claude' }); + app._maybePageCliTranscript({ shiftKey: false }, -1); // must not complete sess-1's page + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + + it('is reachable from both the wheel and the touch paths', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + // Wheel: after the forwarding gate, before the local smooth scroll. + expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;'); + // Touch: touchmove and the momentum loop both fall through to it. + expect(source.match(/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false \}, lines\)\)/g)).toHaveLength(2); + }); +}); + +describe('scroll routing diagnostic (issue #205 round 2)', () => { + it('prints the decision and its inputs once per session, and again when it changes', () => { + const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + + app._logScrollRouting('local-scrollback'); + app._logScrollRouting('local-scrollback'); // same decision → stays quiet + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('sess-1 → local-scrollback'); + expect(logs[0]).toContain('mode=claude'); + expect(logs[0]).toContain('cliVersion=2.1.100'); + expect(logs[0]).toContain('localScrollbackOptOut=false'); + expect(logs[0]).toContain('mouseTracking=none'); + + app._logScrollRouting('page-keys'); // a changed route still prints + expect(logs).toHaveLength(2); + expect(logs[1]).toContain('page-keys'); + }); + + it('reports an unknown CLI version, the false-path that disables forwarding', () => { + const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed + app._logScrollRouting('page-keys'); + expect(logs[0]).toContain('cliVersion=unknown'); + }); +});