mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Review of 8659ba25..be987fbb:
- Usage: a Free account answers premium_interactions {entitlement: 0, remaining: 0,
percent_remaining: 0, unlimited: false, has_quota: false}, which read as 100% used. The parser now
returns null when has_quota is false or the entitlement is not positive, with that payload as a
fixture. Free meters chat (200) and completions (2000) too, so the comment no longer says they
are unlimited on every plan.
- agent read: rows were skipped until a `❯ <prompt>` row, so an answer taller than the pane
(prompt scrolled off the top) read as "". With no prompt row AND no banner / trust dialog on
screen, the rows above the composer are now the tail of the last answer. Text typed in the
composer (a prompt-shaped row between the two rules) is not a prompt row.
- Page keys: measured on 1.0.95 in tmux that Copilot pages on PageUp/PageDown (three PageUp moved
lines 109-140 to 010-042, PageDown returned) as well as on SGR wheel reports, so copilot joins
claude and opencode in _localScrollbackIsHollow(); the stale-tracking-flag fallback the comment
and docs describe is now true, for the main pane and a TerminalTile (same function), with tests.
- X-GitHub-Api-Version is 2022-11-28, a version GitHub supports (verified against the live
endpoint); readPlanUsageTelemetryEnabled() is checked before isCopilotAvailable() so a switched-off
chip skips the resolver probe too.
- Palette label for Copilot in panels-ui.js; stale comments in types/session.ts and stock.ts.
- Docs: Past Sessions continues with --continue (resumeSessionId is for API callers), nothing sets
continueSession on a respawn, and only a tab name the user set is passed as --name; the usage
wording for Free; the wheel and mouse-strip paragraphs in CLAUDE.md and
architecture-invariants; the changeset describes the whole feature.
1035 lines
41 KiB
TypeScript
1035 lines
41 KiB
TypeScript
/**
|
|
* @fileoverview A TerminalTile pages a hollow buffer's CLI transcript, and
|
|
* reports a plain click to a CLI whose mouse DECSETs the server strips, the way
|
|
* the primary pane does (#555), through the primary pane's own gates aimed at
|
|
* the TILE: its terminal, its session, never the active one.
|
|
*
|
|
* opencode draws in place on the alternate screen, so tmux keeps no history
|
|
* for it and the browser's buffer holds one screen. The primary pane turns the
|
|
* wheel into PageUp/PageDown there (`_maybePageCliTranscript`, terminal-ui.js);
|
|
* a tile left the wheel to xterm, which scrolled nothing. #555 also strips
|
|
* opencode's mouse DECSETs on the server, which reaches tile sockets too: a
|
|
* drag selects text again, but xterm's own encoder no longer reports a click,
|
|
* so the tile now hand-encodes it like the primary pane.
|
|
*
|
|
* A tile's buffer is rarely empty above the screen even so: its first capture
|
|
* is taken at the PTY's previous, taller size and written into a shorter
|
|
* xterm, and its own row-shrinking fits push more rows up. Those rows are not
|
|
* history, so the tile discounts them (`_localRows`); the overflow cases below
|
|
* fail without that discount.
|
|
*
|
|
* Claude's fullscreen renderer is the other half: it scrolls its own
|
|
* transcript on SGR wheel reports, which the primary pane sends it, while a
|
|
* tile's xterm holds only replayed repaint frames. A tile left that wheel to
|
|
* xterm too, so in a grid of fullscreen Claude sessions the wheel dragged
|
|
* stale frames around or did nothing; it now forwards the reports to its own
|
|
* session, from its own geometry (`_maybeForwardWheelToCli`).
|
|
*
|
|
* Real code under test: constants.js + app.js + terminal-ui.js +
|
|
* terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts.
|
|
* xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts),
|
|
* with the fake xterm's row emulation switched on.
|
|
*
|
|
* Port: none (no server, no browser).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
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: { now: () => clock.now() },
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
|
|
// running inside this context.
|
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
|
requestAnimationFrame: vi.fn(),
|
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
WebSocket: FakeSocket,
|
|
Terminal: FakeTerminal,
|
|
FitAddon: { FitAddon: FakeFit },
|
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
|
location: { protocol: 'http:', host: 'codeman.test' },
|
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
|
window: windowStub,
|
|
MobileDetection: {
|
|
isTouchDevice: () => false,
|
|
isHandheldDevice: () => false,
|
|
getDeviceType: () => 'desktop',
|
|
},
|
|
});
|
|
vm.runInContext(
|
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
|
'globalThis.__CodemanApp = CodemanApp;',
|
|
context
|
|
);
|
|
return {
|
|
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
|
|
windowStub,
|
|
};
|
|
}
|
|
|
|
const { CodemanApp, windowStub } = loadContext();
|
|
|
|
type Session = { mode: string; cliVersion?: string; cliMouseTracking?: boolean };
|
|
type App = Record<string, unknown> & {
|
|
sessions: Map<string, Session>;
|
|
activeSessionId: string | null;
|
|
_linkHovered?: boolean;
|
|
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
|
};
|
|
|
|
function makeApp(sessions: Record<string, Session>, activeSessionId: string | null = 'other'): App {
|
|
const app = Object.create(CodemanApp.prototype) as App;
|
|
app._clientId = 'c-test';
|
|
app._wsTabNonce = 'nonce-1';
|
|
app._seqCounters = new Map();
|
|
app._pendingDeliveries = new Map();
|
|
app._postDraining = new Set();
|
|
app._extraInputSockets = new Map();
|
|
app._persistReliableState = vi.fn();
|
|
app._persistReliableNow = vi.fn();
|
|
app._updateConnectionIndicator = vi.fn();
|
|
app.markIdleAlertSeen = vi.fn();
|
|
app.showToast = vi.fn();
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app._ws = null;
|
|
app._wsSessionId = null;
|
|
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
|
app.sessions = new Map(Object.entries(sessions));
|
|
app.activeSessionId = activeSessionId;
|
|
return app;
|
|
}
|
|
|
|
/** The tile's mount element: records its listeners by type, with their options. */
|
|
function makeMount() {
|
|
const listeners: Record<string, Array<{ fn: (ev: unknown) => void; opts: unknown }>> = {};
|
|
return {
|
|
listeners,
|
|
addEventListener: vi.fn((type: string, fn: (ev: unknown) => void, opts?: unknown) => {
|
|
(listeners[type] ||= []).push({ fn, opts });
|
|
}),
|
|
removeEventListener: vi.fn(),
|
|
fire(type: string, ev: unknown) {
|
|
for (const { fn } of listeners[type] || []) fn(ev);
|
|
},
|
|
};
|
|
}
|
|
|
|
type Tile = {
|
|
connect(): Promise<void>;
|
|
destroy(): void;
|
|
fit(opts?: { force?: boolean }): void;
|
|
ws: FakeSocket | null;
|
|
sessionId: string;
|
|
_linkHovered: boolean;
|
|
_scrollFlushTimer: unknown;
|
|
_onPtyGeometryReport(cols: number, rows: number): void;
|
|
};
|
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
|
|
|
const liveTiles: Tile[] = [];
|
|
|
|
const lines = (n: number) => Array.from({ length: n }, (_, i) => `row ${i}`).join('\r\n');
|
|
|
|
/** The capture a tile's first load receives. `captureRows` absent = the server could not say. */
|
|
function serveCapture(terminalBuffer: string, captureRows?: number) {
|
|
fetchMock.mockImplementation(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ data: { terminalBuffer, ...(captureRows === undefined ? {} : { captureRows }) } }),
|
|
}));
|
|
}
|
|
|
|
async function connectTile(app: App, opts: { sessionId?: string; mode?: string } = {}) {
|
|
windowStub.app = app;
|
|
const mount = makeMount();
|
|
const tile = new TerminalTile(opts.sessionId ?? 's-tile', mount, { mode: opts.mode ?? 'opencode' });
|
|
liveTiles.push(tile);
|
|
await tile.connect();
|
|
const ws = FakeSocket.instances.at(-1)!;
|
|
ws.open();
|
|
return { tile, ws, term: FakeTerminal.last!, mount };
|
|
}
|
|
|
|
function wheel(deltaY: number, extra: Record<string, unknown> = {}) {
|
|
return {
|
|
deltaY,
|
|
deltaX: 0,
|
|
deltaMode: 0,
|
|
shiftKey: false,
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
/** One wheel event worth `rowsOfTravel` lines (pixel mode: 25 px a line). */
|
|
const wheelLines = (rowsOfTravel: number, extra: Record<string, unknown> = {}) => wheel(rowsOfTravel * 25, extra);
|
|
|
|
const PAGE_UP = '\x1b[5~';
|
|
const PAGE_DOWN = '\x1b[6~';
|
|
|
|
/** Frames the tile's socket sent once the 40 ms coalescer has flushed. */
|
|
function flushed(ws: FakeSocket) {
|
|
vi.advanceTimersByTime(40);
|
|
return ws.inputFrames();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clock.now = () => performance.now();
|
|
vi.useFakeTimers();
|
|
FakeFit.proposed = { cols: 80, rows: 24 };
|
|
FakeSocket.instances = [];
|
|
FakeTerminal.emulateScroll = true;
|
|
fetchMock.mockReset();
|
|
serveCapture(lines(10), 24);
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
|
FakeTerminal.emulateScroll = false;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
|
it('turns half a screen of wheel-up into one ephemeral PageUp on its own socket, and down into PageDown', async () => {
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(term.buffer.active.baseY).toBe(0);
|
|
|
|
const up = wheelLines(-term.rows / 2);
|
|
mount.fire('wheel', up);
|
|
|
|
expect(up.preventDefault).toHaveBeenCalled();
|
|
expect(up.stopPropagation).toHaveBeenCalled();
|
|
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted
|
|
|
|
mount.fire('wheel', wheelLines(term.rows / 2));
|
|
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
|
|
});
|
|
|
|
it('pages a copilot tile with an empty local buffer (the stale-tracking fallback)', async () => {
|
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'copilot', cliMouseTracking: false } }, 'other');
|
|
const { ws, mount } = await connectTile(app, { mode: 'copilot' });
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it("reads the TILE's session, not the active one", async () => {
|
|
// Active session is a shell; the tile shows opencode: the tile still pages.
|
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
|
|
const { ws, mount } = await connectTile(app);
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
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');
|
|
const { ws, mount } = await connectTile(app, { mode });
|
|
|
|
const ev = wheelLines(-12);
|
|
mount.fire('wheel', ev);
|
|
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
}
|
|
);
|
|
|
|
it('still pulls history on a wheel-up at the top of a shell tile', async () => {
|
|
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
|
|
|
|
mount.fire('wheel', wheelLines(-3));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
|
|
});
|
|
|
|
it('never pages Shift, a tracking xterm, the alternate buffer, real history or a horizontal swipe', async () => {
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
|
|
mount.fire('wheel', ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
};
|
|
|
|
// The explicit "local scrollback" gesture: scrolled locally, never paged.
|
|
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
|
|
|
|
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
|
fireAndCheck(wheelLines(-12));
|
|
term.modes.mouseTrackingMode = 'none';
|
|
|
|
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
|
|
fireAndCheck(wheelLines(-12));
|
|
term.buffer.active.type = 'normal';
|
|
|
|
fireAndCheck(wheel(0, { deltaX: 120 })); // pure horizontal: nothing to page
|
|
|
|
term.write(lines(40)); // real output scrolled real lines above the screen
|
|
expect(term.buffer.active.baseY).toBeGreaterThan(0);
|
|
fireAndCheck(wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => {
|
|
const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true };
|
|
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
|
|
const AT = { clientX: 171, clientY: 101 };
|
|
const UP = '\x1b[<64;21;6M';
|
|
const DOWN = '\x1b[<65;21;6M';
|
|
|
|
it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
|
|
const up = wheelLines(-3, AT);
|
|
mount.fire('wheel', up);
|
|
|
|
expect(up.preventDefault).toHaveBeenCalled();
|
|
expect(up.stopPropagation).toHaveBeenCalled();
|
|
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
|
|
|
|
mount.fire('wheel', wheelLines(2, AT));
|
|
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
|
|
});
|
|
|
|
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheel(-100, AT));
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
|
|
|
mount.fire('wheel', wheelLines(-1000, AT));
|
|
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
|
|
});
|
|
|
|
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
|
|
|
const sideways = wheel(0, { ...AT, deltaX: 120 });
|
|
mount.fire('wheel', sideways);
|
|
expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames
|
|
expect(flushed(ws)).toHaveLength(1);
|
|
});
|
|
|
|
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheelLines(-2, AT));
|
|
vi.advanceTimersByTime(20);
|
|
mount.fire('wheel', wheelLines(-2, AT));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
|
});
|
|
|
|
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
term.buffer.active.baseY = 10;
|
|
term.buffer.active.viewportY = 0;
|
|
|
|
mount.fire('wheel', wheelLines(-1, AT));
|
|
|
|
expect(term.scrolledToBottom).toBe(1);
|
|
expect(term.buffer.active.viewportY).toBe(10);
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
|
});
|
|
|
|
it("follows the tile session's gate, never the active session's", async () => {
|
|
// Active session is a shell; the tile shows fullscreen Claude: the tile forwards.
|
|
const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), {
|
|
mode: 'claude',
|
|
});
|
|
forwarded.mount.fire('wheel', wheelLines(-1, AT));
|
|
expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]);
|
|
|
|
// Active session is fullscreen Claude; the tile shows inline Claude (no
|
|
// tracking): no reports, the hollow buffer is paged instead.
|
|
const inline = await connectTile(
|
|
makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'),
|
|
{ mode: 'claude' }
|
|
);
|
|
inline.mount.fire('wheel', wheelLines(-12, AT));
|
|
expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => {
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
|
|
mount.fire('wheel', ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
};
|
|
|
|
const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture
|
|
mount.fire('wheel', shifted);
|
|
expect(shifted.preventDefault).toHaveBeenCalled();
|
|
expect(term.scrolledLines).toEqual([-3]);
|
|
|
|
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
|
fireAndCheck(wheelLines(-3, AT));
|
|
term.modes.mouseTrackingMode = 'none';
|
|
|
|
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
|
|
fireAndCheck(wheelLines(-3, AT));
|
|
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
it('pages, never reports, a Claude older than 2.1.187', async () => {
|
|
const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } });
|
|
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheelLines(-12, AT));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
|
|
const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT));
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
|
const tileBytes: string[] = [];
|
|
for (const ev of sequence) {
|
|
mount.fire('wheel', ev);
|
|
tileBytes.push(
|
|
...flushed(ws)
|
|
.splice(tileBytes.length)
|
|
.map((f) => f.d as string)
|
|
);
|
|
}
|
|
|
|
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
|
|
terminal: unknown;
|
|
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
|
|
_wheelScrollLines(ev: unknown): number;
|
|
_flushWheelSgrQueue(): void;
|
|
};
|
|
const sent: string[] = [];
|
|
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
|
primary.terminal = new FakeTerminal({});
|
|
for (const ev of sequence) {
|
|
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
|
|
primary._flushWheelSgrQueue();
|
|
}
|
|
|
|
expect(tileBytes.join('')).not.toBe('');
|
|
expect(tileBytes).toEqual(sent);
|
|
});
|
|
});
|
|
|
|
describe("Shift+wheel scrolls the tile's local scrollback itself", () => {
|
|
// xterm's own scroller turns a Shift+vertical wheel into a horizontal one off
|
|
// macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so
|
|
// left to xterm the gesture moved nothing.
|
|
it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => {
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode });
|
|
term.write(lines(40));
|
|
const bottom = term.buffer.active.baseY;
|
|
expect(bottom).toBeGreaterThan(5);
|
|
|
|
const up = wheelLines(-3, { shiftKey: true });
|
|
mount.fire('wheel', up);
|
|
|
|
expect(up.preventDefault).toHaveBeenCalled();
|
|
expect(up.stopPropagation).toHaveBeenCalled();
|
|
expect(term.buffer.active.viewportY).toBe(bottom - 3);
|
|
mount.fire('wheel', wheelLines(3, { shiftKey: true }));
|
|
expect(term.buffer.active.viewportY).toBe(bottom);
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => {
|
|
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true }));
|
|
|
|
expect(term.scrolledLines).toEqual([-4]);
|
|
});
|
|
|
|
it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => {
|
|
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line
|
|
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
|
expect(term.scrolledLines).toEqual([]);
|
|
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
|
|
|
expect(term.scrolledLines).toEqual([-1]);
|
|
});
|
|
|
|
it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => {
|
|
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
|
|
|
|
mount.fire('wheel', wheelLines(-3, { shiftKey: true }));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
|
|
});
|
|
});
|
|
|
|
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
|
it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => {
|
|
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
|
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
|
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('pages a Claude tile whose CLI version is unknown', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude', cliMouseTracking: true } }), {
|
|
mode: 'claude',
|
|
});
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
});
|
|
|
|
describe('page-key travel, cap and coalescing', () => {
|
|
beforeEach(() => {
|
|
FakeFit.proposed = { cols: 80, rows: 36 };
|
|
});
|
|
|
|
it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
mount.fire('wheel', wheelLines(-10));
|
|
expect(flushed(ws)).toEqual([]);
|
|
mount.fire('wheel', wheelLines(-10));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('caps one wheel at three PageUps, in one frame', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
mount.fire('wheel', wheelLines(-1000));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(3) }]);
|
|
});
|
|
|
|
it('sends two pages queued within 40 ms as one frame', async () => {
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
mount.fire('wheel', wheelLines(-18));
|
|
vi.advanceTimersByTime(20);
|
|
mount.fire('wheel', wheelLines(-18));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(2) }]);
|
|
});
|
|
|
|
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
|
|
const sequence = [-10, -10, -4, 30, -1000, 7, -18, 200].map((n) => wheelLines(n));
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
for (const ev of sequence) mount.fire('wheel', ev);
|
|
const tileBytes = flushed(ws)
|
|
.map((f) => f.d)
|
|
.join('');
|
|
|
|
const primary = makeApp({ other: { mode: 'opencode' } }, '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 } },
|
|
};
|
|
for (const ev of sequence) primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev));
|
|
primary._flushWheelSgrQueue();
|
|
|
|
expect(tileBytes).not.toBe('');
|
|
expect(tileBytes).toBe(sent.join(''));
|
|
});
|
|
});
|
|
|
|
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
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(term.buffer.active.baseY).toBe(16); // 40 lines into a 24-row xterm
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('does not page a capture that carried real history', async () => {
|
|
serveCapture(lines(60), 24);
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(term.buffer.active.baseY).toBeGreaterThan(0);
|
|
|
|
const ev = wheelLines(-12);
|
|
mount.fire('wheel', ev);
|
|
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
it('falls back to the raw baseY when the server sent no captureRows', async () => {
|
|
serveCapture(lines(40));
|
|
const tall = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(tall.term.buffer.active.baseY).toBe(16);
|
|
tall.mount.fire('wheel', wheelLines(-12));
|
|
expect(flushed(tall.ws)).toEqual([]);
|
|
|
|
serveCapture(lines(10));
|
|
const short = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(short.term.buffer.active.baseY).toBe(0);
|
|
short.mount.fire('wheel', wheelLines(-12));
|
|
expect(flushed(short.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('keeps paging after a row-shrinking fit pushes more rows up', async () => {
|
|
serveCapture(lines(40), 40);
|
|
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
FakeFit.proposed = { cols: 80, rows: 20 }; // a zoom-out or a divider drag
|
|
tile.fit();
|
|
expect(term.buffer.active.baseY).toBe(20);
|
|
|
|
mount.fire('wheel', wheelLines(-10));
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('stops paging once output scrolls real lines above the screen', async () => {
|
|
serveCapture(lines(40), 40);
|
|
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
ws.receive({ t: 'o', d: 'a\r\nb\r\nc\r\n' }); // three real lines scrolled off
|
|
|
|
const ev = wheelLines(-12);
|
|
mount.fire('wheel', ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
it('forgets the overflow when a server clear refreshes the tile, so later real history counts', async () => {
|
|
// A `{t:'c'}` is a refresh (a fresh Claude pane's first prompt), and its
|
|
// in-stream reset leaves nothing above the screen; the fresh capture is one
|
|
// line, so it adds no overflow of its own.
|
|
serveCapture(lines(40), 40);
|
|
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
expect(term.buffer.active.baseY).toBe(16);
|
|
|
|
serveCapture('fresh screen', 24);
|
|
ws.receive({ t: 'c' });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']);
|
|
expect(term.buffer.active.baseY).toBe(0);
|
|
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
|
|
expect(term.buffer.active.baseY).toBe(2);
|
|
|
|
const ev = wheelLines(-12);
|
|
mount.fire('wheel', ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
it('leaves the wheel to xterm while the viewport sits in those rows, and pages again from the bottom', async () => {
|
|
// Hollow by the discount alone: baseY > 0, every row above the screen the
|
|
// tile's own. Shift+PageUp, a scrollbar drag or a wheel during the replay
|
|
// can leave the viewport up there; a primary hollow buffer (baseY 0) never
|
|
// can. Paging from there would swallow every wheel and keep the stale rows
|
|
// on screen, so xterm gets the wheel and a wheel-down brings it home.
|
|
serveCapture(lines(40), 40);
|
|
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
const baseY = term.buffer.active.baseY;
|
|
expect(baseY).toBe(16);
|
|
expect((tile as unknown as { _localRows(): number })._localRows()).toBe(0);
|
|
|
|
term.buffer.active.viewportY = baseY - 5;
|
|
for (const ev of [wheelLines(-12), wheelLines(12), wheelLines(-5), wheelLines(-5)]) {
|
|
mount.fire('wheel', ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
}
|
|
expect(flushed(ws)).toEqual([]);
|
|
|
|
// Back on the live screen, only travel made there counts: two quarter-screen
|
|
// wheels are one PageUp, with nothing carried over from the wheels xterm had
|
|
// (the gate sits before the pending travel is touched).
|
|
term.buffer.active.viewportY = baseY;
|
|
const first = wheelLines(-6);
|
|
mount.fire('wheel', first);
|
|
expect(first.preventDefault).toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
mount.fire('wheel', wheelLines(-6));
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
|
|
it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => {
|
|
serveCapture(lines(40), 40);
|
|
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
// A narrower width wraps the overflow rows onto more rows, as xterm's reflow does.
|
|
term.afterResize = () => {
|
|
term.lineCount += 3;
|
|
term.settleRows();
|
|
};
|
|
|
|
tile._onPtyGeometryReport(60, 40);
|
|
expect(term.cols).toBe(60);
|
|
expect(term.buffer.active.baseY).toBe(19);
|
|
|
|
mount.fire('wheel', wheelLines(-12));
|
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
|
});
|
|
});
|
|
|
|
describe('a tile reports a plain click to a CLI with mouse tracking on', () => {
|
|
const click = (overrides: Record<string, unknown> = {}) => ({
|
|
isTrusted: true,
|
|
button: 0,
|
|
detail: 1,
|
|
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
|
|
clientX: 171,
|
|
clientY: 101,
|
|
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
|
...overrides,
|
|
});
|
|
const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m';
|
|
|
|
it("sends one ephemeral SGR press+release on the tile's socket, from the tile's own geometry", async () => {
|
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
|
const { ws, mount } = await connectTile(app);
|
|
|
|
mount.fire('click', click());
|
|
|
|
// No seq: like every mouse report from a tile, it never enters the
|
|
// persisted exactly-once queue, so a reload cannot replay it.
|
|
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'undefined']]);
|
|
expect((app._pendingDeliveries as Map<string, unknown[]>).get('s-tile')?.length ?? 0).toBe(0);
|
|
});
|
|
|
|
it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => {
|
|
const app = makeApp({ 's-tile': { mode: 'opencode' } });
|
|
const { tile, ws, term, mount } = await connectTile(app);
|
|
|
|
mount.fire('click', click()); // the CLI has no tracking mode on
|
|
app.sessions.set('s-tile', { mode: 'opencode', cliMouseTracking: true });
|
|
|
|
term.selection = 'picked';
|
|
mount.fire('click', click()); // a drag-selection just ended
|
|
term.selection = '';
|
|
|
|
tile._linkHovered = true;
|
|
mount.fire('click', click()); // the link provider opens this one
|
|
tile._linkHovered = false;
|
|
|
|
term.buffer.active.baseY = 10;
|
|
term.buffer.active.viewportY = 0;
|
|
mount.fire('click', click()); // would hit-test a different row
|
|
term.buffer.active.viewportY = 10;
|
|
|
|
expect(ws.inputFrames()).toEqual([]);
|
|
|
|
mount.fire('click', click()); // nothing in the way any more
|
|
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
|
});
|
|
|
|
it("is not blocked by the primary pane's own link hover", async () => {
|
|
const app = makeApp({ 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
|
app._linkHovered = true;
|
|
const { ws, mount } = await connectTile(app);
|
|
|
|
mount.fire('click', click());
|
|
|
|
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
|
});
|
|
|
|
it("follows the tile session's flag, never the active session's", async () => {
|
|
const reported = await connectTile(
|
|
makeApp({
|
|
other: { mode: 'claude', cliMouseTracking: false },
|
|
's-tile': { mode: 'opencode', cliMouseTracking: true },
|
|
})
|
|
);
|
|
reported.mount.fire('click', click());
|
|
expect(reported.ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
|
|
|
const silent = await connectTile(
|
|
makeApp({
|
|
other: { mode: 'claude', cliMouseTracking: true },
|
|
's-tile': { mode: 'opencode', cliMouseTracking: false },
|
|
})
|
|
);
|
|
silent.mount.fire('click', click());
|
|
expect(silent.ws.inputFrames()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('destroy()', () => {
|
|
it('drops queued page keys and detaches both listeners it registered', async () => {
|
|
const { tile, ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
mount.fire('wheel', wheelLines(-12)); // queued, not yet flushed
|
|
const wheelFn = mount.listeners.wheel[0].fn;
|
|
const clickFn = mount.listeners.click[0].fn;
|
|
|
|
tile.destroy();
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(ws.inputFrames()).toEqual([]);
|
|
expect(tile._scrollFlushTimer).toBeNull();
|
|
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', wheelFn, { capture: true });
|
|
expect(mount.removeEventListener).toHaveBeenCalledWith('click', clickFn);
|
|
});
|
|
|
|
it('registers the wheel listener non-passive in the capture phase and the click one in the bubble phase', async () => {
|
|
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
|
|
|
expect(mount.listeners.wheel.map((l) => l.opts)).toEqual([{ capture: true, passive: false }]);
|
|
expect(mount.listeners.click.map((l) => l.opts)).toEqual([undefined]);
|
|
});
|
|
});
|