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

+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 }]);
});