diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cd49d869f..d2077db2a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -221,7 +221,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (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 active tab's alert is `'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` (claude and codex), 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. ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5391c5acd..be78ece13 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 45c40c7a7..12870c37c 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', @@ -632,6 +636,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 147188c5a..a770ddbfd 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -450,6 +450,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 a03bc4d1c..d82113b70 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,16 @@ // 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; + // ...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; 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 @@ -229,6 +239,9 @@ KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, + 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, @@ -5485,15 +5498,22 @@ Object.assign(CodemanApp.prototype, { }, /** - * True when this session's LOCAL scrollback is structurally empty: a Claude - * pane in repaint mode, where tmux reports `history_size≈0` and every frame - * overwrites the last, so xterm's normal buffer never grows past one screen - * (`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. A Claude pane in repaint mode reports tmux + * `history_size≈0` and every frame overwrites the last; Codex draws on tmux's + * alternate screen, which the strip hides from xterm. Either way 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). + * + * ⚠️ 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. */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude') return false; + const pagingModes = window.__codemanTranscriptPageKeys; + if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; @@ -5504,18 +5524,39 @@ Object.assign(CodemanApp.prototype, { * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. * * The rescue path for every way `_shouldForwardWheelToApp` can come back false - * on a Claude session that has no local history to fall back on: the CLI + * on a session that has no local history to fall back on. For Claude: the CLI * version probe failed or is genuinely older than 2.1.187, the CLI's mouse * tracking flag is unset (the inline renderer, or fullscreen right after a * server restart), or the user turned on "Wheel scrolls local history" (which * pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the * setting's footgun). Before this, all of those produced a completely dead * gesture; the #205 reporter proved the keyboard route works by paging back - * through intact text with Fn+Up. + * through intact text with Fn+Up. For Codex the gate is ALWAYS false, because + * codex ignores SGR wheel reports (#227) and is never forwarded the wheel; + * measured on codex 0.157.1, PageUp moves its alternate-screen transcript + * while wheel reports and local scrolling do nothing. So this is not wheel + * forwarding: it sends plain keys, and only over a hollow buffer. + * + * Triple-guarded (`transcriptPageKeys` capability + gate false + `baseY === 0`), + * so a session with real local scrollback is never touched. Shift is excluded + * on purpose: it is the explicit "give me local scrollback" gesture and must + * keep that meaning. + * + * 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. A larger opening event is a wheel notch and + * accumulates as before, so slow notches still page once per `perPage` rows. * - * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with - * real local scrollback is never touched. Shift is excluded on purpose: it is - * the explicit "give me local scrollback" gesture and must keep that meaning. + * 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). @@ -5523,13 +5564,43 @@ 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)); + 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 pending = (this._pageKeyPending || 0) + lines; const pages = Math.trunc(pending / perPage); this._pageKeyPending = pending - pages * perPage; diff --git a/src/web/server.ts b/src/web/server.ts index 4442e5442..0ecf5fa9f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1766,6 +1766,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 7feb42cb8..3cc9a042d 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -363,4 +363,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 7f541443b..6ad067a79 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 2321d8fc0..82324dffe 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -19,16 +19,28 @@ 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[] = []; + // Mutable so a test can move time (gesture gaps); defaults to a frozen clock. + const clock = { now: () => 1_000 }; const context = vm.createContext({ - window: {}, + window: { ...windowGlobals }, 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() {}, @@ -43,12 +55,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 }; + return { app: new (CodemanApp as any)(), logs, clock }; } /** A Claude session whose local buffer holds exactly one screen (baseY 0). */ function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { - const { app, logs } = loadTerminalUiHarness(); + const { app, logs, clock } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ @@ -61,7 +73,7 @@ function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMou modes: { mouseTrackingMode: 'none' }, buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, }; - return { app, sent, logs }; + return { app, sent, logs, clock }; } describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { @@ -148,18 +160,119 @@ 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 } = hollowClaudeApp(); - expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… + // 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 }, -17); app._flushWheelSgrQueue(); - expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet + expect(sent).toHaveLength(1); - app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page + // …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 } = hollowClaudeApp(); + let now = 1_000; + 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 }, 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 } = hollowClaudeApp(); + 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 } = hollowClaudeApp(); + + // 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 } = hollowClaudeApp(); + + // 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 } = hollowClaudeApp(); + // '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 } = hollowClaudeApp(); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -0.05)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + it('caps the keys one gesture batch can emit', () => { const { app, sent } = hollowClaudeApp(); @@ -179,11 +292,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; - // Non-Claude modes keep their existing behavior (shell scrolls tmux history - // through the alt-screen strip; codex/gemini page keys are unverified). + // Modes whose CLI does not declare transcriptPageKeys keep their existing + // behavior (shell scrolls tmux history through the alt-screen strip; gemini's + // page keys are unmeasured). 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); // An alternate-screen pane belongs to xterm's own alt-scroll handling. @@ -192,6 +306,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 } = hollowClaudeApp(); + 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() {'), + 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 @@ -209,12 +364,17 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 it('drops travel accumulated on another tab', () => { const { app, sent } = hollowClaudeApp(); - 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', () => {