mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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