From 2eff6dcbdd228f7ef110dbff00b1d52f146ea87a Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 3 Oct 2026 17:59:30 -0400 Subject: [PATCH 1/6] fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event Two changes to the last-resort page-key scroll (_maybePageCliTranscript), the path a wheel or touch gesture takes when it is not forwarded to the CLI and the local xterm buffer has no history (normal buffer, baseY 0). Codex now takes that path too. Newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so a codex pane is as hollow as a repaint-mode Claude pane and the wheel did nothing. Codex still ignores SGR wheel reports and is still never forwarded the wheel; this sends plain PageUp/PageDown keys, which codex does page its transcript on (measured on 0.157.1 locally and 0.160 over a remote session). Which modes may page is a new registry capability, capabilities.transcriptPageKeys (claude and codex in stock.ts), published to the page as window.__codemanTranscriptPageKeys, so terminal-ui.js holds no CLI id for it. The first event of a gesture now pages at once. The fallback used to send a key only after half a screen of accumulated travel (19 rows, about 475px on a 38-row pane), so an ordinary trackpad flick sent nothing. The skipped travel is owed back by the rest of the gesture, keeping one page per half screen; a pause of more than 150 ms, a direction change or a tab switch starts a new gesture. --- docs/architecture-invariants.md | 2 +- src/config/cli-registry/schema.ts | 4 + src/config/cli-registry/stock.ts | 9 ++ src/config/cli-registry/types.ts | 14 +++ src/web/public/terminal-ui.js | 85 ++++++++++++----- src/web/server.ts | 13 +++ test/cli-registry-schema.test.ts | 20 ++++ test/render-index-html.test.ts | 16 ++++ test/server-index-title.test.ts | 4 +- test/terminal-scroll-routing.test.ts | 136 +++++++++++++++++++++++---- 10 files changed, 259 insertions(+), 44 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fa4fb87c7..4095c4188 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,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 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a gesture pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex (measured 0.157.1 locally, 0.160 remote) draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode rule stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5159a203c..10a9b9329 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -294,6 +294,10 @@ const capabilitiesSchema = z wheelForward: z .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) .strict(), + // Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for + // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). + // Optional: absent means no paging, the safe default for an unmeasured CLI. + transcriptPageKeys: z.boolean().optional(), keyboardAccessory: z.enum(['agent', 'shell']), privilegedCommandGate: z.boolean(), startMode: z.enum(['interactive', 'shell']), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 35b042e95..88e743131 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -267,6 +267,10 @@ const CLAUDE: CliEntry = { // Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version // AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both. wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, + // A repaint-mode Claude pane keeps no local history, so when the wheel is not + // forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is + // turned into PageUp/PageDown, which Claude pages its transcript on (#205). + transcriptPageKeys: true, keyboardAccessory: 'agent', privilegedCommandGate: false, startMode: 'interactive', @@ -703,6 +707,11 @@ const CODEX: CliEntry = { altScreen: 'strip-full', echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward + // ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local, + // alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp + // moved the visible transcript while SGR wheel reports and local scrolling did nothing. + // This is the page-key fallback for a hollow pane, not wheel forwarding. + transcriptPageKeys: true, maxFrameBytes: 32 * 1024, // codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 09cc31363..d457362d3 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -490,6 +490,20 @@ export interface CliCapabilities { }; /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + /** + * The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over + * a pane with no local history can be turned into those keys instead of doing nothing + * (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through + * `window.__codemanTranscriptPageKeys`, which the server builds from this field. + * + * ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends + * plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`). + * A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its + * `wheelForward` stays 'never') yet pages on PageUp/PageDown. + * + * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. + */ + transcriptPageKeys?: boolean; keyboardAccessory: 'agent' | 'shell'; /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ privilegedCommandGate: boolean; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d95960b22..4d2150ea7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -79,7 +79,7 @@ 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 + // gesture for a CLI whose local buffer holds no scrollback // (_maybePageCliTranscript). const KEY_PAGE_UP = '\x1b[5~'; const KEY_PAGE_DOWN = '\x1b[6~'; @@ -92,6 +92,12 @@ // 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; + // A page-key scroll event after this much quiet, or in the opposite direction, + // starts a NEW gesture, and the first event of a gesture pages at once rather + // than waiting for half a screen of travel (a trackpad flick never got there). + const PAGE_KEY_GESTURE_GAP_MS = 150; + // ...but not for sub-row jitter (0.1 row, ~2px). + const PAGE_KEY_MIN_START_ROWS = 0.1; // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The // body of the primary pane's _wheelScrollLinesFloat (see its comment for the @@ -261,6 +267,8 @@ PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, pageKeysForTravel, + PAGE_KEY_GESTURE_GAP_MS, + PAGE_KEY_MIN_START_ROWS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -5669,35 +5677,38 @@ Object.assign(CodemanApp.prototype, { }, /** - * 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. + * True when this session's LOCAL scrollback is structurally empty and its CLI + * can page its own transcript: xterm's normal buffer never grows past one + * screen (`baseY === 0`), so scrolling it 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 + * Shapes measured so far: + * - `claude` in repaint mode (the original, #205 round 2): tmux reports + * `history_size≈0` and every frame overwrites the last; * - `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. + * byte-identical — so paging is the only gesture that reaches it; + * - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which + * the full strip hides from xterm, and pages on PageUp/PageDown while SGR + * wheel reports do nothing. * - * 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). + * ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the + * list the server builds from the `transcriptPageKeys` CAPABILITY, never an id + * literal here. A missing list means no mode pages. * * `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which - * calls this with its own session and terminal, so the mode list above stays - * here alone. `localRows` replaces `baseY` as the history row count: a tile + * calls this with its own session and terminal, so the mode rule stays here + * alone. `localRows` replaces `baseY` as the history row count: a tile * discounts the stale rows its own load order leaves above the screen * (TerminalTile._localRows). Every field left out means the primary pane's. */ _localScrollbackIsHollow(target = {}) { const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude' && mode !== 'opencode') return false; + const pagingModes = window.__codemanTranscriptPageKeys; + if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false; const buf = (target.terminal || this.terminal)?.buffer?.active; if (!buf || buf.type === 'alternate') return false; const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY; @@ -5714,14 +5725,23 @@ Object.assign(CodemanApp.prototype, { * 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 + * one that never fills the buffer and never accepts the wheel (opencode, codex: + * both ignore SGR wheel reports, #227, and page on PageUp/PageDown). 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. + * This is not wheel forwarding: it sends plain keys, and only over a hollow buffer. * - * 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. + * Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability + + * `baseY === 0`) 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. + * + * The FIRST event of a gesture pages at once. Waiting for half a screen of + * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick + * never sent a key at all. That page is pre-paid: the travel it skipped is + * owed back by the rest of the gesture, so the rate stays one page per + * `perPage` rows. A pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a + * tab switch starts a new gesture. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). @@ -5739,8 +5759,29 @@ Object.assign(CodemanApp.prototype, { if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; this._pageKeyPending = 0; + this._pageKeyLastAt = undefined; + } + const tuning = window.CodemanTerminalInput; + const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); + const now = performance.now(); + const dir = lines < 0 ? -1 : 1; + const gestureStart = + typeof this._pageKeyLastAt !== 'number' || + now - this._pageKeyLastAt > tuning.PAGE_KEY_GESTURE_GAP_MS || + dir !== this._pageKeyDir; + this._pageKeyLastAt = now; + this._pageKeyDir = dir; + if (gestureStart) { + this._pageKeyPending = 0; + if (Math.abs(lines) >= tuning.PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < perPage) { + // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). + this._pageKeyPending = lines - dir * perPage; + this._queueScrollBytes(dir < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN); + this._logScrollRouting('page-keys'); + return true; + } } - const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); + const step = tuning.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); this._pageKeyPending = step.pending; if (step.keys) this._queueScrollBytes(step.keys); this._logScrollRouting('page-keys'); diff --git a/src/web/server.ts b/src/web/server.ts index 01f51dcc4..b64037bc8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1778,6 +1778,19 @@ export class WebServer extends EventEmitter { '</head>', () => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>` ); + // Which run modes page their own transcript on PageUp/PageDown, for the scroll + // fallback over a pane with no local history (`_maybePageCliTranscript`). Read off + // the `transcriptPageKeys` capability, never an id list here, and injected for a + // solo window too for the same reason as the gutter map: it scrolls a terminal. + // Ids only, which the registry schema bounds to kebab tokens, so JSON.stringify + // alone is enough. + const pageKeyClis = enabledClis() + .filter((entry) => entry.capabilities.transcriptPageKeys === true) + .map((entry) => entry.id); + html = html.replace( + '</head>', + () => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>` + ); if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`); if (settings.gestureControlEnabled === true) { diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 6424c6c82..3e61538ec 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -414,4 +414,24 @@ describe('capability shapes', () => { (e.capabilities as Record<string, unknown>).transcript = 'some-future-format'; }, 'a transcript reader that does not exist would silently read nothing'); }); + it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => { + for (const value of [true, false]) { + const entry = baseEntry(); + (entry.capabilities as Record<string, unknown>).transcriptPageKeys = value; + expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true); + } + const absent = baseEntry(); + delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys; + expect(CliEntrySchema.safeParse(absent).success).toBe(true); + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes'; + }, 'transcriptPageKeys is a boolean'); + }); + + it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => { + // claude (repaint mode) and codex (alternate screen) were measured paging their + // own transcript on PageUp/PageDown; no other stock entry has been. + const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string); + expect(paging.sort()).toEqual(['claude', 'codex']); + }); }); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 9d3427cc6..d7fe373b6 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -159,6 +159,22 @@ describe('WebServer.renderIndexHtml', () => { expect(map.shell).toBeUndefined(); }); + it('injects the transcript page-key modes, in a /session/:id window too', async () => { + // Read off the transcriptPageKeys capability. A solo window scrolls a terminal + // like the main page does, so it needs the list as much as the gutter map. + for (const solo of [undefined, 'sess-123']) { + const { server } = makeServer(); + const html = await render(server, solo); + const match = html.match(/window\.__codemanTranscriptPageKeys=(\[[^<]*\]);/); + expect(match).not.toBeNull(); + const modes = JSON.parse(match![1]) as string[]; + expect(modes).toContain('claude'); + expect(modes).toContain('codex'); + expect(modes).not.toContain('shell'); + expect(modes).not.toContain('gemini'); + } + }); + it('escapes the solo id so it cannot break out of the inline <script>', async () => { const { server } = makeServer({}); const html = await render(server, 'a</script><b>'); diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index f8629aeed..726b5f094 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -113,7 +113,9 @@ describe('WebServer index.html <title> templating (#82)', () => { .replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '') // Injected unconditionally as an object keyed by run mode, empty when no // enabled CLI declares a gutter, so it needs stripping on every machine. - .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, ''); + .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '') + // Likewise the page-key mode list: an array, possibly empty, always injected. + .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 8550b7b4c..fca7d5e06 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -19,18 +19,30 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { describe, expect, it, vi } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; -function loadTerminalUiHarness() { +/** + * The run modes whose CLI can page its own transcript with PageUp/PageDown, read + * off the shipped registry exactly as the server builds `__codemanTranscriptPageKeys`, + * so these tests break if a stock entry loses (or quietly gains) the capability. + */ +const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id); + +function loadTerminalUiHarness( + windowGlobals: Record = { __codemanTranscriptPageKeys: PAGE_KEY_MODES } +) { const CodemanApp = function CodemanApp(this: any) {}; const logs: string[] = []; // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. - const windowRef: Record = {}; + const windowRef: Record = { ...windowGlobals }; + // Mutable so a test can move time (gesture gaps); defaults to a frozen clock. + const clock = { now: () => 1_000 }; const context = vm.createContext({ window: windowRef, CodemanApp, console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) }, _crashDiag: { log: vi.fn() }, - performance: { now: () => 1_000 }, + performance: clock, requestAnimationFrame: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1, Blob: function Blob() {}, @@ -45,12 +57,12 @@ function loadTerminalUiHarness() { 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, windowRef }; + return { app: new (CodemanApp as any)(), logs, windowRef, clock }; } /** 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, windowRef } = loadTerminalUiHarness(); + const { app, logs, windowRef, clock } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ @@ -70,7 +82,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe modes: { mouseTrackingMode: 'none' }, buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, }; - return { app, sent, logs, windowRef }; + return { app, sent, logs, windowRef, clock }; } describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { @@ -157,16 +169,52 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); }); - it('accumulates sub-page travel instead of dropping or over-sending it', () => { + it('answers the first event of a gesture at once and owes the travel back', () => { const { app, sent } = hollowApp(); - expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… + // A trackpad flick is far less than half a screen (18 rows here). It used to + // send nothing at all, so a session that always lands here looked dead. + expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); app._flushWheelSgrQueue(); - expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); - app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page + // The pre-paid page is owed back: the rest of this page's travel sends nothing… + app._maybePageCliTranscript({ shiftKey: false }, -8); app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + expect(sent).toHaveLength(1); + + // …and the next page arrives after a further half screen, so the rate is unchanged. + app._maybePageCliTranscript({ shiftKey: false }, -17); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(1); + app._maybePageCliTranscript({ shiftKey: false }, -1); + app._flushWheelSgrQueue(); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-1', data: '\x1b[5~' }, + ]); + }); + + it('starts a new gesture after a pause or a direction change', () => { + const { app, sent, clock } = hollowApp(); + let now = 1_000; + clock.now = () => now; + + app._maybePageCliTranscript({ shiftKey: false }, -2); // first event: one PageUp + app._maybePageCliTranscript({ shiftKey: false }, 2); // reversal: one PageDown at once + now += 1_000; + app._maybePageCliTranscript({ shiftKey: false }, 2); // after a pause: another at once + app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[6~\x1b[6~' }]); + }); + + it('does not page on sub-row jitter that opens a gesture', () => { + const { app, sent } = hollowApp(); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -0.05)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); }); it('caps the keys one gesture batch can emit', () => { @@ -207,12 +255,12 @@ 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; - // 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). + // Modes whose CLI does not declare transcriptPageKeys keep their existing + // behavior (shell/pi own tmux history through the alt-screen strip; + // 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' }]]); + app.sessions = new Map([['sess-1', { mode: 'gemini' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); @@ -223,6 +271,47 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); }); + it('pages a Codex transcript only while its local scrollback is empty', () => { + // Codex is never sent SGR wheel reports (it ignores them); this is the + // separate PageUp/PageDown fallback, which codex does honour. + const { app, sent } = hollowApp(); + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + app.terminal.buffer.active.baseY = 40; + expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false); + expect(app._maybePageCliTranscript({ shiftKey: true }, 18)).toBe(false); + }); + + it('reads the paging modes from the injected capability map, never an id list of its own', () => { + // No map (a page rendered without it) means no mode pages, the same fail-safe + // direction the transcript-gutter map takes. + const bare = loadTerminalUiHarness({}).app; + bare.activeSessionId = 'sess-1'; + bare.sessions = new Map([['sess-1', { mode: 'claude' }]]); + bare.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } }; + expect(bare._localScrollbackIsHollow()).toBe(false); + + // A mode the map names pages even if no stock entry has that id. + const custom = loadTerminalUiHarness({ __codemanTranscriptPageKeys: ['my-cli'] }).app; + custom.activeSessionId = 'sess-1'; + custom.sessions = new Map([['sess-1', { mode: 'my-cli' }]]); + custom.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } }; + expect(custom._localScrollbackIsHollow()).toBe(true); + + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const helper = source.slice( + source.indexOf(' _localScrollbackIsHollow(target = {}) {'), + source.indexOf(' _maybePageCliTranscript(ev, lines) {') + ); + expect(helper).toContain('window.__codemanTranscriptPageKeys'); + expect(helper).not.toMatch(/=== '(?:claude|codex)'|!== '(?:claude|codex)'/); + }); + 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 @@ -240,12 +329,17 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 it('drops travel accumulated on another tab', () => { const { app, sent } = hollowApp(); - app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page + app._maybePageCliTranscript({ shiftKey: false }, -1); // first event pages sess-1 at once + app._maybePageCliTranscript({ shiftKey: false }, -34); // just short of sess-1's second page + app._flushWheelSgrQueue(); app.activeSessionId = 'sess-2'; app.sessions.set('sess-2', { mode: 'claude' }); - app._maybePageCliTranscript({ shiftKey: false }, -1); // must not complete sess-1's page + app._maybePageCliTranscript({ shiftKey: false }, -1); // a NEW gesture on sess-2, never sess-1's page app._flushWheelSgrQueue(); - expect(sent).toEqual([]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-2', data: '\x1b[5~' }, + ]); }); it('is reachable from both the wheel and the touch paths', () => { @@ -276,7 +370,9 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36)); let pending = 0; let expected = ''; - for (const lines of [-10, -10, 30, -1000, 7]) { + // One direction under a frozen clock, opening past the page size: a single + // gesture that accumulates, which is exactly the pure helper's arithmetic. + for (const lines of [-20, -10, -30, -1000, -7]) { const step = pageKeysForTravel(pending, lines, 36); pending = step.pending; expected += step.keys; @@ -304,7 +400,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { tileBuffer.type = 'alternate'; expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); tileBuffer.type = 'normal'; - app.sessions.set('tile-1', { mode: 'codex' }); + app.sessions.set('tile-1', { mode: 'gemini' }); expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); }); From a4926e49f12d33cdd8c4c2db1d44ac51636201ad Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 5 Oct 2026 21:23:03 -0400 Subject: [PATCH 2/6] fix(terminal): keep the page-key fallback out of swipes, pinches, wheel notches and dialogs Review follow-up for the hollow-pane PageUp/PageDown fallback (_maybePageCliTranscript): - A mostly horizontal wheel swipe (both deltas present, |deltaX| > |deltaY|) is consumed without paging. The touch path already locks its axis. - A pinch (wheel events with ctrlKey, as Chrome reports one) is consumed without paging. - Only a trackpad-sized opening event (under 2 rows) pages at once. A wheel notch (100 px, 4 rows) accumulates toward half a screen as before, so five slow notches send one PageUp again instead of five. A debt from a pre-paid page still dies with its gesture; plain wheel travel carries across notches. - Nothing pages while the active tab's alert is 'action' (a pending permission_prompt or elicitation_dialog), so a page key never reaches an open dialog's selector. Docs: the invariants paragraph now says which Codex versions were seen drawing on the alt screen (0.157.1, 0.160) and that 0.147.0 still draws inline, without claiming a cutoff. --- docs/architecture-invariants.md | 2 +- src/web/public/terminal-ui.js | 35 ++++++++++-- test/terminal-scroll-routing.test.ts | 79 +++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4095c4188..219adcd18 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a gesture pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex (measured 0.157.1 locally, 0.160 remote) draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode rule stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's own session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode rule stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4d2150ea7..9d939a90f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -96,8 +96,12 @@ // starts a NEW gesture, and the first event of a gesture pages at once rather // than waiting for half a screen of travel (a trackpad flick never got there). const PAGE_KEY_GESTURE_GAP_MS = 150; - // ...but not for sub-row jitter (0.1 row, ~2px). + // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; + // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, + // 4 rows) accumulates toward half a screen as before; paging at once on it made + // slow notches, each its own gesture past the gap, page a full screen apiece. + const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The // body of the primary pane's _wheelScrollLinesFloat (see its comment for the @@ -269,6 +273,7 @@ pageKeysForTravel, PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, + PAGE_KEY_IMMEDIATE_MAX_ROWS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -5736,12 +5741,21 @@ Object.assign(CodemanApp.prototype, { * scrollback is never touched. Shift is excluded on purpose: it is the explicit * "give me local scrollback" gesture and must keep that meaning. * - * The FIRST event of a gesture pages at once. Waiting for half a screen of + * The FIRST event of a trackpad-sized gesture (opening under + * PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per * `perPage` rows. A pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a - * tab switch starts a new gesture. + * tab switch starts a new gesture. A larger opening event is a wheel notch and + * accumulates as before, so slow notches still page once per `perPage` rows. + * + * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad + * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; + * the touch path locks its own axis before it gets here), and any gesture while + * the session has an open dialog (tab alert 'action', set by a pending + * permission_prompt or elicitation_dialog), so a page key never reaches its + * selector. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). @@ -5755,11 +5769,17 @@ Object.assign(CodemanApp.prototype, { _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; if (!this._localScrollbackIsHollow()) return false; + if (ev?.ctrlKey) return true; // pinch + const dx = Math.abs(ev?.deltaX || 0); + const dy = Math.abs(ev?.deltaY || 0); + if (dx && dy && dx > dy) return true; // mostly horizontal swipe + if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true; // dialog up // Leftover travel belongs to the tab it was made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; this._pageKeyPending = 0; this._pageKeyLastAt = undefined; + this._pageKeyPrepaid = false; } const tuning = window.CodemanTerminalInput; const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); @@ -5772,9 +5792,14 @@ Object.assign(CodemanApp.prototype, { this._pageKeyLastAt = now; this._pageKeyDir = dir; if (gestureStart) { - this._pageKeyPending = 0; - if (Math.abs(lines) >= tuning.PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < perPage) { + // A debt left by an earlier pre-paid page dies with its gesture; plain + // wheel travel keeps accumulating across notches. + if (this._pageKeyPrepaid) this._pageKeyPending = 0; + this._pageKeyPrepaid = false; + const size = Math.abs(lines); + if (size >= tuning.PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, tuning.PAGE_KEY_IMMEDIATE_MAX_ROWS)) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). + this._pageKeyPrepaid = true; this._pageKeyPending = lines - dir * perPage; this._queueScrollBytes(dir < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN); this._logScrollRouting('page-keys'); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index fca7d5e06..ea7ffa335 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -172,14 +172,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 it('answers the first event of a gesture at once and owes the travel back', () => { const { app, sent } = hollowApp(); - // A trackpad flick is far less than half a screen (18 rows here). It used to - // send nothing at all, so a session that always lands here looked dead. - expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); + // A trackpad flick opens with a small delta and stays far short of half a + // screen (18 rows here). It used to send nothing at all, so a session that + // always lands here looked dead. + expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true); app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); // The pre-paid page is owed back: the rest of this page's travel sends nothing… - app._maybePageCliTranscript({ shiftKey: false }, -8); + app._maybePageCliTranscript({ shiftKey: false }, -17); app._flushWheelSgrQueue(); expect(sent).toHaveLength(1); @@ -200,15 +201,79 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 let now = 1_000; clock.now = () => now; - app._maybePageCliTranscript({ shiftKey: false }, -2); // first event: one PageUp - app._maybePageCliTranscript({ shiftKey: false }, 2); // reversal: one PageDown at once + app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp + app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal: one PageDown at once now += 1_000; - app._maybePageCliTranscript({ shiftKey: false }, 2); // after a pause: another at once + app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: another at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[6~\x1b[6~' }]); }); + it('lets wheel notches accumulate instead of paging a full screen on each one', () => { + // A 100 px notch is 4 rows. Only a trackpad-sized opening event (under 2 rows) + // pages at once; a notch adds up toward half a screen as it always did, so + // slow notches (each one its own gesture by the 150 ms gap) still page once + // per 18 rows here, not once per notch. + for (const gapMs of [250, 40]) { + const { app, sent, clock } = hollowApp(); + let now = 1_000; + clock.now = () => now; + for (let i = 0; i < 5; i++) { + expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true); + now += gapMs; + } + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + } + }); + + it('consumes a mostly horizontal swipe without paging', () => { + const { app, sent } = hollowApp(); + + // A sideways trackpad swipe carries a little vertical drift (3 px is 0.12 rows, + // above the jitter floor). It must not page the transcript. + for (let i = 0; i < 6; i++) { + expect(app._maybePageCliTranscript({ shiftKey: false, deltaX: 60, deltaY: 3 }, 0.12)).toBe(true); + } + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + + // A mostly vertical swipe with some sideways drift still pages. + app._maybePageCliTranscript({ shiftKey: false, deltaX: 3, deltaY: -25 }, -1); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + }); + + it('consumes a trackpad pinch without paging', () => { + const { app, sent } = hollowApp(); + + // Chrome reports a pinch as wheel events with ctrlKey set. + for (let i = 0; i < 4; i++) { + expect(app._maybePageCliTranscript({ shiftKey: false, ctrlKey: true, deltaY: 4 }, 0.16)).toBe(true); + } + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + + it('does not page while the session is showing a dialog', () => { + const { app, sent } = hollowApp(); + // 'action' is set while a permission_prompt or elicitation_dialog is pending + // (updateTabAlertFromHooks in app.js). Page keys must not reach that selector. + app.tabAlerts = new Map([['sess-1', 'action']]); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true); + expect(app._maybePageCliTranscript({ shiftKey: false }, -40)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + + // Once the dialog is answered (an idle alert, or none) paging resumes. + app.tabAlerts.set('sess-1', 'idle'); + app._maybePageCliTranscript({ shiftKey: false }, 1); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); + }); + it('does not page on sub-row jitter that opens a gesture', () => { const { app, sent } = hollowApp(); From d7198dd0584a44374c2bb9b70435aace2d8a2994 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Fri, 9 Oct 2026 20:27:16 -0400 Subject: [PATCH 3/6] fix(terminal): share the gesture-aware page-key math with tiles, add opencode Review follow-up after the rebase onto 1.40.0, where tiles began paging a hollow buffer through the shared CodemanTerminalInput helpers and opencode joined the hollow check (#555). - opencode declares transcriptPageKeys in stock.ts, so the paging list read by both panes is claude, codex and opencode. Without it, reading the capability instead of the old id check would have turned opencode paging off again. The stock-set and render tests name it. - The gesture rules (first trackpad-sized event pages at once and is owed back, wheel notches accumulate, a pre-paid debt dies with its gesture, pinch and mostly horizontal swipes are consumed without paging) move out of the primary pane into the pure CodemanTerminalInput.pageKeysForGesture. The primary pane and TerminalTile both call it with their own state, so a Codex or opencode tile pages exactly like the main pane. - The dialog check stays per pane: the primary pane reads tabAlerts for the active session, a tile for its own sessionId, so a wheel over a tile never pages into that session's permission prompt while the focused tab is clear, and a tile is not blocked by a dialog in another tab. Tests: pure helper cases in terminal-scroll-routing, tile cases for Codex paging, first-event paging, notch accumulation, pinch and swipe, byte parity with the primary pane over a mixed sequence, and the per-session dialog check in both directions. --- docs/architecture-invariants.md | 2 +- src/config/cli-registry/stock.ts | 5 + src/web/public/terminal-tile.js | 52 +++++---- src/web/public/terminal-ui.js | 116 +++++++++++------- test/cli-registry-schema.test.ts | 6 +- test/render-index-html.test.ts | 1 + test/terminal-scroll-routing.test.ts | 116 ++++++++++++++++-- test/terminal-tile-scroll.test.ts | 169 ++++++++++++++++++++++++++- 8 files changed, 387 insertions(+), 80 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 219adcd18..33775cc54 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` 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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's own session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode rule stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ These gesture rules (first-event page, notch accumulation, prepaid-debt reset, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 88e743131..cb31df64c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -497,6 +497,11 @@ const OPENCODE: CliEntry = { ...agentDefaults(), altScreen: 'strip-mux-and-mouse', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, + // Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores + // SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux + // `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the + // wheel is turned into page keys or it does nothing at all (#555). + transcriptPageKeys: true, // Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real // turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every // composer row starts with a `┃` bar, and the submitted prompt lands in the transcript diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 222ebc658..a216d296b 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -19,13 +19,15 @@ * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): - * - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the - * alternate screen, Claude's repaint mode) leaves the xterm no scrollback, - * so the wheel pages the CLI's own transcript with PageUp/PageDown - * (_maybePageCliTranscript) through the primary pane's gates, plus an - * overflow-row discount for this pane's capture-before-resize load - * (_localRows), and only while the viewport is on the live screen (a - * wheel-down from those overflow rows is xterm's, and brings it home). + * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex + * on the alternate screen, Claude's repaint mode) leaves the xterm no + * scrollback, so the wheel pages the CLI's own transcript with + * PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates + * and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus + * an overflow-row discount for this pane's capture-before-resize load + * (_localRows), only while the viewport is on the live screen (a wheel-down + * from those overflow rows is xterm's, and brings it home), and never while + * THIS pane's session has a dialog open. * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener), sent @@ -43,7 +45,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick, app.tabAlerts) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -198,9 +200,10 @@ // flag, app._linkHovered, belongs to its terminal alone). this._linkHovered = false; this._onFocusIn = null; - // Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a - // whole page, carried to the next wheel event. - this._pageKeyPending = 0; + // Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress + // (travel short of a whole page, its direction and last event time), the + // state CodemanTerminalInput.pageKeysForGesture hands back. + this._pageKeyGesture = null; // Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes). this._scrollBytes = ''; this._scrollFlushTimer = null; @@ -1071,17 +1074,20 @@ // Hollow-buffer paging, the twin of the primary pane's // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that - // draws in place (opencode, on the alternate screen; Claude's repaint mode) - // leaves this xterm no scrollback, so a wheel scrolled nothing; instead the - // travel pages the CLI's own transcript with PageUp/PageDown. Every gate is - // the primary pane's own, asked for THIS pane (its terminal, its session, - // never the active one), so the CLI rules stay in terminal-ui.js and this - // file names no CLI. Returns true when the wheel was consumed here. + // draws in place (on the alternate screen, or Claude's repaint mode) leaves + // this xterm no scrollback, so a wheel scrolled nothing; instead the travel + // pages the CLI's own transcript with PageUp/PageDown. Every gate is the + // primary pane's own, asked for THIS pane (its terminal, its session, never + // the active one), so the CLI rules stay in terminal-ui.js and this file + // names no CLI. The gesture rules (first-event page, notch accumulation, + // pinch and sideways-swipe consume) are the shared pure + // CodemanTerminalInput.pageKeysForGesture, run on this tile's own state. + // Returns true when the wheel was consumed here. _maybePageCliTranscript(ev) { if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; const app = global.app; const input = global.CodemanTerminalInput; - if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false; + if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false; // xterm's own encoder forwards the wheel while the CLI's tracking reaches // it (a shell running htop), as in the primary pane. const tracking = this.terminal.modes?.mouseTrackingMode; @@ -1105,8 +1111,12 @@ if (!app._terminalViewportAtBottom?.(this.terminal)) return false; const lines = input.wheelDeltaLines(ev, this.terminal.rows); if (!lines) return false; - const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); - this._pageKeyPending = step.pending; + // An open dialog (a pending permission prompt or question) in THIS tile's + // session, which is usually not the active one: a page key would move its + // selector. Consumed, so xterm does not scroll the rows under it either. + if (app.tabAlerts?.get(this.sessionId) === 'action') return true; + const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now()); + this._pageKeyGesture = step.state; if (step.keys) this._queueScrollBytes(step.keys); return true; } @@ -1459,7 +1469,7 @@ clearTimeout(this._scrollFlushTimer); this._scrollFlushTimer = null; this._scrollBytes = ''; - this._pageKeyPending = 0; + this._pageKeyGesture = null; this._detachSocket(); if (this._onFocusIn) { this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 9d939a90f..96820432f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -117,13 +117,17 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } + // Rows of travel per page key for a terminal `rows` tall (half a screen). + function pageKeyRows(rows) { + return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); + } + // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds // `lines` to the sub-page travel already `pending`, and returns the travel - // left over plus the keys to send ('' below one page). The arithmetic of the - // primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps - // its own pending travel) pages identically. + // left over plus the keys to send ('' below one page). The plain accumulation + // step of pageKeysForGesture below. function pageKeysForTravel(pending, lines, rows) { - const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); + const perPage = pageKeyRows(rows); const total = (pending || 0) + lines; const pages = Math.trunc(total / perPage); const keys = pages @@ -132,6 +136,51 @@ return { pending: total - pages * perPage, keys }; } + // One wheel or touch event of a hollow-pane gesture → the PageUp/PageDown keys + // to send, for a terminal `rows` tall, at time `now` (ms). Pure: `state` is + // the previous call's returned state (null or {} for a fresh pane), and the + // caller keeps it, so the primary pane and every TerminalTile page with the + // same gesture rules through their own state: + // - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly + // horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing + // and leave the state alone (the caller still consumes them); + // - after PAGE_KEY_GESTURE_GAP_MS of quiet, or a direction change, a NEW + // gesture starts, and a trackpad-sized opening event (at least + // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, + // pre-paid: the half screen it skipped is owed back by the rest of the + // gesture, so the rate stays one page per half screen. A debt left by a + // pre-paid page dies with its gesture; + // - everything else, a wheel notch included, accumulates through + // pageKeysForTravel, carried across notches. + // Returns { state, keys } with keys '' when nothing is due. + function pageKeysForGesture(state, ev, lines, rows, now) { + const prev = state || {}; + if (ev?.ctrlKey) return { state: prev, keys: '' }; + const dx = Math.abs(ev?.deltaX || 0); + const dy = Math.abs(ev?.deltaY || 0); + if (dx && dy && dx > dy) return { state: prev, keys: '' }; + const perPage = pageKeyRows(rows); + const dir = lines < 0 ? -1 : 1; + let pending = prev.pending || 0; + let prepaid = !!prev.prepaid; + const gestureStart = + typeof prev.lastAt !== 'number' || now - prev.lastAt > PAGE_KEY_GESTURE_GAP_MS || dir !== prev.dir; + if (gestureStart) { + if (prepaid) pending = 0; + prepaid = false; + const size = Math.abs(lines); + if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { + // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). + return { + state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true }, + keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN, + }; + } + } + const step = pageKeysForTravel(pending, lines, rows); + return { state: { pending: step.pending, lastAt: now, dir, prepaid }, keys: step.keys }; + } + const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; // Composer navigation keys as xterm.js encodes user keystrokes: plain and // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3 @@ -271,6 +320,7 @@ PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, pageKeysForTravel, + pageKeysForGesture, PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, PAGE_KEY_IMMEDIATE_MAX_ROWS, @@ -5760,54 +5810,34 @@ Object.assign(CodemanApp.prototype, { * @returns true when the gesture was consumed here (the caller must not also * scroll locally). * + * The gesture rules above live in the pure CodemanTerminalInput.pageKeysForGesture + * (top of this file); this method adds the gates and keeps the state. + * * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile - * through the same gates and the same pageKeysForTravel arithmetic; keep the - * two in step. The tile adds one gate this pane cannot need, viewport at the + * through the same gates and the same pageKeysForGesture, with its own state + * and its OWN session's dialog check; keep the two in step. The tile adds one gate this pane cannot need, viewport at the * bottom: hollow here means baseY 0, so this viewport is always there, while a * tile is hollow with its own discounted rows still above the screen. */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; if (!this._localScrollbackIsHollow()) return false; - if (ev?.ctrlKey) return true; // pinch - const dx = Math.abs(ev?.deltaX || 0); - const dy = Math.abs(ev?.deltaY || 0); - if (dx && dy && dx > dy) return true; // mostly horizontal swipe - if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true; // dialog up - // Leftover travel belongs to the tab it was made on. + // An open dialog in THIS pane's session (the active one): a page key would + // move its selector. Consumed, so the buffer does not scroll under it either. + if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true; + // Leftover travel and the gesture in progress belong to the tab they were made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; - this._pageKeyPending = 0; - this._pageKeyLastAt = undefined; - this._pageKeyPrepaid = false; - } - const tuning = window.CodemanTerminalInput; - const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); - const now = performance.now(); - const dir = lines < 0 ? -1 : 1; - const gestureStart = - typeof this._pageKeyLastAt !== 'number' || - now - this._pageKeyLastAt > tuning.PAGE_KEY_GESTURE_GAP_MS || - dir !== this._pageKeyDir; - this._pageKeyLastAt = now; - this._pageKeyDir = dir; - if (gestureStart) { - // A debt left by an earlier pre-paid page dies with its gesture; plain - // wheel travel keeps accumulating across notches. - if (this._pageKeyPrepaid) this._pageKeyPending = 0; - this._pageKeyPrepaid = false; - const size = Math.abs(lines); - if (size >= tuning.PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, tuning.PAGE_KEY_IMMEDIATE_MAX_ROWS)) { - // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). - this._pageKeyPrepaid = true; - this._pageKeyPending = lines - dir * perPage; - this._queueScrollBytes(dir < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN); - this._logScrollRouting('page-keys'); - return true; - } - } - const step = tuning.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); - this._pageKeyPending = step.pending; + this._pageKeyGesture = null; + } + const step = window.CodemanTerminalInput.pageKeysForGesture( + this._pageKeyGesture, + ev, + lines, + this.terminal?.rows, + performance.now() + ); + this._pageKeyGesture = step.state; if (step.keys) this._queueScrollBytes(step.keys); this._logScrollRouting('page-keys'); return true; diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 3e61538ec..156d315bf 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -429,9 +429,9 @@ describe('capability shapes', () => { }); it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => { - // claude (repaint mode) and codex (alternate screen) were measured paging their - // own transcript on PageUp/PageDown; no other stock entry has been. + // claude (repaint mode), codex and opencode (both on the alternate screen) were + // measured paging their own transcript on PageUp/PageDown; no other stock entry has been. const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string); - expect(paging.sort()).toEqual(['claude', 'codex']); + expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']); }); }); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index d7fe373b6..5553b1fa6 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -170,6 +170,7 @@ describe('WebServer.renderIndexHtml', () => { const modes = JSON.parse(match![1]) as string[]; expect(modes).toContain('claude'); expect(modes).toContain('codex'); + expect(modes).toContain('opencode'); expect(modes).not.toContain('shell'); expect(modes).not.toContain('gemini'); } diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index ea7ffa335..4fb5951b1 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -274,6 +274,16 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); }); + it("reads the ACTIVE session's dialog, not some other tab's", () => { + const { app, sent } = hollowApp(); + // A tile or a background tab with a pending prompt does not block this pane. + app.tabAlerts = new Map([['tile-1', 'action']]); + + app._maybePageCliTranscript({ shiftKey: false }, -1); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + }); + it('does not page on sub-row jitter that opens a gesture', () => { const { app, sent } = hollowApp(); @@ -416,10 +426,84 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); }); +describe('pageKeysForGesture, the pure gesture rules both panes share', () => { + const UP = '\x1b[5~'; + const DOWN = '\x1b[6~'; + const helper = () => loadTerminalUiHarness().windowRef.CodemanTerminalInput.pageKeysForGesture; + type Step = { state: unknown; keys: string }; + /** Runs [lines, msSinceStart, extra] events through the helper; returns every step. */ + function run(events: Array<[number, number, Record<string, unknown>?]>, rows = 36) { + const pageKeysForGesture = helper(); + let state: unknown = null; + return events.map(([lines, at, extra]) => { + const step: Step = pageKeysForGesture(state, { shiftKey: false, ...extra }, lines, rows, 10_000 + at); + state = step.state; + return step; + }); + } + + it('pages a trackpad-sized opening event at once and owes the half screen back', () => { + const steps = run([ + [-1, 0], + [-16, 10], + [-18, 20], + [-1, 30], + ]); + expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); + }); + + it('accumulates a notch-sized opening event, and carries plain travel across notches', () => { + // Five 4-row notches 400 ms apart: each is a new gesture, none pages alone. + const steps = run([ + [-4, 0], + [-4, 400], + [-4, 800], + [-4, 1200], + [-4, 1600], + ]); + expect(steps.map((s) => s.keys).join('')).toBe(UP); + }); + + it('drops a pre-paid debt when its gesture ends', () => { + // A flick pre-pays a page; a second flick after a pause pages at once again + // instead of first paying off the first one's debt. + const steps = run([ + [-1, 0], + [-1, 500], + ]); + expect(steps.map((s) => s.keys)).toEqual([UP, UP]); + }); + + it('starts a new gesture on a direction change, and ignores sub-row jitter', () => { + const steps = run([ + [-1, 0], + [1, 10], + [0.05, 20], + [-0.05, 1000], + ]); + expect(steps.map((s) => s.keys)).toEqual([UP, DOWN, '', '']); + }); + + it('sends nothing for a pinch or a mostly horizontal swipe, and leaves the state as it was', () => { + const pageKeysForGesture = helper(); + const state = { pending: -7, lastAt: 9_990, dir: -1, prepaid: false }; + const pinch = pageKeysForGesture(state, { ctrlKey: true, deltaY: -900 }, -36, 36, 10_000); + const swipe = pageKeysForGesture(state, { deltaX: -1000, deltaY: -900 }, -36, 36, 10_000); + expect(pinch).toEqual({ state, keys: '' }); + expect(swipe).toEqual({ state, keys: '' }); + // A mostly VERTICAL swipe with some sideways drift still pages. + expect(pageKeysForGesture(state, { deltaX: -100, deltaY: -900 }, -36, 36, 10_000).keys).toBe(UP.repeat(2)); + }); + + it('caps a fling at PAGE_KEY_MAX_PER_BATCH keys', () => { + expect(run([[-1000, 0]])[0].keys).toBe(UP.repeat(3)); + }); +}); + describe('the paging gates asked for another pane (a TerminalTile)', () => { it('exports the paging math, and the primary pane runs on it', () => { const { app, sent, windowRef } = hollowApp(); - const { wheelDeltaLines, pageKeysForTravel } = windowRef.CodemanTerminalInput; + const { wheelDeltaLines, pageKeysForTravel, pageKeysForGesture } = windowRef.CodemanTerminalInput; expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox) @@ -433,19 +517,31 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { // The primary pane's own methods agree with them. const ev = { deltaY: -250, deltaMode: 2 }; expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36)); - let pending = 0; + let state: unknown = null; let expected = ''; - // One direction under a frozen clock, opening past the page size: a single - // gesture that accumulates, which is exactly the pure helper's arithmetic. - for (const lines of [-20, -10, -30, -1000, -7]) { - const step = pageKeysForTravel(pending, lines, 36); - pending = step.pending; + // Mixed: a trackpad opening, a notch-sized opening, a reversal, a pinch and + // a sideways swipe, all under a frozen clock (one gesture per direction). + const steps: Array<[number, Record<string, unknown>]> = [ + [-1, {}], + [-10, {}], + [-30, {}], + [1, {}], + [-5, { ctrlKey: true }], + [-5, { deltaX: 400, deltaY: -125 }], + [-1000, {}], + [-7, {}], + ]; + for (const [lines, extra] of steps) { + const event = { shiftKey: false, ...extra }; + const step = pageKeysForGesture(state, event, lines, 36, 1_000); + state = step.state; expected += step.keys; - app._maybePageCliTranscript({ shiftKey: false }, lines); + app._maybePageCliTranscript(event, lines); } app._flushWheelSgrQueue(); - expect(app._pageKeyPending).toBe(pending); - expect(expected).not.toBe(''); + expect(app._pageKeyGesture).toEqual(state); + expect(expected).toContain('\x1b[5~'); + expect(expected).toContain('\x1b[6~'); expect(sent).toEqual([{ id: 'sess-1', data: expected }]); }); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 1ad5459e9..c93e29cad 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -30,20 +30,35 @@ import { performance } from 'node:perf_hooks'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js'; const fetchMock = vi.fn(); +/** + * The run modes that page, read off the shipped registry exactly as the server + * builds `window.__codemanTranscriptPageKeys` (renderIndexHtml), which is the only + * place either pane learns them. + */ +const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id); + +/** + * The clock both panes read gesture gaps from (`performance.now()`). Real time by + * default; a test that needs a pause or a burst moves it by hand. + */ +const clock = { now: () => performance.now() }; + function loadContext() { const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn(), CodemanBase: { base: '' }, + __codemanTranscriptPageKeys: PAGE_KEY_MODES, }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, - performance, + performance: { now: () => clock.now() }, setInterval: vi.fn(), clearInterval: vi.fn(), // Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code @@ -185,6 +200,7 @@ function flushed(ws: FakeSocket) { } beforeEach(() => { + clock.now = () => performance.now(); vi.useFakeTimers(); FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; @@ -226,7 +242,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => { expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); }); - it.each(['shell', 'codex', 'antigravity'])( + it.each(['shell', 'gemini', 'antigravity'])( 'leaves a %s tile to xterm even while the active session is opencode', async (mode) => { const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other'); @@ -375,6 +391,155 @@ describe('page-key travel, cap and coalescing', () => { }); }); +describe('gesture handling is the primary pane\'s, through the shared pure helper', () => { + beforeEach(() => { + FakeFit.proposed = { cols: 80, rows: 36 }; // half a screen: 18 rows a page + }); + + /** A hand-moved clock; returns a function that advances it. */ + function manualClock() { + let now = 10_000; + clock.now = () => now; + return (ms: number) => { + now += ms; + }; + } + + it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); + + mount.fire('wheel', wheelLines(-18)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('answers the first trackpad-sized event of a gesture at once, and owes the travel back', async () => { + manualClock(); + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); + + mount.fire('wheel', wheelLines(-1)); // a trackpad flick opens with a row or so + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + + // Same gesture (no pause): the pre-paid page is owed back first, so the next + // PageUp needs a further half screen, not another first event. + mount.fire('wheel', wheelLines(-16)); + mount.fire('wheel', wheelLines(-18)); + expect(flushed(ws)).toHaveLength(1); // 1 + 16 + 18 rows: still one short of two pages + mount.fire('wheel', wheelLines(-1)); + expect(flushed(ws)).toEqual([ + { t: 'i', d: PAGE_UP }, + { t: 'i', d: PAGE_UP }, + ]); + }); + + it('accumulates slow wheel notches instead of paging a screen per notch', async () => { + const advance = manualClock(); + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + // Five notches of 4 rows, each past the gesture gap: 20 rows, one page. + for (let i = 0; i < 5; i++) { + mount.fire('wheel', wheelLines(-4)); + advance(400); + } + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('consumes a pinch and a mostly horizontal swipe without paging', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); + + // Each carries a full page of vertical travel, so either one pages if it + // reaches the travel math at all. + const pinch = wheelLines(-18, { ctrlKey: true }); + const swipe = wheel(-18 * 25, { deltaX: -40 * 25 }); + mount.fire('wheel', pinch); + mount.fire('wheel', swipe); + + // Consumed: xterm must not scroll its stale rows for them either. + expect(pinch.preventDefault).toHaveBeenCalled(); + expect(swipe.preventDefault).toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('sends exactly the bytes the primary pane sends for a mixed gesture sequence', async () => { + const advance = manualClock(); + // [rows of travel, ms before the next event, extra event fields] + const steps: Array<[number, number, Record<string, unknown>?]> = [ + [-1, 10], + [-6, 10], + [-30, 300], + [-4, 400], + [-4, 400], + [-1, 10, { ctrlKey: true }], + [-1.2, 10, { deltaX: 200 }], + [1, 10], // reversal, trackpad-sized: a PageDown at once + [2, 500], + [-0.05, 10], + [-200, 10], + ]; + const events = () => steps.map(([rows, , extra]) => wheelLines(rows, extra)); + + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); + const tileEvents = events(); + steps.forEach(([, gap], i) => { + mount.fire('wheel', tileEvents[i]); + advance(gap); + }); + const tileBytes = flushed(ws) + .map((f) => f.d) + .join(''); + + const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { + terminal: unknown; + _maybePageCliTranscript(ev: unknown, lines: number): boolean; + _wheelScrollLinesFloat(ev: unknown): number; + _flushWheelSgrQueue(): void; + }; + const sent: string[] = []; + primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + primary.terminal = { + rows: 36, + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } }, + }; + const primaryEvents = events(); + steps.forEach(([, gap], i) => { + primary._maybePageCliTranscript(primaryEvents[i], primary._wheelScrollLinesFloat(primaryEvents[i])); + advance(gap); + }); + primary._flushWheelSgrQueue(); + + expect(tileBytes).toContain(PAGE_UP); + expect(tileBytes).toContain(PAGE_DOWN); + expect(tileBytes).toBe(sent.join('')); + }); +}); + +describe("the dialog check reads the TILE's session, never the active tab's", () => { + it('does not page a tile whose own session has a pending dialog while the active tab is clear', async () => { + const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other'); + (app as App & { tabAlerts: Map<string, string> }).tabAlerts = new Map([['s-tile', 'action']]); + const { ws, mount } = await connectTile(app, { mode: 'codex' }); + + const ev = wheelLines(-18); + mount.fire('wheel', ev); + + // Consumed, so xterm does not scroll the stale rows under the dialog either. + expect(ev.preventDefault).toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('pages a clear tile while the ACTIVE tab has a pending dialog', async () => { + const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other'); + (app as App & { tabAlerts: Map<string, string> }).tabAlerts = new Map([['other', 'action']]); + const { ws, mount } = await connectTile(app, { mode: 'codex' }); + + mount.fire('wheel', wheelLines(-18)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); +}); + describe('rows the tile pushed above the screen itself are not history', () => { it('pages after a one-screen capture taken at a taller size (the first load)', async () => { serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured From 3ea1e03aaf9f5cb505e447f1c61e8826b5b44bc2 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Sat, 10 Oct 2026 22:50:06 -0400 Subject: [PATCH 4/6] fix(terminal): one touch contact is one paging gesture; bound slow ticks and wobble Review of #527 (Ark0N, 2026-10-11): 1. A slow touch drag paged a full screen per line. touchmove calls the fallback once per whole cell, so a slow drag's calls were further apart than the 150 ms gesture gap and each pre-paid a page (12 lines at 170 ms sent 12 PageUps). The touch paths now tag every call, touchmove and momentum frames alike, with a per-contact touchId (incremented on touchstart), and pageKeysForGesture never splits a contact by time; a new contact is a new gesture. 2. A pre-paid debt now survives up to PAGE_KEY_DEBT_MS (1 s) of quiet when the next event goes the same way, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay it back instead of each pre-paying. A reversal inside the gap (or inside one contact) drops the debt and accumulates without pre-paying, so a +/-3 px wobble no longer pages back and forth. A reversal after the gap still pages at once. Main pane and tiles share the helper, so both get it. 3. A wheel consumed by the dialog check now logs page-keys-dialog-open. 4. The Dashboard wiki names newer Codex beside OpenCode; the invariants paragraph describes the new gesture boundaries. Tests: unit regressions through the real _maybePageCliTranscript, helper cases, tile/main parity for ticks and wobble, and a Chromium test that drives the real initTerminal touch listeners (12 PageUps before, 1 after). --- config/test-suites.ts | 1 + docs/architecture-invariants.md | 2 +- docs/wiki/The-Dashboard.md | 8 +- src/web/public/terminal-ui.js | 64 +++++-- test/terminal-scroll-routing.test.ts | 186 +++++++++++++++++++-- test/terminal-tile-scroll.test.ts | 50 +++++- test/terminal-touch-paging.browser.test.ts | 147 ++++++++++++++++ 7 files changed, 426 insertions(+), 32 deletions(-) create mode 100644 test/terminal-touch-paging.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index d005f0aa0..32d9a990e 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/terminal-tile-scroll.browser.test.ts', + 'test/terminal-touch-paging.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', 'test/webhook-settings.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9aeb8c037..8d205790d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` 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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ These gesture rules (first-event page, notch accumulation, prepaid-debt reset, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 610df7395..5fbc0bab5 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -223,8 +223,12 @@ Worth knowing: `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. OpenCode's wheel and swipes page its own conversation - (PageUp/PageDown); in a grid tile or the split view's second pane the wheel does - too. Other CLIs scroll locally. + (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen + and leave the terminal no history of its own (an older Codex that draws inline keeps + its history in the terminal and scrolls locally). The first small movement of a + gesture pages at once, then one page per half screen of travel; one finger on the + screen is one gesture however slowly it moves. In a grid tile or the split view's + second pane the wheel pages too. Other CLIs scroll locally. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not. `Ctrl+Shift+C` always copies. - **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c641f1b04..758e671d1 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -102,6 +102,10 @@ // 4 rows) accumulates toward half a screen as before; paging at once on it made // slow notches, each its own gesture past the gap, page a full screen apiece. const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; + // A pre-paid page's debt outlives the gesture gap for this long when the next + // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a + // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. + const PAGE_KEY_DEBT_MS = 1000; // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The // body of the primary pane's _wheelScrollLinesFloat (see its comment for the @@ -170,12 +174,21 @@ // - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly // horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing // and leave the state alone (the caller still consumes them); - // - after PAGE_KEY_GESTURE_GAP_MS of quiet, or a direction change, a NEW - // gesture starts, and a trackpad-sized opening event (at least + // - a NEW gesture starts on the first event, on a different touch contact + // (`ev.touchId`, set by the touch paths per touchstart and carried by that + // contact's momentum; a wheel event has none, so wheel and touch never + // share a gesture), or, for events outside any contact, after quiet: + // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid + // page is still owed in the same direction. One contact is never split by + // time, so a slow drag (one line per 170 ms) is one gesture; + // - a new gesture's trackpad-sized opening event (at least // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, // pre-paid: the half screen it skipped is owed back by the rest of the // gesture, so the rate stays one page per half screen. A debt left by a // pre-paid page dies with its gesture; + // - a direction change inside a gesture (a reversal within the gap, or + // within the same contact) drops a pre-paid debt and accumulates, never + // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; // - everything else, a wheel notch included, accumulates through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. @@ -187,24 +200,37 @@ if (dx && dy && dx > dy) return { state: prev, keys: '' }; const perPage = pageKeyRows(rows); const dir = lines < 0 ? -1 : 1; + const touchId = typeof ev?.touchId === 'number' ? ev.touchId : null; + const prevTouchId = typeof prev.touchId === 'number' ? prev.touchId : null; let pending = prev.pending || 0; let prepaid = !!prev.prepaid; - const gestureStart = - typeof prev.lastAt !== 'number' || now - prev.lastAt > PAGE_KEY_GESTURE_GAP_MS || dir !== prev.dir; - if (gestureStart) { + const reversed = dir !== prev.dir; + let gestureStart; + if (typeof prev.lastAt !== 'number' || touchId !== prevTouchId) { + gestureStart = true; + } else if (touchId !== null) { + gestureStart = false; // the same contact, however long it has been + } else { + const quiet = now - prev.lastAt; + gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); + } + if (gestureStart || reversed) { + // A pre-paid debt belongs to its gesture and its direction. if (prepaid) pending = 0; prepaid = false; + } + if (gestureStart) { const size = Math.abs(lines); if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { - state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true }, + state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN, }; } } const step = pageKeysForTravel(pending, lines, rows); - return { state: { pending: step.pending, lastAt: now, dir, prepaid }, keys: step.keys }; + return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys }; } const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; @@ -366,6 +392,7 @@ PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, PAGE_KEY_IMMEDIATE_MAX_ROWS, + PAGE_KEY_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -1119,6 +1146,10 @@ Object.assign(CodemanApp.prototype, { let lastTime = 0; let scrollFrame = null; let isTouching = false; + // One id per touch contact (touchstart), carried by its touchmoves AND its + // momentum frames, so the page-key fallback treats a whole contact as one + // gesture however slowly the finger moves (pageKeysForGesture). + let touchContactId = 0; const scrollLoop = (timestamp) => { const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; @@ -1132,7 +1163,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 if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { + } else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); } @@ -1205,6 +1236,7 @@ Object.assign(CodemanApp.prototype, { velocity = 0; pixelAccum = 0; isTouching = true; + touchContactId++; didScroll = false; tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); // Classifying scans the whole viewport with translateToString, and @@ -1309,7 +1341,7 @@ Object.assign(CodemanApp.prototype, { if (this._shouldForwardWheelToApp({ shiftKey: false })) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); - } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { + } else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) { this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); @@ -5840,9 +5872,12 @@ Object.assign(CodemanApp.prototype, { * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per - * `perPage` rows. A pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a - * tab switch starts a new gesture. A larger opening event is a wheel notch and - * accumulates as before, so slow notches still page once per `perPage` rows. + * `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact + * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is + * still owed), a reversal after the gap or a tab switch starts a new gesture; + * one contact is never split by time, and a quick reversal never pre-pays. A + * larger opening event is a wheel notch and accumulates as before, so slow + * notches still page once per `perPage` rows. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; @@ -5868,7 +5903,10 @@ Object.assign(CodemanApp.prototype, { if (!this._localScrollbackIsHollow()) return false; // An open dialog in THIS pane's session (the active one): a page key would // move its selector. Consumed, so the buffer does not scroll under it either. - if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true; + if (this.tabAlerts?.get(this.activeSessionId) === 'action') { + this._logScrollRouting('page-keys-dialog-open'); + return true; + } // Leftover travel and the gesture in progress belong to the tab they were made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 4fb5951b1..2cc246de7 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -202,12 +202,14 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 clock.now = () => now; app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp - app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal: one PageDown at once - now += 1_000; - app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: another at once + app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page + now += 200; + app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once + now += 1_100; + app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[6~\x1b[6~' }]); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[5~\x1b[6~' }]); }); it('lets wheel notches accumulate instead of paging a full screen on each one', () => { @@ -422,7 +424,145 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // 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); + expect( + source.match( + /else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false, touchId: touchContactId \}, lines\)\)/g + ) + ).toHaveLength(2); + // ...and every contact gets its own id at touchstart, so a contact is one gesture. + expect(source).toContain('touchContactId++;'); + }); +}); + +describe('page-key gestures across slow touch drags, slow wheel ticks and jitter (#527 review)', () => { + const UP = '\x1b[5~'; + const DOWN = '\x1b[6~'; + + /** A hollow 38-row pane (19 rows a page) on a hand-moved clock, with every key sent. */ + function pane(mode = 'codex') { + const h = hollowApp({ mode, rows: 38 }); + let now = 10_000; + h.clock.now = () => now; + const keys = () => { + h.app._flushWheelSgrQueue(); + return h.sent.map((s) => s.data).join(''); + }; + return { ...h, keys, advance: (ms: number) => (now += ms) }; + } + const count = (keys: string, key: string) => keys.split(key).length - 1; + + it('keeps one touch contact one gesture, however slowly the finger moves', () => { + const p = pane(); + // touchmove calls once per whole cell, so a slow drag arrives as single lines + // 170 ms apart, each past the 150 ms gesture gap. It sent one PageUp per line. + for (let i = 0; i < 12; i++) { + expect(p.app._maybePageCliTranscript({ shiftKey: false, touchId: 1 }, -1)).toBe(true); + p.advance(170); + } + expect(p.keys()).toBe(UP); + + // A new contact is a new gesture: its first line pages at once. + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 2 }, -1); + expect(p.keys()).toBe(UP + UP); + }); + + it('pages a slow 40-line drag twice, and keeps momentum in the same gesture', () => { + const p = pane(); + for (let i = 0; i < 40; i++) { + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1); + p.advance(170); + } + // 1 at once, then 18 owed back, then 19 more: two pages by line 38. + expect(count(p.keys(), UP)).toBe(2); + + // Momentum frames after the finger lifts carry the same contact: they + // continue the drag's travel instead of opening a gesture of their own. + for (let i = 0; i < 10; i++) { + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1); + p.advance(16); + } + expect(count(p.keys(), UP)).toBe(2); + }); + + it('never splits a contact on a pause, but does not carry its debt into the next contact', () => { + const p = pane(); + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1); // pages, owes 18 + p.advance(5_000); // the finger rests, still down + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1); + expect(p.keys()).toBe(UP); + p.advance(10); + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 4 }, -1); // a new contact pages at once + expect(p.keys()).toBe(UP + UP); + }); + + it('pages slow small wheel ticks once per half screen, not once per tick', () => { + // Five 40 px ticks 250 ms apart (1.6 rows each, under the 2-row trackpad + // size) used to page once per tick. The first one still pages at once. + const p = pane(); + for (let i = 0; i < 5; i++) { + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40, deltaMode: 0 }, -1.6); + p.advance(250); + } + expect(p.keys()).toBe(UP); + + // Firefox reports a notch as 1 line (deltaMode 1): the same. + const f = pane(); + for (let i = 0; i < 5; i++) { + f.app._maybePageCliTranscript({ shiftKey: false, deltaY: -1, deltaMode: 1 }, -1); + f.advance(250); + } + expect(f.keys()).toBe(UP); + }); + + it('lets a pre-paid debt lapse after a second of quiet, so the next tick pages at once again', () => { + const p = pane(); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); + p.advance(900); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // inside the second: owed + expect(p.keys()).toBe(UP); + p.advance(1_100); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // debt lapsed: pages + expect(p.keys()).toBe(UP + UP); + }); + + it('does not page back and forth on a +/-3 px trackpad wobble', () => { + const p = pane(); + for (let i = 0; i < 20; i++) { + const dy = i % 2 ? 3 : -3; + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25); + p.advance(16); + } + // The opening event is a fresh gesture and pages; no reversal inside the + // gap pre-pays, so the wobble sends nothing after it. + expect(p.keys()).toBe(UP); + }); + + it('still pages a deliberate reversal at once after a pause', () => { + const p = pane(); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); + p.advance(200); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); + expect(p.keys()).toBe(UP + DOWN); + }); + + it('does not let a reversal inherit the other direction’s debt', () => { + const p = pane(); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); // UP, owes 18 upward + p.advance(16); + // Downward travel just short of a page: the upward debt must not count toward it. + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18); + expect(p.keys()).toBe(UP); + p.advance(16); + p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); + expect(p.keys()).toBe(UP + DOWN); + }); + + it('logs a wheel the dialog check consumed', () => { + const p = pane(); + p.app.tabAlerts = new Map([['sess-1', 'action']]); + expect(p.app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true); + expect(p.keys()).toBe(''); + expect(p.logs.some((line) => line.includes('[scroll] sess-1 → page-keys-dialog-open'))).toBe(true); }); }); @@ -464,24 +604,40 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys).join('')).toBe(UP); }); - it('drops a pre-paid debt when its gesture ends', () => { - // A flick pre-pays a page; a second flick after a pause pages at once again - // instead of first paying off the first one's debt. + it('keeps a same-direction pre-paid debt for PAGE_KEY_DEBT_MS, then drops it with its gesture', () => { + const { PAGE_KEY_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + expect(PAGE_KEY_DEBT_MS).toBe(1000); + // A tick 500 ms later, past the 150 ms gap, pays the debt back instead of + // pre-paying a second page; a flick after a second of quiet pages at once. const steps = run([ [-1, 0], [-1, 500], + [-1, 1600], ]); - expect(steps.map((s) => s.keys)).toEqual([UP, UP]); + expect(steps.map((s) => s.keys)).toEqual([UP, '', UP]); }); - it('starts a new gesture on a direction change, and ignores sub-row jitter', () => { + it('pre-pays a reversal only after the gap, and ignores sub-row jitter', () => { const steps = run([ [-1, 0], - [1, 10], - [0.05, 20], - [-0.05, 1000], + [1, 10], // reversal inside the gap: accumulates, no page + [-1, 20], // and back: still nothing + [1, 300], // a reversal after the gap is a new gesture: pages at once + [0.05, 310], + [-0.05, 1500], + ]); + expect(steps.map((s) => s.keys)).toEqual([UP, '', '', DOWN, '', '']); + }); + + it('treats one touch contact as one gesture, and a new contact as a new one', () => { + const steps = run([ + [-1, 0, { touchId: 1 }], + [-1, 5_000, { touchId: 1 }], // same contact, any pause: owed + [1, 5_010, { touchId: 1 }], // a reversal within the contact never pre-pays + [-1, 5_020, { touchId: 2 }], // a new contact pages at once + [-1, 5_030], // a wheel event is not that contact: a new gesture ]); - expect(steps.map((s) => s.keys)).toEqual([UP, DOWN, '', '']); + expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP, UP]); }); it('sends nothing for a pinch or a mostly horizontal swipe, and leaves the state as it was', () => { @@ -525,7 +681,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { [-1, {}], [-10, {}], [-30, {}], - [1, {}], + [20, {}], // a reversal inside the gap pages by travel, not at once [-5, { ctrlKey: true }], [-5, { deltaX: 400, deltaY: -125 }], [-1000, {}], diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index d01c2b477..9629e926a 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -588,7 +588,7 @@ describe('page-key travel, cap and coalescing', () => { }); }); -describe('gesture handling is the primary pane\'s, through the shared pure helper', () => { +describe("gesture handling is the primary pane's, through the shared pure helper", () => { beforeEach(() => { FakeFit.proposed = { cols: 80, rows: 36 }; // half a screen: 18 rows a page }); @@ -710,6 +710,54 @@ describe('gesture handling is the primary pane\'s, through the shared pure helpe expect(tileBytes).toContain(PAGE_DOWN); expect(tileBytes).toBe(sent.join('')); }); + + it('pages slow small ticks and a back-and-forth wobble once, exactly as the primary pane does', async () => { + const advance = manualClock(); + // Five 40 px ticks 250 ms apart (each under the 2-row trackpad size), a pause + // longer than the pre-paid debt's second, then a +/-3 px wobble at 16 ms. + const steps: Array<[number, number]> = [ + ...Array.from({ length: 5 }, () => [-40, 250] as [number, number]), + [0, 1_200], + ...Array.from({ length: 12 }, (_, i) => [i % 2 ? 3 : -3, 16] as [number, number]), + ]; + const events = () => steps.filter(([px]) => px).map(([px]) => wheel(px)); + const gaps = steps.reduce<number[]>((acc, [px, gap]) => { + if (px) acc.push(gap); + else acc[acc.length - 1] += gap; + return acc; + }, []); + + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); + events().forEach((ev, i) => { + mount.fire('wheel', ev); + advance(gaps[i]); + }); + const tileBytes = flushed(ws) + .map((f) => f.d) + .join(''); + // One page for the slow ticks, one for the wobble's opening event, nothing more. + expect(tileBytes).toBe(PAGE_UP + PAGE_UP); + + const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { + terminal: unknown; + _maybePageCliTranscript(ev: unknown, lines: number): boolean; + _wheelScrollLinesFloat(ev: unknown): number; + _flushWheelSgrQueue(): void; + }; + const sent: string[] = []; + primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + primary.terminal = { + rows: 36, + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } }, + }; + events().forEach((ev, i) => { + primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev)); + advance(gaps[i]); + }); + primary._flushWheelSgrQueue(); + expect(sent.join('')).toBe(tileBytes); + }); }); describe("the dialog check reads the TILE's session, never the active tab's", () => { diff --git a/test/terminal-touch-paging.browser.test.ts b/test/terminal-touch-paging.browser.test.ts new file mode 100644 index 000000000..4dcaaba00 --- /dev/null +++ b/test/terminal-touch-paging.browser.test.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Real Chromium coverage for the page-key fallback on the TOUCH + * path of the primary pane (#527 review, #508): the touch listeners that + * `initTerminal()` installs on `#terminalContainer`, not a call into the pure + * helper. + * + * A slow drag reaches `_maybePageCliTranscript` once per whole cell of finger + * travel, so its calls are further apart than the 150 ms gesture gap. Each call + * used to start a new gesture and pre-pay a page, so twelve lines of slow + * finger travel sent twelve PageUps. The touch paths now tag every call with + * the contact's id (one per touchstart, carried by its momentum frames), and a + * contact is one gesture. + * + * What runs for real: the shipped app (page served by a WebServer on port 0), + * its xterm, the touch listeners, the momentum loop on requestAnimationFrame, + * the gates in terminal-ui.js and the 40 ms key coalescer. What is stubbed: + * the active session (a Codex session the page knows about but no PTY) and + * `_sendInputEphemeral`, which records the keys instead of sending them. The + * touch events are DOM events carrying `touches`, dispatched on the container + * with real time between them. + * + * Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PROBE_ID = 'touch-paging-probe'; +const UP = '\x1b[5~'; + +describe('touch paging of a hollow Codex pane in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, hasTouch: true }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** Dispatch one touch event of `type` at (x, y) on the terminal container. */ + const touch = (type: string, x: number, y: number) => + page.evaluate( + ([t, cx, cy]) => { + const container = document.getElementById('terminalContainer')!; + const ev = new Event(t as string, { bubbles: true, cancelable: true }); + const point = { clientX: cx, clientY: cy, target: container }; + Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] }); + Object.defineProperty(ev, 'changedTouches', { value: [point] }); + container.dispatchEvent(ev); + }, + [type, x, y] as const + ); + + const probe = () => + page.evaluate(() => { + const p = (window as any).__touchProbe; + p.app._flushWheelSgrQueue(); + return { keys: p.sent.join(''), calls: p.calls.slice(), rows: p.app.terminal.rows }; + }); + + it('pages a slow one-line-at-a-time drag once, keeps momentum in it, and pages a new contact at once', async () => { + const cell = await page.evaluate((id) => { + const w = window as any; + const app = w.app; + app.sessions.set(id, { id, name: id, mode: 'codex', status: 'idle' }); + app.activeSessionId = id; + app.tabAlerts?.delete?.(id); + const sent: string[] = []; + app._sendInputEphemeral = (_sessionId: string, data: string) => sent.push(data); + const calls: Array<{ touchId: unknown; lines: number }> = []; + const real = app._maybePageCliTranscript; + app._maybePageCliTranscript = function (ev: { touchId?: unknown }, lines: number) { + calls.push({ touchId: ev?.touchId, lines }); + return real.call(this, ev, lines); + }; + w.__touchProbe = { app, sent, calls }; + return app.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; + }, PROBE_ID); + + // The preconditions the fallback needs, so a pass is not vacuous. + const pre = await page.evaluate(() => { + const app = (window as any).app; + return { + hollow: app._localScrollbackIsHollow(), + forwards: app._shouldForwardWheelToApp({ shiftKey: false }), + modes: (window as any).__codemanTranscriptPageKeys, + }; + }); + expect(pre.modes).toContain('codex'); + expect(pre.hollow).toBe(true); + expect(pre.forwards).toBe(false); + + // One contact, finger moving DOWN one cell every 170 ms (scrolls back up). + const x = 300; + const y0 = 200; + await touch('touchstart', x, y0); + for (let i = 1; i <= 12; i++) { + await page.waitForTimeout(170); + await touch('touchmove', x, y0 + Math.ceil(cell) * i); + } + await page.waitForTimeout(60); // the 40 ms coalescer + const drag = await probe(); + expect(drag.calls.length).toBeGreaterThanOrEqual(12); + expect(drag.calls.every((c) => c.lines < 0)).toBe(true); + expect(drag.keys).toBe(UP); // was one PageUp per line + const contact = drag.calls[0].touchId; + expect(typeof contact).toBe('number'); + expect(drag.calls.every((c) => c.touchId === contact)).toBe(true); + + // Lift: the momentum loop keeps calling with the SAME contact id. + await touch('touchend', x, y0 + Math.ceil(cell) * 12); + await page.waitForTimeout(800); + const afterMomentum = await probe(); + const momentum = afterMomentum.calls.slice(drag.calls.length); + expect(momentum.length).toBeGreaterThan(0); + expect(momentum.every((c) => c.touchId === contact)).toBe(true); + // The contact's whole travel (drag + momentum) is short of the two half + // screens a second page needs once the first was pre-paid, so it is still + // one PageUp. A momentum frame that opened its own gesture would have paged. + const travelled = afterMomentum.calls.reduce((sum, c) => sum + Math.abs(c.lines), 0); + expect(travelled).toBeGreaterThan(12); + expect(travelled).toBeLessThan(2 * Math.round(afterMomentum.rows / 2)); + expect(afterMomentum.keys).toBe(UP); + + // A new contact is a new gesture: its first line pages at once. + await touch('touchstart', x, y0); + await page.waitForTimeout(20); + await touch('touchmove', x, y0 + Math.ceil(cell)); + await page.waitForTimeout(60); + const second = await probe(); + const fresh = second.calls.slice(afterMomentum.calls.length); + expect(fresh.length).toBe(1); + expect(fresh[0].touchId).not.toBe(contact); + expect(second.keys).toBe(UP + UP); + await touch('touchend', x, y0 + Math.ceil(cell)); + }, 60000); +}); From d6c87bbd27bf80ed05850b97a0eaacde15c29c1c Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Sat, 10 Oct 2026 22:52:15 -0400 Subject: [PATCH 5/6] fix(terminal): end the page-key debt lifetime once travel repays it pageKeysForGesture kept `prepaid` set after the travel had fully repaid a pre-paid page, so the one-second debt lifetime still applied: on a 38-row pane, 19 lines of -1 at 32 ms left { pending: 0, prepaid: true } and a -1 300 ms later sent nothing. `prepaid` now clears as soon as the pending travel reaches or crosses zero, so the leftover is plain travel and the 150 ms gap applies again. Touch contacts are unaffected (never split by time). The mixed parity step's reversal grows from 20 to 25 rows because the -5 rows left after repayment are now plain travel and cancel against it. --- docs/architecture-invariants.md | 2 +- src/web/public/terminal-ui.js | 4 +++ test/terminal-scroll-routing.test.ts | 52 +++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 8d205790d..eab833d3f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` 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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 758e671d1..6eacec6e5 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -230,6 +230,10 @@ } } const step = pageKeysForTravel(pending, lines, rows); + // A debt carries the sign opposite to its direction; once the travel reaches + // or crosses zero it is repaid, and what is left is plain travel (so the + // one-second debt lifetime no longer applies). + if (prepaid && step.pending * dir >= 0) prepaid = false; return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys }; } diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 2cc246de7..0dbcaf086 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -557,6 +557,56 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter expect(p.keys()).toBe(UP + DOWN); }); + it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => { + // 38 rows: 19 a page. The opening -1 pre-pays and owes 18; 18 more lines at + // 32 ms repay it EXACTLY. Nothing is owed any more, so the 150 ms gap applies + // again and a -1 300 ms later is a new gesture that pages at once. + const exact = pane(); + for (let i = 0; i < 19; i++) { + exact.app._maybePageCliTranscript({ shiftKey: false }, -1); + exact.advance(32); + } + expect(exact.keys()).toBe(UP); + expect(exact.app._pageKeyGesture).toMatchObject({ pending: 0, prepaid: false }); + exact.advance(300 - 32); + exact.app._maybePageCliTranscript({ shiftKey: false }, -1); + expect(exact.keys()).toBe(UP + UP); + + // Overpaid: 19 lines after the opening one leave 1 row of plain travel. + const over = pane(); + for (let i = 0; i < 20; i++) { + over.app._maybePageCliTranscript({ shiftKey: false }, -1); + over.advance(32); + } + expect(over.keys()).toBe(UP); + expect(over.app._pageKeyGesture).toMatchObject({ pending: -1, prepaid: false }); + over.advance(300 - 32); + over.app._maybePageCliTranscript({ shiftKey: false }, -1); + expect(over.keys()).toBe(UP + UP); + + // One line short of repaid: still owed, so 300 ms later is the same gesture. + const owed = pane(); + for (let i = 0; i < 18; i++) { + owed.app._maybePageCliTranscript({ shiftKey: false }, -1); + owed.advance(32); + } + expect(owed.app._pageKeyGesture).toMatchObject({ pending: 1, prepaid: true }); + owed.advance(300 - 32); + owed.app._maybePageCliTranscript({ shiftKey: false }, -1); + expect(owed.keys()).toBe(UP); + }); + + it('keeps a touch contact one gesture after its debt is repaid', () => { + const p = pane(); + for (let i = 0; i < 19; i++) { + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 5 }, -1); + p.advance(32); + } + p.advance(2_000); // the finger rests, still down + p.app._maybePageCliTranscript({ shiftKey: false, touchId: 5 }, -1); + expect(p.keys()).toBe(UP); // accumulates; no fresh pre-pay inside the contact + }); + it('logs a wheel the dialog check consumed', () => { const p = pane(); p.app.tabAlerts = new Map([['sess-1', 'action']]); @@ -681,7 +731,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { [-1, {}], [-10, {}], [-30, {}], - [20, {}], // a reversal inside the gap pages by travel, not at once + [25, {}], // a reversal inside the gap pages by travel (net of the -5 plain travel left), not at once [-5, { ctrlKey: true }], [-5, { deltaX: 400, deltaY: -125 }], [-1000, {}], From dd7774f9adeb539a6033f225d47314d5a8012b26 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Sat, 10 Oct 2026 22:59:54 -0400 Subject: [PATCH 6/6] test(mobile): give the touch-scrollback case real local history "keeps terminal touch drag available for scrollback with the visible textarea enabled" built a Codex session on a blank terminal. With Codex in transcriptPageKeys that pane is hollow, so the drag is correctly consumed by the page-key fallback and never reaches scrollLines; the fixture no longer described local scrollback. The case now writes 200 lines into xterm (awaiting the write), turns the visible CJK textarea on explicitly, and asserts the scenario before the drag: normal buffer, baseY > 0, viewport at the bottom, not hollow, wheel forwarding off, cjk-input-visible set. A real touch drag back into history must then call scrollLines with negative lines, move the viewport up, and send nothing to the CLI. Hollow Codex paging stays covered by terminal-touch-paging.browser.test.ts. --- test/mobile/keyboard.test.ts | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 91cf4000d..a6ddb7652 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -1254,7 +1254,11 @@ describe('Virtual Keyboard', () => { }); it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => { - const calls = await page.evaluate(async () => { + // A Codex pane with REAL local scrollback (an older inline Codex, or any pane + // whose output scrolled lines into xterm's normal buffer). A blank pane is + // hollow and correctly pages the CLI instead (terminal-touch-paging.browser + // test), so the history is written first and checked before the drag. + const result = await page.evaluate(async () => { app.activeSessionId = 'mobile-touch-scroll-test'; app.sessions.set('mobile-touch-scroll-test', { id: 'mobile-touch-scroll-test', @@ -1262,22 +1266,42 @@ describe('Virtual Keyboard', () => { status: 'running', }); app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = true; + app.saveAppSettingsToStorage(settings); + app._serverCjkOverride = true; app._updateCjkInputState(); + const history = Array.from({ length: 200 }, (_, i) => `history line ${i}`).join('\r\n') + '\r\n'; + await new Promise<void>((resolve) => app.terminal.write(history, resolve)); + app.terminal.scrollToBottom(); + const buffer = app.terminal.buffer.active; + const before = { + type: buffer.type, + baseY: buffer.baseY, + viewportY: buffer.viewportY, + hollow: app._localScrollbackIsHollow(), + forwards: app._shouldForwardWheelToApp({ shiftKey: false }), + cjkVisible: document.body.classList.contains('cjk-input-visible'), + }; + const originalScrollLines = app.terminal.scrollLines.bind(app.terminal); const scrollCalls: number[] = []; app.terminal.scrollLines = (lines: number) => { scrollCalls.push(lines); return originalScrollLines(lines); }; + const sentToCli: string[] = []; + app._sendInputEphemeral = (_id: string, data: string) => sentToCli.push(data); const target = document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); - if (!target) return scrollCalls; + if (!target) return { before, scrollCalls, sentToCli, viewportAfter: -1 }; const rect = target.getBoundingClientRect(); const x = rect.left + rect.width / 2; - const startY = rect.top + Math.min(180, rect.height - 20); - const endY = startY - 120; + // The finger moves DOWN the screen, so the content follows it back into history. + const startY = rect.top + 20; + const endY = startY + 120; function createTouch(y: number) { return new Touch({ @@ -1315,11 +1339,23 @@ describe('Virtual Keyboard', () => { }) ); await new Promise((resolve) => setTimeout(resolve, 50)); - return scrollCalls; + app._flushWheelSgrQueue?.(); + return { before, scrollCalls, sentToCli, viewportAfter: app.terminal.buffer.active.viewportY }; }); - expect(calls.length).toBeGreaterThan(0); - expect(calls.some((lines) => lines !== 0)).toBe(true); + // The scenario is real local scrollback with the visible textarea on. + expect(result.before.cjkVisible).toBe(true); + expect(result.before.type).toBe('normal'); + expect(result.before.baseY).toBeGreaterThan(0); + expect(result.before.viewportY).toBe(result.before.baseY); + expect(result.before.hollow).toBe(false); + expect(result.before.forwards).toBe(false); + + // The drag scrolled xterm's own history upward, and sent nothing to the CLI. + expect(result.scrollCalls.length).toBeGreaterThan(0); + expect(result.scrollCalls.some((lines) => lines < 0)).toBe(true); + expect(result.viewportAfter).toBeLessThan(result.before.viewportY); + expect(result.sentToCli).toEqual([]); }); it('keeps typed phone text in the terminal local echo path', async () => {