mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.
The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
arrival time, as _pullHistory already did, and the body read of a bounded
window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.
The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.
Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
616 lines
24 KiB
TypeScript
616 lines
24 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.
|
|
*
|
|
* 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 { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
|
|
|
|
const fetchMock = vi.fn();
|
|
|
|
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: '' },
|
|
};
|
|
const context = vm.createContext({
|
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
|
performance,
|
|
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(() => {
|
|
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("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', 'codex', '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();
|
|
};
|
|
|
|
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
|
|
|
|
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("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
|
it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => {
|
|
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
|
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
|
|
|
const ev = wheelLines(-12);
|
|
mount.fire('wheel', ev);
|
|
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
expect(flushed(ws)).toEqual([]);
|
|
});
|
|
|
|
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('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]);
|
|
});
|
|
});
|