Merge pull request #527: fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event

Conflict resolved in terminal-tile.js's @dependency header: #601's
SHELL_LOAD_BASE_QUERY/SHELL_LOAD_QUERY and #527's pageKeysForGesture and
app.tabAlerts are all listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:06:24 +02:00
commit 0e11012470
16 files changed
+1192 -96

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts', 'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.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/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts', 'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts', 'test/webhook-settings.browser.test.ts',
+1 -1
View File
@@ -229,7 +229,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`. **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 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, Line truncated
**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. **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.
+6 -2
View File
@@ -228,8 +228,12 @@ Worth knowing:
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. `~/.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 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 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 (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
too. Other CLIs scroll locally. 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. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies. `Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane - **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+4
View File
@@ -297,6 +297,10 @@ const capabilitiesSchema = z
wheelForward: z wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(), .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']), keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(), privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']), startMode: z.enum(['interactive', 'shell']),
+14
View File
@@ -285,6 +285,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version // 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. // AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, 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', keyboardAccessory: 'agent',
privilegedCommandGate: false, privilegedCommandGate: false,
startMode: 'interactive', startMode: 'interactive',
@@ -513,6 +517,11 @@ const OPENCODE: CliEntry = {
...AGENTS_DIR_SKILL, ...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse', altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, 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 // 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 // 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 // composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
@@ -724,6 +733,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full', altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward 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, maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so // 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. // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
+14
View File
@@ -515,6 +515,20 @@ export interface CliCapabilities {
}; };
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; 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'; keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean; privilegedCommandGate: boolean;
+31 -21
View File
@@ -25,13 +25,15 @@
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op. * primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback, * on the alternate screen, Claude's repaint mode) leaves the xterm no
* so the wheel pages the CLI's own transcript with PageUp/PageDown * scrollback, so the wheel pages the CLI's own transcript with
* (_maybePageCliTranscript) through the primary pane's gates, plus an * PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
* overflow-row discount for this pane's capture-before-resize load * and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
* (_localRows), and only while the viewport is on the live screen (a * an overflow-row discount for this pane's capture-before-resize load
* wheel-down from those overflow rows is xterm's, and brings it home). * (_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 desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes * the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent * whose mouse DECSETs the server strips (_installClickListener), sent
@@ -49,7 +51,7 @@
* *
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE) * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick) * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @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 * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/ */
@@ -204,9 +206,10 @@
// flag, app._linkHovered, belongs to its terminal alone). // flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false; this._linkHovered = false;
this._onFocusIn = null; this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a // Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
// whole page, carried to the next wheel event. // (travel short of a whole page, its direction and last event time), the
this._pageKeyPending = 0; // state CodemanTerminalInput.pageKeysForGesture hands back.
this._pageKeyGesture = null;
// Shift+wheel travel short of a whole line, carried to the next wheel // Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift). // event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0; this._shiftScrollPending = 0;
@@ -1154,17 +1157,20 @@
// Hollow-buffer paging, the twin of the primary pane's // Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode) // draws in place (on the alternate screen, or Claude's repaint mode) leaves
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the // this xterm no scrollback, so a wheel scrolled nothing; instead the travel
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is // pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// the primary pane's own, asked for THIS pane (its terminal, its session, // primary pane's own, asked for THIS pane (its terminal, its session, never
// never the active one), so the CLI rules stay in terminal-ui.js and this // the active one), so the CLI rules stay in terminal-ui.js and this file
// file names no CLI. Returns true when the wheel was consumed here. // 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) { _maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app; const app = global.app;
const input = global.CodemanTerminalInput; 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 // xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane. // it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode; const tracking = this.terminal.modes?.mouseTrackingMode;
@@ -1188,8 +1194,12 @@
if (!app._terminalViewportAtBottom?.(this.terminal)) return false; if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows); const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false; if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); // An open dialog (a pending permission prompt or question) in THIS tile's
this._pageKeyPending = step.pending; // 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); if (step.keys) this._queueScrollBytes(step.keys);
return true; return true;
} }
@@ -1543,7 +1553,7 @@
clearTimeout(this._scrollFlushTimer); clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null; this._scrollFlushTimer = null;
this._scrollBytes = ''; this._scrollBytes = '';
this._pageKeyPending = 0; this._pageKeyGesture = null;
this._detachSocket(); this._detachSocket();
if (this._onFocusIn) { if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
+171 -33
View File
@@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE = const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g; /\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 // 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). // (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~'; const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~'; const KEY_PAGE_DOWN = '\x1b[6~';
@@ -92,6 +92,20 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick // 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. // cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3; 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;
// 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 // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the // body of the primary pane's _wheelScrollLinesFloat (see its comment for the
@@ -133,13 +147,17 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
} }
// 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 // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel // `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 // left over plus the keys to send ('' below one page). The plain accumulation
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps // step of pageKeysForGesture below.
// its own pending travel) pages identically.
function pageKeysForTravel(pending, lines, rows) { 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 total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage); const pages = Math.trunc(total / perPage);
const keys = pages const keys = pages
@@ -148,6 +166,77 @@
return { pending: total - pages * perPage, keys }; 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);
// - 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.
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;
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 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, touchId },
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
};
}
}
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 };
}
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and // 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 // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
@@ -303,6 +392,11 @@
SGR_WHEEL_MAX_TICKS, SGR_WHEEL_MAX_TICKS,
sgrWheelReports, sgrWheelReports,
pageKeysForTravel, pageKeysForTravel,
pageKeysForGesture,
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, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP, MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -1056,6 +1150,10 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0; let lastTime = 0;
let scrollFrame = null; let scrollFrame = null;
let isTouching = false; 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 scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
@@ -1069,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
// Flick momentum keeps feeding the CLI's transcript from the last // Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports. // touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines); 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.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines); this._maybeLoadMoreHistoryOnScroll(lines);
} }
@@ -1142,6 +1240,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0; velocity = 0;
pixelAccum = 0; pixelAccum = 0;
isTouching = true; isTouching = true;
touchContactId++;
didScroll = false; didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
// Classifying scans the whole viewport with translateToString, and // Classifying scans the whole viewport with translateToString, and
@@ -1246,7 +1345,7 @@ Object.assign(CodemanApp.prototype, {
if (this._shouldForwardWheelToApp({ shiftKey: false })) { if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr'); this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines); 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._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines); this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines); this.terminal.scrollLines(lines);
@@ -5730,35 +5829,38 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* True when this session's LOCAL scrollback is structurally empty: a pane whose * True when this session's LOCAL scrollback is structurally empty and its CLI
* TUI repaints one full screen in place, so tmux keeps no history for it * can page its own transcript: xterm's normal buffer never grows past one
* (`history_size≈0`) and xterm's normal buffer never grows past one screen * screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
* (`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).
* is routed — the "wheel does nothing at all" half of the #205 retest.
* *
* Two shapes, measured separately: * Shapes measured so far:
* - `claude` in repaint mode (the original, #205 round 2), and * - `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 * - `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 * `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 * terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture * 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 * byte-identical — so paging is the only gesture that reaches it;
* this the wheel was silently dead in every opencode tab. * - `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 * ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour * list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* is unverified (docs/scrollback-fix-plan.md). * literal here. A missing list means no mode pages.
* *
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which * `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays * calls this with its own session and terminal, so the mode rule stays here
* here alone. `localRows` replaces `baseY` as the history row count: a tile * alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen * discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's. * (TerminalTile._localRows). Every field left out means the primary pane's.
*/ */
_localScrollbackIsHollow(target = {}) { _localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude'; 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; const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false; if (!buf || buf.type === 'alternate') return false;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY; const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
@@ -5775,34 +5877,70 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the * 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 * 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 * 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 * 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. * 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 * Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* session with real local scrollback is never touched. Shift is excluded on * `baseY === 0`) plus a false forwarding gate, so a session with real local
* purpose: it is the explicit "give me local scrollback" gesture and must keep * scrollback is never touched. Shift is excluded on purpose: it is the explicit
* that meaning. * "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 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|;
* 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 * @returns true when the gesture was consumed here (the caller must not also
* scroll locally). * 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 * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the * through the same gates and the same pageKeysForGesture, with its own state
* two in step. The tile adds one gate this pane cannot need, viewport at the * 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 * 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. * tile is hollow with its own discounted rows still above the screen.
*/ */
_maybePageCliTranscript(ev, lines) { _maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false; if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false; if (!this._localScrollbackIsHollow()) return false;
// 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') {
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) { if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId; this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0; this._pageKeyGesture = null;
} }
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyPending = step.pending; this._pageKeyGesture,
ev,
lines,
this.terminal?.rows,
performance.now()
);
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys); if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys'); this._logScrollRouting('page-keys');
return true; return true;
+13
View File
@@ -1783,6 +1783,19 @@ export class WebServer extends EventEmitter {
'</head>', '</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</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') { if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`); html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) { if (settings.gestureControlEnabled === true) {
+20
View File
@@ -414,4 +414,24 @@ describe('capability shapes', () => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format'; (e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing'); }, '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), 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', 'opencode']);
});
}); });
+43 -7
View File
@@ -1254,7 +1254,11 @@ describe('Virtual Keyboard', () => {
}); });
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => { 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.activeSessionId = 'mobile-touch-scroll-test';
app.sessions.set('mobile-touch-scroll-test', { app.sessions.set('mobile-touch-scroll-test', {
id: 'mobile-touch-scroll-test', id: 'mobile-touch-scroll-test',
@@ -1262,22 +1266,42 @@ describe('Virtual Keyboard', () => {
status: 'running', status: 'running',
}); });
app.hideWelcome(); app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._serverCjkOverride = true;
app._updateCjkInputState(); 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 originalScrollLines = app.terminal.scrollLines.bind(app.terminal);
const scrollCalls: number[] = []; const scrollCalls: number[] = [];
app.terminal.scrollLines = (lines: number) => { app.terminal.scrollLines = (lines: number) => {
scrollCalls.push(lines); scrollCalls.push(lines);
return originalScrollLines(lines); return originalScrollLines(lines);
}; };
const sentToCli: string[] = [];
app._sendInputEphemeral = (_id: string, data: string) => sentToCli.push(data);
const target = const target =
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); 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 rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2; const x = rect.left + rect.width / 2;
const startY = rect.top + Math.min(180, rect.height - 20); // The finger moves DOWN the screen, so the content follows it back into history.
const endY = startY - 120; const startY = rect.top + 20;
const endY = startY + 120;
function createTouch(y: number) { function createTouch(y: number) {
return new Touch({ return new Touch({
@@ -1315,11 +1339,23 @@ describe('Virtual Keyboard', () => {
}) })
); );
await new Promise((resolve) => setTimeout(resolve, 50)); 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); // The scenario is real local scrollback with the visible textarea on.
expect(calls.some((lines) => lines !== 0)).toBe(true); 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 () => { it('keeps typed phone text in the terminal local echo path', async () => {
+17
View File
@@ -159,6 +159,23 @@ describe('WebServer.renderIndexHtml', () => {
expect(map.shell).toBeUndefined(); 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).toContain('opencode');
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 () => { it('escapes the solo id so it cannot break out of the inline <script>', async () => {
const { server } = makeServer({}); const { server } = makeServer({});
const html = await render(server, 'a</script><b>'); const html = await render(server, 'a</script><b>');
+3 -1
View File
@@ -113,7 +113,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '') .replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
// Injected unconditionally as an object keyed by run mode, empty when no // Injected unconditionally as an object keyed by run mode, empty when no
// enabled CLI declares a gutter, so it needs stripping on every machine. // 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</title>')[0]; const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1]; const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true); expect(html.startsWith(beforeTitle)).toBe(true);
+492 -29
View File
@@ -19,18 +19,30 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import vm from 'node:vm'; import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest'; 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<string, unknown> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES }
) {
const CodemanApp = function CodemanApp(this: any) {}; const CodemanApp = function CodemanApp(this: any) {};
const logs: string[] = []; const logs: string[] = [];
// terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there.
const windowRef: Record<string, any> = {}; const windowRef: Record<string, any> = { ...windowGlobals };
// Mutable so a test can move time (gesture gaps); defaults to a frozen clock.
const clock = { now: () => 1_000 };
const context = vm.createContext({ const context = vm.createContext({
window: windowRef, window: windowRef,
CodemanApp, CodemanApp,
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) }, console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
_crashDiag: { log: vi.fn() }, _crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 }, performance: clock,
requestAnimationFrame: (_fn: () => void) => 1, requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {}, Blob: function Blob() {},
@@ -45,12 +57,12 @@ function loadTerminalUiHarness() {
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' }); 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. */ /** 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 } = {}) { 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 }> = []; const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1'; app.activeSessionId = 'sess-1';
app.sessions = new Map([ app.sessions = new Map([
@@ -70,7 +82,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe
modes: { mouseTrackingMode: 'none' }, modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, 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)', () => { describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
@@ -157,16 +169,129 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); 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(); const { app, sent } = hollowApp();
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… // A trackpad flick opens with a small delta and stays far short of half a
app._flushWheelSgrQueue(); // screen (18 rows here). It used to send nothing at all, so a session that
expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet // always lands here looked dead.
expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page
app._flushWheelSgrQueue(); app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); 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).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 }, -1); // first event: one PageUp
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[5~\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("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();
expect(app._maybePageCliTranscript({ shiftKey: false }, -0.05)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
}); });
it('caps the keys one gesture batch can emit', () => { it('caps the keys one gesture batch can emit', () => {
@@ -207,12 +332,12 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.terminal.buffer.active.baseY = 0; app.terminal.buffer.active.baseY = 0;
// Modes with real terminal scrollback keep their existing behavior (shell/pi // Modes whose CLI does not declare transcriptPageKeys keep their existing
// own tmux history through the alt-screen strip; codex/gemini/antigravity/… // behavior (shell/pi own tmux history through the alt-screen strip;
// page-key behaviour is unverified — docs/scrollback-fix-plan.md). // gemini/antigravity/… page-key behaviour is unverified — docs/scrollback-fix-plan.md).
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); 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); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
@@ -223,6 +348,47 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); 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', () => { 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 // "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 // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
@@ -240,12 +406,17 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
it('drops travel accumulated on another tab', () => { it('drops travel accumulated on another tab', () => {
const { app, sent } = hollowApp(); 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.activeSessionId = 'sess-2';
app.sessions.set('sess-2', { mode: 'claude' }); 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(); 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', () => { it('is reachable from both the wheel and the touch paths', () => {
@@ -253,14 +424,292 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
// Wheel: after the forwarding gate, before the local smooth scroll. // Wheel: after the forwarding gate, before the local smooth scroll.
expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;'); expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;');
// Touch: touchmove and the momentum loop both fall through to it. // 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('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']]);
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);
});
});
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('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]);
});
it('pre-pays a reversal only after the gap, and ignores sub-row jitter', () => {
const steps = run([
[-1, 0],
[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, '', '', UP, UP]);
});
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)', () => { describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => { it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp(); 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: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line
expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox) expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox)
@@ -274,17 +723,31 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
// The primary pane's own methods agree with them. // The primary pane's own methods agree with them.
const ev = { deltaY: -250, deltaMode: 2 }; const ev = { deltaY: -250, deltaMode: 2 };
expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36)); expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36));
let pending = 0; let state: unknown = null;
let expected = ''; let expected = '';
for (const lines of [-10, -10, 30, -1000, 7]) { // Mixed: a trackpad opening, a notch-sized opening, a reversal, a pinch and
const step = pageKeysForTravel(pending, lines, 36); // a sideways swipe, all under a frozen clock (one gesture per direction).
pending = step.pending; const steps: Array<[number, Record<string, unknown>]> = [
[-1, {}],
[-10, {}],
[-30, {}],
[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, {}],
[-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; expected += step.keys;
app._maybePageCliTranscript({ shiftKey: false }, lines); app._maybePageCliTranscript(event, lines);
} }
app._flushWheelSgrQueue(); app._flushWheelSgrQueue();
expect(app._pageKeyPending).toBe(pending); expect(app._pageKeyGesture).toEqual(state);
expect(expected).not.toBe(''); expect(expected).toContain('\x1b[5~');
expect(expected).toContain('\x1b[6~');
expect(sent).toEqual([{ id: 'sess-1', data: expected }]); expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
}); });
@@ -304,7 +767,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
tileBuffer.type = 'alternate'; tileBuffer.type = 'alternate';
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
tileBuffer.type = 'normal'; 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); expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
}); });
+215 -2
View File
@@ -37,20 +37,35 @@ import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import vm from 'node:vm'; import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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'; import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
const fetchMock = vi.fn(); 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() { function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = { const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(), addEventListener: vi.fn(),
removeEventListener: vi.fn(), removeEventListener: vi.fn(),
CodemanBase: { base: '' }, CodemanBase: { base: '' },
__codemanTranscriptPageKeys: PAGE_KEY_MODES,
}; };
const context = vm.createContext({ const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() }, console: { ...console, log: vi.fn(), debug: vi.fn() },
performance, performance: { now: () => clock.now() },
setInterval: vi.fn(), setInterval: vi.fn(),
clearInterval: vi.fn(), clearInterval: vi.fn(),
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code // Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
@@ -192,6 +207,7 @@ function flushed(ws: FakeSocket) {
} }
beforeEach(() => { beforeEach(() => {
clock.now = () => performance.now();
vi.useFakeTimers(); vi.useFakeTimers();
FakeFit.proposed = { cols: 80, rows: 24 }; FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = []; FakeSocket.instances = [];
@@ -233,7 +249,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); 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', 'leaves a %s tile to xterm even while the active session is opencode',
async (mode) => { async (mode) => {
const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other'); const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other');
@@ -572,6 +588,203 @@ 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(''));
});
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", () => {
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', () => { 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 () => { 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 serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured
+147
View File
@@ -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);
});