diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 219adcd1..33775cc5 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 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 88e74313..cb31df64 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 222ebc65..a216d296 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 9d939a90..96820432 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; + this._pageKeyGesture = null; } - 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; + 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 3e61538e..156d315b 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 d7fe373b..5553b1fa 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 ea7ffa33..4fb5951b 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 1ad5459e..c93e29ca 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