fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event

Two changes to the last-resort page-key scroll (_maybePageCliTranscript), the
path a wheel or touch gesture takes when it is not forwarded to the CLI and
the local xterm buffer has no history (normal buffer, baseY 0).

Codex now takes that path too. Newer codex draws on tmux's alternate screen,
which the full strip hides from xterm, so a codex pane is as hollow as a
repaint-mode Claude pane and the wheel did nothing. Codex still ignores SGR
wheel reports and is still never forwarded the wheel; this sends plain
PageUp/PageDown keys, which codex does page its transcript on (measured on
0.157.1 locally and 0.160 over a remote session). Which modes may page is a
new registry capability, capabilities.transcriptPageKeys (claude and codex in
stock.ts), published to the page as window.__codemanTranscriptPageKeys, so
terminal-ui.js holds no CLI id for it.

The first event of a gesture now pages at once. The fallback used to send a
key only after half a screen of accumulated travel (19 rows, about 475px on a
38-row pane), so an ordinary trackpad flick sent nothing. The skipped travel is
owed back by the rest of the gesture, keeping one page per half screen; a pause
of more than 150 ms, a direction change or a tab switch starts a new gesture.
This commit is contained in:
Saqeb Akhter
2026-10-09 20:19:46 -04:00
committed by Aamer Akhter
parent b59145effd
commit 2eff6dcbdd
10 changed files with 260 additions and 45 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a gesture pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex (measured 0.157.1 locally, 0.160 remote) draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode rule stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
+4
View File
@@ -294,6 +294,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']),
+9
View File
@@ -267,6 +267,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',
@@ -703,6 +707,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
@@ -490,6 +490,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;
+63 -22
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,12 @@
// 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;
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
@@ -261,6 +267,8 @@
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
pageKeysForTravel,
PAGE_KEY_GESTURE_GAP_MS,
PAGE_KEY_MIN_START_ROWS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -5669,35 +5677,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;
@@ -5714,14 +5725,23 @@ 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 gesture 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 pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a
* tab switch starts a new gesture.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
@@ -5739,8 +5759,29 @@ Object.assign(CodemanApp.prototype, {
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
this._pageKeyLastAt = undefined;
}
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
const tuning = window.CodemanTerminalInput;
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
const now = performance.now();
const dir = lines < 0 ? -1 : 1;
const gestureStart =
typeof this._pageKeyLastAt !== 'number' ||
now - this._pageKeyLastAt > tuning.PAGE_KEY_GESTURE_GAP_MS ||
dir !== this._pageKeyDir;
this._pageKeyLastAt = now;
this._pageKeyDir = dir;
if (gestureStart) {
this._pageKeyPending = 0;
if (Math.abs(lines) >= tuning.PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < perPage) {
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
this._pageKeyPending = lines - dir * perPage;
this._queueScrollBytes(dir < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN);
this._logScrollRouting('page-keys');
return true;
}
}
const step = tuning.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
this._pageKeyPending = step.pending;
if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys');
+13
View File
@@ -1778,6 +1778,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) and codex (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']);
});
});
+16
View File
@@ -159,6 +159,22 @@ 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).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);
+117 -21
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,52 @@ 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 is far less than 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 }, -10)).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 }, -8);
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 }, -2); // first event: one PageUp
app._maybePageCliTranscript({ shiftKey: false }, 2); // reversal: one PageDown at once
now += 1_000;
app._maybePageCliTranscript({ shiftKey: false }, 2); // after a pause: another 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[6~\x1b[6~' }]);
});
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 +255,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 +271,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 +329,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', () => {
@@ -276,7 +370,9 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36));
let pending = 0;
let expected = '';
for (const lines of [-10, -10, 30, -1000, 7]) {
// One direction under a frozen clock, opening past the page size: a single
// gesture that accumulates, which is exactly the pure helper's arithmetic.
for (const lines of [-20, -10, -30, -1000, -7]) {
const step = pageKeysForTravel(pending, lines, 36);
pending = step.pending;
expected += step.keys;
@@ -304,7 +400,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);
});