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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-13 18:06:24 +02:00
16 changed files with 1192 additions and 96 deletions
+1
View File
@@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.browser.test.ts',
'test/terminal-touch-paging.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -228,8 +228,12 @@ Worth knowing:
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. OpenCode's wheel and swipes page its own conversation
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
too. Other CLIs scroll locally.
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
and leave the terminal no history of its own (an older Codex that draws inline keeps
its history in the terminal and scrolls locally). The first small movement of a
gesture pages at once, then one page per half screen of travel; one finger on the
screen is one gesture however slowly it moves. In a grid tile or the split view's
second pane the wheel pages too. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+4
View File
@@ -297,6 +297,10 @@ const capabilitiesSchema = z
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
+14
View File
@@ -285,6 +285,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -513,6 +517,11 @@ const OPENCODE: CliEntry = {
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
// wheel is turned into page keys or it does nothing at all (#555).
transcriptPageKeys: true,
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
@@ -724,6 +733,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
transcriptPageKeys: true,
maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
+14
View File
@@ -515,6 +515,20 @@ export interface CliCapabilities {
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
+31 -21
View File
@@ -25,13 +25,15 @@
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* 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
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
* (_maybePageCliTranscript) through the primary pane's gates, plus an
* overflow-row discount for this pane's capture-before-resize load
* (_localRows), and only while the viewport is on the live screen (a
* wheel-down from those overflow rows is xterm's, and brings it home).
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* on the alternate screen, Claude's repaint mode) leaves the xterm no
* scrollback, so the wheel pages the CLI's own transcript with
* PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
* and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
* an overflow-row discount for this pane's capture-before-resize load
* (_localRows), only while the viewport is on the live screen (a wheel-down
* from those overflow rows is xterm's, and brings it home), and never while
* THIS pane's session has a dialog open.
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent
@@ -49,7 +51,7 @@
*
* @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 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)
* @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).
this._linkHovered = false;
this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
// (travel short of a whole page, its direction and last event time), the
// state CodemanTerminalInput.pageKeysForGesture hands back.
this._pageKeyGesture = null;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
@@ -1154,17 +1157,20 @@
// Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
// the primary pane's own, asked for THIS pane (its terminal, its session,
// never the active one), so the CLI rules stay in terminal-ui.js and this
// file names no CLI. Returns true when the wheel was consumed here.
// draws in place (on the alternate screen, or Claude's repaint mode) leaves
// this xterm no scrollback, so a wheel scrolled nothing; instead the travel
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// primary pane's own, asked for THIS pane (its terminal, its session, never
// the active one), so the CLI rules stay in terminal-ui.js and this file
// names no CLI. The gesture rules (first-event page, notch accumulation,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
@@ -1188,8 +1194,12 @@
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
this._pageKeyPending = step.pending;
// An open dialog (a pending permission prompt or question) in THIS tile's
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
return true;
}
@@ -1543,7 +1553,7 @@
clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
this._scrollBytes = '';
this._pageKeyPending = 0;
this._pageKeyGesture = null;
this._detachSocket();
if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
+171 -33
View File
@@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
// gesture for a CLI whose local buffer holds no scrollback
// (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~';
@@ -92,6 +92,20 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// A page-key scroll event after this much quiet, or in the opposite direction,
// starts a NEW gesture, and the first event of a gesture pages at once rather
// than waiting for half a screen of travel (a trackpad flick never got there).
const PAGE_KEY_GESTURE_GAP_MS = 150;
// ...but not for sub-row jitter (0.1 row, ~2px)...
const PAGE_KEY_MIN_START_ROWS = 0.1;
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
// 4 rows) accumulates toward half a screen as before; paging at once on it made
// slow notches, each its own gesture past the gap, page a full screen apiece.
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
// A pre-paid page's debt outlives the gesture gap for this long when the next
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
const PAGE_KEY_DEBT_MS = 1000;
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
@@ -133,13 +147,17 @@
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
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
// its own pending travel) pages identically.
// left over plus the keys to send ('' below one page). The plain accumulation
// step of pageKeysForGesture below.
function pageKeysForTravel(pending, lines, rows) {
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
const perPage = pageKeyRows(rows);
const total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage);
const keys = pages
@@ -148,6 +166,77 @@
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;
// 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
@@ -303,6 +392,11 @@
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
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,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -1056,6 +1150,10 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0;
let scrollFrame = null;
let isTouching = false;
// One id per touch contact (touchstart), carried by its touchmoves AND its
// momentum frames, so the page-key fallback treats a whole contact as one
// gesture however slowly the finger moves (pageKeysForGesture).
let touchContactId = 0;
const scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
@@ -1069,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
// Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines);
}
@@ -1142,6 +1240,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0;
pixelAccum = 0;
isTouching = true;
touchContactId++;
didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
// Classifying scans the whole viewport with translateToString, and
@@ -1246,7 +1345,7 @@ Object.assign(CodemanApp.prototype, {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
@@ -5730,35 +5829,38 @@ Object.assign(CodemanApp.prototype, {
},
/**
* True when this session's LOCAL scrollback is structurally empty: a pane whose
* TUI repaints one full screen in place, so tmux keeps no history for it
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
* True when this session's LOCAL scrollback is structurally empty and its CLI
* can page its own transcript: xterm's normal buffer never grows past one
* screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
* is routed (the "wheel does nothing at all" half of the #205 retest).
*
* Two shapes, measured separately:
* - `claude` in repaint mode (the original, #205 round 2), and
* Shapes measured so far:
* - `claude` in repaint mode (the original, #205 round 2): tmux reports
* `history_size≈0` and every frame overwrites the last;
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
* terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it. Without
* this the wheel was silently dead in every opencode tab.
* byte-identical — so paging is the only gesture that reaches it;
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
* wheel reports do nothing.
*
* Every other mode is deliberately absent: shell/pi own real terminal
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
* is unverified (docs/scrollback-fix-plan.md).
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* literal here. A missing list means no mode pages.
*
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays
* here alone. `localRows` replaces `baseY` as the history row count: a tile
* calls this with its own session and terminal, so the mode rule stays here
* alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's.
*/
_localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude' && mode !== 'opencode') return false;
const pagingModes = window.__codemanTranscriptPageKeys;
if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false;
const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
@@ -5775,34 +5877,70 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* opencode, which never fills the buffer and never accepts the wheel. Before
* one that never fills the buffer and never accepts the wheel (opencode, codex:
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
*
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
* session with real local scrollback is never touched. Shift is excluded on
* purpose: it is the explicit "give me local scrollback" gesture and must keep
* that meaning.
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
*
* The FIRST event of a 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
* scroll locally).
*
* The gesture rules above live in the pure CodemanTerminalInput.pageKeysForGesture
* (top of this file); this method adds the gates and keeps the state.
*
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the
* two in step. The tile adds one gate this pane cannot need, viewport at the
* through the same gates and the same pageKeysForGesture, with its own state
* and its OWN session's dialog check; keep the two in step. The tile adds one gate this pane cannot need, viewport at the
* bottom: hollow here means baseY 0, so this viewport is always there, while a
* tile is hollow with its own discounted rows still above the screen.
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false;
// 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) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
this._pageKeyGesture = null;
}
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
this._pageKeyPending = step.pending;
const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyGesture,
ev,
lines,
this.terminal?.rows,
performance.now()
);
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys');
return true;
+13
View File
@@ -1783,6 +1783,19 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
// Which run modes page their own transcript on PageUp/PageDown, for the scroll
// fallback over a pane with no local history (`_maybePageCliTranscript`). Read off
// the `transcriptPageKeys` capability, never an id list here, and injected for a
// solo window too for the same reason as the gutter map: it scrolls a terminal.
// Ids only, which the registry schema bounds to kebab tokens, so JSON.stringify
// alone is enough.
const pageKeyClis = enabledClis()
.filter((entry) => entry.capabilities.transcriptPageKeys === true)
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
+20
View File
@@ -414,4 +414,24 @@ describe('capability shapes', () => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => {
for (const value of [true, false]) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).transcriptPageKeys = value;
expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true);
}
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes';
}, 'transcriptPageKeys is a boolean');
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
});
});
+43 -7
View File
@@ -1254,7 +1254,11 @@ describe('Virtual Keyboard', () => {
});
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
const calls = await page.evaluate(async () => {
// A Codex pane with REAL local scrollback (an older inline Codex, or any pane
// whose output scrolled lines into xterm's normal buffer). A blank pane is
// hollow and correctly pages the CLI instead (terminal-touch-paging.browser
// test), so the history is written first and checked before the drag.
const result = await page.evaluate(async () => {
app.activeSessionId = 'mobile-touch-scroll-test';
app.sessions.set('mobile-touch-scroll-test', {
id: 'mobile-touch-scroll-test',
@@ -1262,22 +1266,42 @@ describe('Virtual Keyboard', () => {
status: 'running',
});
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._serverCjkOverride = true;
app._updateCjkInputState();
const history = Array.from({ length: 200 }, (_, i) => `history line ${i}`).join('\r\n') + '\r\n';
await new Promise<void>((resolve) => app.terminal.write(history, resolve));
app.terminal.scrollToBottom();
const buffer = app.terminal.buffer.active;
const before = {
type: buffer.type,
baseY: buffer.baseY,
viewportY: buffer.viewportY,
hollow: app._localScrollbackIsHollow(),
forwards: app._shouldForwardWheelToApp({ shiftKey: false }),
cjkVisible: document.body.classList.contains('cjk-input-visible'),
};
const originalScrollLines = app.terminal.scrollLines.bind(app.terminal);
const scrollCalls: number[] = [];
app.terminal.scrollLines = (lines: number) => {
scrollCalls.push(lines);
return originalScrollLines(lines);
};
const sentToCli: string[] = [];
app._sendInputEphemeral = (_id: string, data: string) => sentToCli.push(data);
const target =
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer');
if (!target) return scrollCalls;
if (!target) return { before, scrollCalls, sentToCli, viewportAfter: -1 };
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + Math.min(180, rect.height - 20);
const endY = startY - 120;
// The finger moves DOWN the screen, so the content follows it back into history.
const startY = rect.top + 20;
const endY = startY + 120;
function createTouch(y: number) {
return new Touch({
@@ -1315,11 +1339,23 @@ describe('Virtual Keyboard', () => {
})
);
await new Promise((resolve) => setTimeout(resolve, 50));
return scrollCalls;
app._flushWheelSgrQueue?.();
return { before, scrollCalls, sentToCli, viewportAfter: app.terminal.buffer.active.viewportY };
});
expect(calls.length).toBeGreaterThan(0);
expect(calls.some((lines) => lines !== 0)).toBe(true);
// The scenario is real local scrollback with the visible textarea on.
expect(result.before.cjkVisible).toBe(true);
expect(result.before.type).toBe('normal');
expect(result.before.baseY).toBeGreaterThan(0);
expect(result.before.viewportY).toBe(result.before.baseY);
expect(result.before.hollow).toBe(false);
expect(result.before.forwards).toBe(false);
// The drag scrolled xterm's own history upward, and sent nothing to the CLI.
expect(result.scrollCalls.length).toBeGreaterThan(0);
expect(result.scrollCalls.some((lines) => lines < 0)).toBe(true);
expect(result.viewportAfter).toBeLessThan(result.before.viewportY);
expect(result.sentToCli).toEqual([]);
});
it('keeps typed phone text in the terminal local echo path', async () => {
+17
View File
@@ -159,6 +159,23 @@ describe('WebServer.renderIndexHtml', () => {
expect(map.shell).toBeUndefined();
});
it('injects the transcript page-key modes, in a /session/:id window too', async () => {
// Read off the transcriptPageKeys capability. A solo window scrolls a terminal
// like the main page does, so it needs the list as much as the gutter map.
for (const solo of [undefined, 'sess-123']) {
const { server } = makeServer();
const html = await render(server, solo);
const match = html.match(/window\.__codemanTranscriptPageKeys=(\[[^<]*\]);/);
expect(match).not.toBeNull();
const modes = JSON.parse(match![1]) as string[];
expect(modes).toContain('claude');
expect(modes).toContain('codex');
expect(modes).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 () => {
const { server } = makeServer({});
const html = await render(server, 'a</script><b>');
+3 -1
View File
@@ -113,7 +113,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
// Injected unconditionally as an object keyed by run mode, empty when no
// enabled CLI declares a gutter, so it needs stripping on every machine.
.replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '');
.replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '')
// Likewise the page-key mode list: an array, possibly empty, always injected.
.replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);
+492 -29
View File
@@ -19,18 +19,30 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
function loadTerminalUiHarness() {
/**
* The run modes whose CLI can page its own transcript with PageUp/PageDown, read
* off the shipped registry exactly as the server builds `__codemanTranscriptPageKeys`,
* so these tests break if a stock entry loses (or quietly gains) the capability.
*/
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
function loadTerminalUiHarness(
windowGlobals: Record<string, unknown> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES }
) {
const CodemanApp = function CodemanApp(this: any) {};
const logs: string[] = [];
// terminal-ui.js hangs CodemanTerminalInput off window; tests read it there.
const windowRef: Record<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({
window: windowRef,
CodemanApp,
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
performance: clock,
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
@@ -45,12 +57,12 @@ function loadTerminalUiHarness() {
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), logs, windowRef };
return { app: new (CodemanApp as any)(), logs, windowRef, clock };
}
/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */
function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
const { app, logs, windowRef } = loadTerminalUiHarness();
const { app, logs, windowRef, clock } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([
@@ -70,7 +82,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } },
};
return { app, sent, logs, windowRef };
return { app, sent, logs, windowRef, clock };
}
describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
@@ -157,16 +169,129 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
});
it('accumulates sub-page travel instead of dropping or over-sending it', () => {
it('answers the first event of a gesture at once and owes the travel back', () => {
const { app, sent } = hollowApp();
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
app._flushWheelSgrQueue();
expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet
app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page
// A trackpad flick opens with a small delta and stays far short of half a
// screen (18 rows here). It used to send nothing at all, so a session that
// always lands here looked dead.
expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
// The pre-paid page is owed back: the rest of this page's travel sends nothing…
app._maybePageCliTranscript({ shiftKey: false }, -17);
app._flushWheelSgrQueue();
expect(sent).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', () => {
@@ -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);
app.terminal.buffer.active.baseY = 0;
// Modes with real terminal scrollback keep their existing behavior (shell/pi
// own tmux history through the alt-screen strip; codex/gemini/antigravity/…
// page-key behaviour is unverified — docs/scrollback-fix-plan.md).
// Modes whose CLI does not declare transcriptPageKeys keep their existing
// behavior (shell/pi own tmux history through the alt-screen strip;
// gemini/antigravity/… page-key behaviour is unverified — docs/scrollback-fix-plan.md).
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
app.sessions = new Map([['sess-1', { mode: 'gemini' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
@@ -223,6 +348,47 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
});
it('pages a Codex transcript only while its local scrollback is empty', () => {
// Codex is never sent SGR wheel reports (it ignores them); this is the
// separate PageUp/PageDown fallback, which codex does honour.
const { app, sent } = hollowApp();
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
app.terminal.buffer.active.baseY = 40;
expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: true }, 18)).toBe(false);
});
it('reads the paging modes from the injected capability map, never an id list of its own', () => {
// No map (a page rendered without it) means no mode pages, the same fail-safe
// direction the transcript-gutter map takes.
const bare = loadTerminalUiHarness({}).app;
bare.activeSessionId = 'sess-1';
bare.sessions = new Map([['sess-1', { mode: 'claude' }]]);
bare.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } };
expect(bare._localScrollbackIsHollow()).toBe(false);
// A mode the map names pages even if no stock entry has that id.
const custom = loadTerminalUiHarness({ __codemanTranscriptPageKeys: ['my-cli'] }).app;
custom.activeSessionId = 'sess-1';
custom.sessions = new Map([['sess-1', { mode: 'my-cli' }]]);
custom.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } };
expect(custom._localScrollbackIsHollow()).toBe(true);
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const helper = source.slice(
source.indexOf(' _localScrollbackIsHollow(target = {}) {'),
source.indexOf(' _maybePageCliTranscript(ev, lines) {')
);
expect(helper).toContain('window.__codemanTranscriptPageKeys');
expect(helper).not.toMatch(/=== '(?:claude|codex)'|!== '(?:claude|codex)'/);
});
it('rescues the local-scrollback opt-out footgun instead of silently dying', () => {
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
@@ -240,12 +406,17 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
it('drops travel accumulated on another tab', () => {
const { app, sent } = hollowApp();
app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
app._maybePageCliTranscript({ shiftKey: false }, -1); // first event pages sess-1 at once
app._maybePageCliTranscript({ shiftKey: false }, -34); // just short of sess-1's second page
app._flushWheelSgrQueue();
app.activeSessionId = 'sess-2';
app.sessions.set('sess-2', { mode: 'claude' });
app._maybePageCliTranscript({ shiftKey: false }, -1); // must not complete sess-1's page
app._maybePageCliTranscript({ shiftKey: false }, -1); // a NEW gesture on sess-2, never sess-1's page
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
expect(sent).toEqual([
{ id: 'sess-1', data: '\x1b[5~' },
{ id: 'sess-2', data: '\x1b[5~' },
]);
});
it('is reachable from both the wheel and the touch paths', () => {
@@ -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.
expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;');
// Touch: touchmove and the momentum loop both fall through to it.
expect(source.match(/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false \}, lines\)\)/g)).toHaveLength(2);
expect(
source.match(
/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false, touchId: touchContactId \}, lines\)\)/g
)
).toHaveLength(2);
// ...and every contact gets its own id at touchstart, so a contact is one gesture.
expect(source).toContain('touchContactId++;');
});
});
describe('page-key gestures across slow touch drags, slow wheel ticks and jitter (#527 review)', () => {
const UP = '\x1b[5~';
const DOWN = '\x1b[6~';
/** A hollow 38-row pane (19 rows a page) on a hand-moved clock, with every key sent. */
function pane(mode = 'codex') {
const h = hollowApp({ mode, rows: 38 });
let now = 10_000;
h.clock.now = () => now;
const keys = () => {
h.app._flushWheelSgrQueue();
return h.sent.map((s) => s.data).join('');
};
return { ...h, keys, advance: (ms: number) => (now += ms) };
}
const count = (keys: string, key: string) => keys.split(key).length - 1;
it('keeps one touch contact one gesture, however slowly the finger moves', () => {
const p = pane();
// touchmove calls once per whole cell, so a slow drag arrives as single lines
// 170 ms apart, each past the 150 ms gesture gap. It sent one PageUp per line.
for (let i = 0; i < 12; i++) {
expect(p.app._maybePageCliTranscript({ shiftKey: false, touchId: 1 }, -1)).toBe(true);
p.advance(170);
}
expect(p.keys()).toBe(UP);
// A new contact is a new gesture: its first line pages at once.
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 2 }, -1);
expect(p.keys()).toBe(UP + UP);
});
it('pages a slow 40-line drag twice, and keeps momentum in the same gesture', () => {
const p = pane();
for (let i = 0; i < 40; i++) {
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
p.advance(170);
}
// 1 at once, then 18 owed back, then 19 more: two pages by line 38.
expect(count(p.keys(), UP)).toBe(2);
// Momentum frames after the finger lifts carry the same contact: they
// continue the drag's travel instead of opening a gesture of their own.
for (let i = 0; i < 10; i++) {
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
p.advance(16);
}
expect(count(p.keys(), UP)).toBe(2);
});
it('never splits a contact on a pause, but does not carry its debt into the next contact', () => {
const p = pane();
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1); // pages, owes 18
p.advance(5_000); // the finger rests, still down
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1);
expect(p.keys()).toBe(UP);
p.advance(10);
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 4 }, -1); // a new contact pages at once
expect(p.keys()).toBe(UP + UP);
});
it('pages slow small wheel ticks once per half screen, not once per tick', () => {
// Five 40 px ticks 250 ms apart (1.6 rows each, under the 2-row trackpad
// size) used to page once per tick. The first one still pages at once.
const p = pane();
for (let i = 0; i < 5; i++) {
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40, deltaMode: 0 }, -1.6);
p.advance(250);
}
expect(p.keys()).toBe(UP);
// Firefox reports a notch as 1 line (deltaMode 1): the same.
const f = pane();
for (let i = 0; i < 5; i++) {
f.app._maybePageCliTranscript({ shiftKey: false, deltaY: -1, deltaMode: 1 }, -1);
f.advance(250);
}
expect(f.keys()).toBe(UP);
});
it('lets a pre-paid debt lapse after a second of quiet, so the next tick pages at once again', () => {
const p = pane();
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6);
p.advance(900);
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // inside the second: owed
expect(p.keys()).toBe(UP);
p.advance(1_100);
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // debt lapsed: pages
expect(p.keys()).toBe(UP + UP);
});
it('does not page back and forth on a +/-3 px trackpad wobble', () => {
const p = pane();
for (let i = 0; i < 20; i++) {
const dy = i % 2 ? 3 : -3;
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25);
p.advance(16);
}
// The opening event is a fresh gesture and pages; no reversal inside the
// gap pre-pays, so the wobble sends nothing after it.
expect(p.keys()).toBe(UP);
});
it('still pages a deliberate reversal at once after a pause', () => {
const p = pane();
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1);
p.advance(200);
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
expect(p.keys()).toBe(UP + DOWN);
});
it('does not let a reversal inherit the other direction’s debt', () => {
const p = pane();
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); // UP, owes 18 upward
p.advance(16);
// Downward travel just short of a page: the upward debt must not count toward it.
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18);
expect(p.keys()).toBe(UP);
p.advance(16);
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
expect(p.keys()).toBe(UP + DOWN);
});
it('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)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();
const { wheelDeltaLines, pageKeysForTravel } = windowRef.CodemanTerminalInput;
const { wheelDeltaLines, pageKeysForTravel, pageKeysForGesture } = windowRef.CodemanTerminalInput;
expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line
expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox)
@@ -274,17 +723,31 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
// The primary pane's own methods agree with them.
const ev = { deltaY: -250, deltaMode: 2 };
expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36));
let pending = 0;
let state: unknown = null;
let expected = '';
for (const lines of [-10, -10, 30, -1000, 7]) {
const step = pageKeysForTravel(pending, lines, 36);
pending = step.pending;
// Mixed: a trackpad opening, a notch-sized opening, a reversal, a pinch and
// a sideways swipe, all under a frozen clock (one gesture per direction).
const steps: Array<[number, Record<string, unknown>]> = [
[-1, {}],
[-10, {}],
[-30, {}],
[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;
app._maybePageCliTranscript({ shiftKey: false }, lines);
app._maybePageCliTranscript(event, lines);
}
app._flushWheelSgrQueue();
expect(app._pageKeyPending).toBe(pending);
expect(expected).not.toBe('');
expect(app._pageKeyGesture).toEqual(state);
expect(expected).toContain('\x1b[5~');
expect(expected).toContain('\x1b[6~');
expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
});
@@ -304,7 +767,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
tileBuffer.type = 'alternate';
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
tileBuffer.type = 'normal';
app.sessions.set('tile-1', { mode: 'codex' });
app.sessions.set('tile-1', { mode: 'gemini' });
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
});
+215 -2
View File
@@ -37,20 +37,35 @@ import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
const fetchMock = vi.fn();
/**
* The run modes that page, read off the shipped registry exactly as the server
* builds `window.__codemanTranscriptPageKeys` (renderIndexHtml), which is the only
* place either pane learns them.
*/
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
/**
* The clock both panes read gesture gaps from (`performance.now()`). Real time by
* default; a test that needs a pause or a burst moves it by hand.
*/
const clock = { now: () => performance.now() };
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
__codemanTranscriptPageKeys: PAGE_KEY_MODES,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
performance: { now: () => clock.now() },
setInterval: vi.fn(),
clearInterval: vi.fn(),
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
@@ -192,6 +207,7 @@ function flushed(ws: FakeSocket) {
}
beforeEach(() => {
clock.now = () => performance.now();
vi.useFakeTimers();
FakeFit.proposed = { cols: 80, rows: 24 };
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 }]);
});
it.each(['shell', 'codex', 'antigravity'])(
it.each(['shell', 'gemini', 'antigravity'])(
'leaves a %s tile to xterm even while the active session is opencode',
async (mode) => {
const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other');
@@ -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', () => {
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
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview Real Chromium coverage for the page-key fallback on the TOUCH
* path of the primary pane (#527 review, #508): the touch listeners that
* `initTerminal()` installs on `#terminalContainer`, not a call into the pure
* helper.
*
* A slow drag reaches `_maybePageCliTranscript` once per whole cell of finger
* travel, so its calls are further apart than the 150 ms gesture gap. Each call
* used to start a new gesture and pre-pay a page, so twelve lines of slow
* finger travel sent twelve PageUps. The touch paths now tag every call with
* the contact's id (one per touchstart, carried by its momentum frames), and a
* contact is one gesture.
*
* What runs for real: the shipped app (page served by a WebServer on port 0),
* its xterm, the touch listeners, the momentum loop on requestAnimationFrame,
* the gates in terminal-ui.js and the 40 ms key coalescer. What is stubbed:
* the active session (a Codex session the page knows about but no PTY) and
* `_sendInputEphemeral`, which records the keys instead of sending them. The
* touch events are DOM events carrying `touches`, dispatched on the container
* with real time between them.
*
* Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PROBE_ID = 'touch-paging-probe';
const UP = '\x1b[5~';
describe('touch paging of a hollow Codex pane in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, hasTouch: true });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Dispatch one touch event of `type` at (x, y) on the terminal container. */
const touch = (type: string, x: number, y: number) =>
page.evaluate(
([t, cx, cy]) => {
const container = document.getElementById('terminalContainer')!;
const ev = new Event(t as string, { bubbles: true, cancelable: true });
const point = { clientX: cx, clientY: cy, target: container };
Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] });
Object.defineProperty(ev, 'changedTouches', { value: [point] });
container.dispatchEvent(ev);
},
[type, x, y] as const
);
const probe = () =>
page.evaluate(() => {
const p = (window as any).__touchProbe;
p.app._flushWheelSgrQueue();
return { keys: p.sent.join(''), calls: p.calls.slice(), rows: p.app.terminal.rows };
});
it('pages a slow one-line-at-a-time drag once, keeps momentum in it, and pages a new contact at once', async () => {
const cell = await page.evaluate((id) => {
const w = window as any;
const app = w.app;
app.sessions.set(id, { id, name: id, mode: 'codex', status: 'idle' });
app.activeSessionId = id;
app.tabAlerts?.delete?.(id);
const sent: string[] = [];
app._sendInputEphemeral = (_sessionId: string, data: string) => sent.push(data);
const calls: Array<{ touchId: unknown; lines: number }> = [];
const real = app._maybePageCliTranscript;
app._maybePageCliTranscript = function (ev: { touchId?: unknown }, lines: number) {
calls.push({ touchId: ev?.touchId, lines });
return real.call(this, ev, lines);
};
w.__touchProbe = { app, sent, calls };
return app.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
}, PROBE_ID);
// The preconditions the fallback needs, so a pass is not vacuous.
const pre = await page.evaluate(() => {
const app = (window as any).app;
return {
hollow: app._localScrollbackIsHollow(),
forwards: app._shouldForwardWheelToApp({ shiftKey: false }),
modes: (window as any).__codemanTranscriptPageKeys,
};
});
expect(pre.modes).toContain('codex');
expect(pre.hollow).toBe(true);
expect(pre.forwards).toBe(false);
// One contact, finger moving DOWN one cell every 170 ms (scrolls back up).
const x = 300;
const y0 = 200;
await touch('touchstart', x, y0);
for (let i = 1; i <= 12; i++) {
await page.waitForTimeout(170);
await touch('touchmove', x, y0 + Math.ceil(cell) * i);
}
await page.waitForTimeout(60); // the 40 ms coalescer
const drag = await probe();
expect(drag.calls.length).toBeGreaterThanOrEqual(12);
expect(drag.calls.every((c) => c.lines < 0)).toBe(true);
expect(drag.keys).toBe(UP); // was one PageUp per line
const contact = drag.calls[0].touchId;
expect(typeof contact).toBe('number');
expect(drag.calls.every((c) => c.touchId === contact)).toBe(true);
// Lift: the momentum loop keeps calling with the SAME contact id.
await touch('touchend', x, y0 + Math.ceil(cell) * 12);
await page.waitForTimeout(800);
const afterMomentum = await probe();
const momentum = afterMomentum.calls.slice(drag.calls.length);
expect(momentum.length).toBeGreaterThan(0);
expect(momentum.every((c) => c.touchId === contact)).toBe(true);
// The contact's whole travel (drag + momentum) is short of the two half
// screens a second page needs once the first was pre-paid, so it is still
// one PageUp. A momentum frame that opened its own gesture would have paged.
const travelled = afterMomentum.calls.reduce((sum, c) => sum + Math.abs(c.lines), 0);
expect(travelled).toBeGreaterThan(12);
expect(travelled).toBeLessThan(2 * Math.round(afterMomentum.rows / 2));
expect(afterMomentum.keys).toBe(UP);
// A new contact is a new gesture: its first line pages at once.
await touch('touchstart', x, y0);
await page.waitForTimeout(20);
await touch('touchmove', x, y0 + Math.ceil(cell));
await page.waitForTimeout(60);
const second = await probe();
const fresh = second.calls.slice(afterMomentum.calls.length);
expect(fresh.length).toBe(1);
expect(fresh[0].touchId).not.toBe(contact);
expect(second.keys).toBe(UP + UP);
await touch('touchend', x, y0 + Math.ceil(cell));
}, 60000);
});