fix(terminal): share the gesture-aware page-key math with tiles, add opencode

Review follow-up after the rebase onto 1.40.0, where tiles began paging a
hollow buffer through the shared CodemanTerminalInput helpers and opencode
joined the hollow check (#555).

- opencode declares transcriptPageKeys in stock.ts, so the paging list read
  by both panes is claude, codex and opencode. Without it, reading the
  capability instead of the old id check would have turned opencode paging
  off again. The stock-set and render tests name it.
- The gesture rules (first trackpad-sized event pages at once and is owed
  back, wheel notches accumulate, a pre-paid debt dies with its gesture,
  pinch and mostly horizontal swipes are consumed without paging) move out
  of the primary pane into the pure CodemanTerminalInput.pageKeysForGesture.
  The primary pane and TerminalTile both call it with their own state, so a
  Codex or opencode tile pages exactly like the main pane.
- The dialog check stays per pane: the primary pane reads tabAlerts for the
  active session, a tile for its own sessionId, so a wheel over a tile never
  pages into that session's permission prompt while the focused tab is clear,
  and a tile is not blocked by a dialog in another tab.

Tests: pure helper cases in terminal-scroll-routing, tile cases for Codex
paging, first-event paging, notch accumulation, pinch and swipe, byte parity
with the primary pane over a mixed sequence, and the per-session dialog
check in both directions.
This commit is contained in:
Aamer Akhter committed 2026-10-09 20:27:16 -04:00
1 parent a4926e49f1
commit d7198dd058
8 files changed
+386 -79

No files matched your search

+3 -3
View File
@@ -429,9 +429,9 @@ describe('capability shapes', () => {
});
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.
// 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']);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
});
});
+1
View File
@@ -170,6 +170,7 @@ describe('WebServer.renderIndexHtml', () => {
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');
}
+106 -10
View File
@@ -274,6 +274,16 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
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();
@@ -416,10 +426,84 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
});
});
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('drops a pre-paid debt when its gesture ends', () => {
// A flick pre-pays a page; a second flick after a pause pages at once again
// instead of first paying off the first one's debt.
const steps = run([
[-1, 0],
[-1, 500],
]);
expect(steps.map((s) => s.keys)).toEqual([UP, UP]);
});
it('starts a new gesture on a direction change, and ignores sub-row jitter', () => {
const steps = run([
[-1, 0],
[1, 10],
[0.05, 20],
[-0.05, 1000],
]);
expect(steps.map((s) => s.keys)).toEqual([UP, DOWN, '', '']);
});
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)
@@ -433,19 +517,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 = '';
// 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;
// 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, {}],
[1, {}],
[-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 }]);
});
+167 -2
View File
@@ -30,20 +30,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
@@ -185,6 +200,7 @@ function flushed(ws: FakeSocket) {
}
beforeEach(() => {
clock.now = () => performance.now();
vi.useFakeTimers();
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
@@ -226,7 +242,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');
@@ -375,6 +391,155 @@ 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(''));
});
});
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