mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +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:
commit
0e11012470
16 files changed
+1192
-96
No files matched your search
@@ -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