diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cd49d869..75bbff65 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -221,7 +221,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **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 (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; 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, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), 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`. +**A false gate on a hollow pane 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, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: 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 guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, 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). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ 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. diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 65d8870d..497d2707 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -279,7 +279,7 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. - Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`), which is not a strip/forward list. antigravity/grok/deepseek/omp remain unverified. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a03bc4d1..9647927e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5485,15 +5485,29 @@ Object.assign(CodemanApp.prototype, { }, /** - * 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 + * True when this session's LOCAL scrollback is structurally empty: a pane whose + * TUI repaints one full screen in place, so tmux keeps no history for it + * (`history_size≈0`) and 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. + * + * Two shapes, measured separately: + * - `claude` in repaint mode (the original, #205 round 2), and + * - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux + * `alternate_on=1`, `history_size=0`) and so pushes nothing into the + * terminal's scrollback at all. It pages its own transcript with the same + * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel + * reports — six `\x1b[<64;…M` reports against an idle pane left the capture + * byte-identical — so paging is the only gesture that reaches it. Without + * this the wheel was silently dead in every opencode tab. + * + * Every other mode is deliberately absent: shell/pi own real terminal + * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour + * is unverified (docs/scrollback-fix-plan.md). */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude') return false; + if (mode !== 'claude' && mode !== 'opencode') return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; @@ -5504,18 +5518,19 @@ Object.assign(CodemanApp.prototype, { * 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, the CLI's mouse - * tracking flag is unset (the inline renderer, or fullscreen right after a - * server restart), 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. + * on a session that has no local history to fall back on: the CLI version probe + * failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is + * unset (the inline renderer, or fullscreen right after a server restart), 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), or the CLI is + * opencode, which never fills the buffer and never accepts the wheel. 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. + * Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, 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). diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 2321d8fc..060b6668 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -46,13 +46,20 @@ function loadTerminalUiHarness() { 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; cliMouseTracking?: boolean } = {}) { +/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ +function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { 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, cliMouseTracking: overrides.cliMouseTracking }], + [ + 'sess-1', + { + mode: overrides.mode ?? 'claude', + cliVersion: overrides.cliVersion, + cliMouseTracking: overrides.cliMouseTracking, + }, + ], ]); app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { @@ -135,7 +142,7 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { 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 + const { app, sent } = hollowApp(); // 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); @@ -149,7 +156,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('accumulates sub-page travel instead of dropping or over-sending it', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… app._flushWheelSgrQueue(); @@ -161,15 +168,34 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('caps the keys one gesture batch can emit', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]); }); + it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => { + // OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux + // `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at + // one screen exactly like a repaint-mode Claude pane. The difference is that + // OpenCode IGNORES SGR wheel reports (verified against an idle pane: six + // `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown + // — its `messages_page_up/down` binds — is the ONLY gesture that reaches its + // transcript. Without this the wheel was silently dead in every OpenCode tab. + const { app, sent } = hollowApp({ mode: 'opencode' }); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + app._maybePageCliTranscript({ shiftKey: false }, 18); + app._flushWheelSgrQueue(); + expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); + }); + it('leaves every session that has real local scrollback alone', () => { - const { app } = hollowClaudeApp(); + const { app } = hollowApp(); // Shift is the explicit "give me local scrollback" gesture — never paged. expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false); @@ -179,12 +205,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 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). + // Modes with real terminal scrollback keep their existing behavior (shell/pi + // own tmux history through the alt-screen strip; codex/gemini/antigravity/… + // page-key behaviour is unverified — docs/scrollback-fix-plan.md). 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); + app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); + 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' }]]); @@ -197,7 +226,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // 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. // Version and tracking both qualify, so the opt-out is the only thing saying no. - const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… + const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins @@ -207,7 +236,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('drops travel accumulated on another tab', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page app.activeSessionId = 'sess-2'; @@ -228,7 +257,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 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' }); + const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); app._logScrollRouting('local-scrollback'); @@ -255,7 +284,7 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); it('reports an unknown CLI version, the false-path that disables forwarding', () => { - const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed + const { app, logs } = hollowApp(); // no cliVersion — the probe failed app._logScrollRouting('page-keys'); expect(logs[0]).toContain('cliVersion=unknown'); });