mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
commit
0e11012470
16 files changed
+1192
-96
No files matched your search
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user