mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
feat(terminal): replay 10,000 retained rows without cell-grid copies
Serialize the worker-owned terminal directly so larger retained history does not require a second object grid. Commit input modes with parsed output so replay bytes and metadata describe the same revision.
This commit is contained in:
@@ -19,13 +19,14 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Invariants (the easy-to-break ones)
|
||||
|
||||
- **Replay is incremental across the daemon and SDK.** The worker retains 1,000 history rows. Capable clients request their configured history; the worker bounds it by retained history. Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||
- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||
- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations.
|
||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
|
||||
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
|
||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
|
||||
@@ -15,6 +15,8 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. This addon omits cursor visibility and DECSCUSR presentation, so the session supplements those sequences from xterm’s cursor state. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
Generated
+8
@@ -14740,6 +14740,12 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||
@@ -37703,6 +37709,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -40682,6 +40689,7 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -62,8 +62,8 @@ test.describe("retained terminal tab streams", () => {
|
||||
});
|
||||
|
||||
for (const { count, retainedMarker } of [
|
||||
{ count: 400, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 3000, retainedMarker: "history-2500界🙂" },
|
||||
{ count: 3000, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 12050, retainedMarker: "history-3000界🙂" },
|
||||
]) {
|
||||
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
|
||||
page,
|
||||
@@ -87,7 +87,12 @@ test.describe("retained terminal tab streams", () => {
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
const restored = await getTerminalBufferText(page);
|
||||
expect(restored).toContain(retainedMarker);
|
||||
expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual(
|
||||
Math.min(count, 10000),
|
||||
);
|
||||
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
createNativeHeadlessTerminal,
|
||||
type TerminalCellRow,
|
||||
type TerminalViewportState,
|
||||
} from "./headless-terminal-state";
|
||||
import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload";
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
NATIVE_TERMINAL_PARSE_CHUNK_CHARS,
|
||||
} from "./headless-terminal-output-drain";
|
||||
import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { Terminal as ReplaySource } from "@xterm/headless";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
|
||||
const ROWS = 12;
|
||||
const COLS = 80;
|
||||
@@ -42,33 +42,6 @@ function createFrameRecorder() {
|
||||
};
|
||||
}
|
||||
|
||||
function lineCells(line: string, cols: number): TerminalCellRow {
|
||||
const cells: TerminalCellRow = [];
|
||||
for (let index = 0; index < cols; index += 1) {
|
||||
cells.push({ char: line[index] ?? " ", width: 1 });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function terminalStateFromLines(input: {
|
||||
lines: string[];
|
||||
rows: number;
|
||||
cols: number;
|
||||
cursor: TerminalViewportState["cursor"];
|
||||
}) {
|
||||
const grid: TerminalCellRow[] = [];
|
||||
for (let row = 0; row < input.rows; row += 1) {
|
||||
grid.push(lineCells(input.lines[row] ?? "", input.cols));
|
||||
}
|
||||
return {
|
||||
rows: input.rows,
|
||||
cols: input.cols,
|
||||
grid,
|
||||
scrollback: [],
|
||||
cursor: input.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
async function runScheduledWork(input: {
|
||||
callbacks: Array<() => void>;
|
||||
flush: Promise<void>;
|
||||
@@ -81,11 +54,13 @@ async function runScheduledWork(input: {
|
||||
}
|
||||
|
||||
async function benchmarkSnapshot(lineCount: number) {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 });
|
||||
await terminal.write(
|
||||
createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }),
|
||||
);
|
||||
return terminal.getState();
|
||||
return {
|
||||
rows: ROWS,
|
||||
cols: COLS,
|
||||
data: new TextEncoder().encode(
|
||||
createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createScreenRestorer(
|
||||
@@ -112,7 +87,7 @@ function createScreenRestorer(
|
||||
drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols });
|
||||
drain.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(snapshot)),
|
||||
data: snapshot.data,
|
||||
});
|
||||
drain.replay({ type: "end" });
|
||||
await drain.flush();
|
||||
@@ -220,26 +195,22 @@ describe("native terminal output drain", () => {
|
||||
scheduleFrame: frameRecorder.scheduleFrame,
|
||||
cancelFrame: frameRecorder.cancelFrame,
|
||||
});
|
||||
const restoredSnapshot = terminalStateFromLines({
|
||||
rows: 4,
|
||||
cols: 32,
|
||||
lines: ["restored-one", "restored-two"],
|
||||
cursor: { row: 1, col: "restored-two".length },
|
||||
});
|
||||
const restoredCursor = { row: 1, col: "restored-two".length };
|
||||
const replay = new TextEncoder().encode("restored-one\r\nrestored-two");
|
||||
|
||||
await terminal.write("old-one\r\nold-two\r\nold-three\r\n");
|
||||
drain.enqueueText("stale-queued\r\n");
|
||||
drain.replay({ type: "begin" });
|
||||
drain.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(restoredSnapshot)),
|
||||
data: replay,
|
||||
});
|
||||
drain.replay({ type: "end" });
|
||||
await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() });
|
||||
|
||||
const state = terminal.getViewportState();
|
||||
expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]);
|
||||
expect(state.cursor).toEqual(restoredSnapshot.cursor);
|
||||
expect(state.cursor).toEqual(restoredCursor);
|
||||
});
|
||||
|
||||
test("snapshot restore preserves a scrolled viewport anchor while replacing terminal state", async () => {
|
||||
@@ -257,7 +228,7 @@ describe("native terminal output drain", () => {
|
||||
paintedMode: screen?.scroll.mode,
|
||||
paintedTopRow: rowText(screen?.viewport.grid[0] ?? []),
|
||||
beforeTopRow,
|
||||
restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText),
|
||||
restoredBottomRows: terminal.getViewportState().grid.slice(-2).map(rowText),
|
||||
}).toEqual({
|
||||
paintedMode: "scrolled",
|
||||
paintedTopRow: beforeTopRow,
|
||||
@@ -379,7 +350,7 @@ test("replay geometry returns to measured layout before live writes and unchange
|
||||
const sizes: number[] = [];
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
write: async (text) => {
|
||||
sizes.push(terminal.getState().cols);
|
||||
sizes.push(terminal.getViewportState().cols);
|
||||
await terminal.write(text);
|
||||
},
|
||||
reset: (size) => {
|
||||
@@ -404,10 +375,10 @@ test("replay geometry returns to measured layout before live writes and unchange
|
||||
});
|
||||
expect(focus.measuredSizeChanged).toBe(false);
|
||||
expect(focus.resizeClaim).toMatchObject(measured);
|
||||
expect(terminal.getState()).toMatchObject(measured);
|
||||
expect(terminal.getViewportState()).toMatchObject(measured);
|
||||
expect(terminal.getBufferBounds().cols).toBe(40);
|
||||
const lines = terminal
|
||||
.getState()
|
||||
.getViewportState()
|
||||
.grid.map((row) =>
|
||||
row
|
||||
.map((cell) => cell.char)
|
||||
@@ -463,7 +434,7 @@ test.each(["clear", "replacement", "dispose"] as const)(
|
||||
await writing;
|
||||
local.cols = 60;
|
||||
drain.resize();
|
||||
expect(terminal.getState().cols).toBe(120);
|
||||
expect(terminal.getViewportState().cols).toBe(120);
|
||||
drain.replay({ type: "end" });
|
||||
if (action === "clear") drain.clear();
|
||||
else if (action === "dispose") {
|
||||
@@ -477,7 +448,7 @@ test.each(["clear", "replacement", "dispose"] as const)(
|
||||
finish();
|
||||
await drain.flush();
|
||||
expect(restores).toBe(action === "dispose" ? 0 : 1);
|
||||
if (action !== "dispose") expect(terminal.getState()).toMatchObject(local);
|
||||
if (action !== "dispose") expect(terminal.getViewportState()).toMatchObject(local);
|
||||
} finally {
|
||||
finish();
|
||||
drain.dispose();
|
||||
@@ -487,9 +458,13 @@ test.each(["clear", "replacement", "dispose"] as const)(
|
||||
);
|
||||
|
||||
test("holds captured geometry across mid-replay measurement changes", async () => {
|
||||
const source = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
await source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H");
|
||||
const ansi = new TextEncoder().encode(renderTerminalSnapshotToAnsi(source.getState()));
|
||||
const source = new ReplaySource({ rows: 4, cols: 8, scrollback: 100, allowProposedApi: true });
|
||||
const serializer = new SerializeAddon();
|
||||
source.loadAddon(serializer);
|
||||
await new Promise<void>((resolve) =>
|
||||
source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H", resolve),
|
||||
);
|
||||
const ansi = new TextEncoder().encode(serializer.serialize());
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
await expected.write(new TextDecoder().decode(ansi));
|
||||
@@ -513,7 +488,20 @@ test("holds captured geometry across mid-replay measurement changes", async () =
|
||||
drain.replay({ type: "data", data: ansi });
|
||||
drain.replay({ type: "end" });
|
||||
await drain.flush();
|
||||
expect(terminal.getState()).toEqual(expected.getState());
|
||||
expect(terminal.getViewportState()).toEqual(expected.getViewportState());
|
||||
const actualBounds = terminal.getBufferBounds();
|
||||
const expectedBounds = expected.getBufferBounds();
|
||||
expect(
|
||||
terminal.getBufferWindow({
|
||||
startRow: actualBounds.oldestRow,
|
||||
rowCount: actualBounds.newestRow - actualBounds.oldestRow + 1,
|
||||
}),
|
||||
).toEqual(
|
||||
expected.getBufferWindow({
|
||||
startRow: expectedBounds.oldestRow,
|
||||
rowCount: expectedBounds.newestRow - expectedBounds.oldestRow + 1,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
drain.dispose();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface NativeHeadlessTerminalOptions {
|
||||
|
||||
export interface NativeHeadlessTerminal {
|
||||
write(data: NativeTerminalWriteData): Promise<void>;
|
||||
getState(): TerminalState;
|
||||
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState;
|
||||
getInputModeState(): NativeHeadlessTerminalInputModeState;
|
||||
getBufferBounds(): TerminalBufferBounds;
|
||||
@@ -165,35 +164,6 @@ function extractRow(
|
||||
return rowCells;
|
||||
}
|
||||
|
||||
function extractGrid(terminal: HeadlessTerminalInstance): TerminalCellRow[] {
|
||||
const grid: TerminalCellRow[] = [];
|
||||
const baseY = terminal.buffer.active.baseY;
|
||||
const reusableCell = terminal.buffer.active.getNullCell();
|
||||
for (let row = 0; row < terminal.rows; row += 1) {
|
||||
grid.push(extractRow(terminal, baseY + row, reusableCell));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function extractScrollback(
|
||||
terminal: HeadlessTerminalInstance,
|
||||
options: { scrollbackLines?: number },
|
||||
): TerminalCellRow[] {
|
||||
const scrollback: TerminalCellRow[] = [];
|
||||
const scrollbackLines = terminal.buffer.active.baseY;
|
||||
const startRow =
|
||||
typeof options.scrollbackLines === "number"
|
||||
? Math.max(0, scrollbackLines - options.scrollbackLines)
|
||||
: 0;
|
||||
const reusableCell = terminal.buffer.active.getNullCell();
|
||||
|
||||
for (let row = startRow; row < scrollbackLines; row += 1) {
|
||||
scrollback.push(extractRow(terminal, row, reusableCell));
|
||||
}
|
||||
|
||||
return scrollback;
|
||||
}
|
||||
|
||||
function extractBufferWindow(
|
||||
terminal: HeadlessTerminalInstance,
|
||||
oldestRow: number,
|
||||
@@ -290,19 +260,6 @@ function extractInputModeState(
|
||||
};
|
||||
}
|
||||
|
||||
function extractState(
|
||||
terminal: HeadlessTerminalInstance,
|
||||
options: { scrollbackLines?: number },
|
||||
): TerminalState {
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
grid: extractGrid(terminal),
|
||||
scrollback: extractScrollback(terminal, options),
|
||||
cursor: extractCursorState(terminal),
|
||||
};
|
||||
}
|
||||
|
||||
function extractViewportState(
|
||||
terminal: HeadlessTerminalInstance,
|
||||
coordinateEpoch: number,
|
||||
@@ -380,9 +337,6 @@ export function createNativeHeadlessTerminal(
|
||||
});
|
||||
});
|
||||
},
|
||||
getState(): TerminalState {
|
||||
return extractState(terminal, { scrollbackLines: options.scrollbackLines });
|
||||
},
|
||||
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState {
|
||||
return extractViewportState(
|
||||
terminal,
|
||||
|
||||
@@ -54,16 +54,19 @@ describe("native terminal scrollback retention", () => {
|
||||
scrollbackLines: SCROLLBACK,
|
||||
lineCount: SCROLLBACK + ROWS + 10,
|
||||
});
|
||||
const state = terminal.getState();
|
||||
const bounds = terminal.getBufferBounds();
|
||||
|
||||
expect(state.scrollback.length).toBeGreaterThan(0);
|
||||
expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK);
|
||||
const historyRows = bounds.bottomViewport.firstRow - bounds.oldestRow;
|
||||
expect(historyRows).toBeGreaterThan(0);
|
||||
expect(historyRows).toBeLessThanOrEqual(SCROLLBACK);
|
||||
expect(bounds.newestRow).toBeGreaterThan(ROWS);
|
||||
expect(bounds.oldestRow).toBeGreaterThan(0);
|
||||
expect(bounds.newestRow - bounds.oldestRow + 1).toBe(
|
||||
state.scrollback.length + state.grid.length,
|
||||
);
|
||||
const retained = terminal.getBufferWindow({
|
||||
startRow: bounds.oldestRow,
|
||||
rowCount: SCROLLBACK + ROWS + 1,
|
||||
});
|
||||
expect(retained.rows).toHaveLength(SCROLLBACK + ROWS);
|
||||
expect(rowText(retained.rows[0]!)).toBe(expectedBenchmarkLine(bounds.oldestRow));
|
||||
});
|
||||
|
||||
test("observable scrollback count is bounded by configured lines", async () => {
|
||||
@@ -73,9 +76,9 @@ describe("native terminal scrollback retention", () => {
|
||||
scrollbackLines: SCROLLBACK,
|
||||
lineCount: SCROLLBACK * 3,
|
||||
});
|
||||
const state = terminal.getState();
|
||||
|
||||
expect(state.scrollback.length).toBe(SCROLLBACK);
|
||||
const bounds = terminal.getBufferBounds();
|
||||
expect(bounds.bottomViewport.firstRow - bounds.oldestRow).toBe(SCROLLBACK);
|
||||
});
|
||||
|
||||
test("scroll up moves viewport into retained history", async () => {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { Terminal as ReplaySource } from "@xterm/xterm";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
// Regression: "streaming pino log, resized the Paseo terminal, old logs stayed
|
||||
// narrow and new logs drew on top of the old ones."
|
||||
//
|
||||
// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a
|
||||
// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi.
|
||||
// When the snapshot carries per-row soft-wrap flags, restored long lines must
|
||||
// reflow on resize just like live output — not freeze at the snapshot width.
|
||||
// Backpressure triggers an ANSI replay serialized directly from xterm. Replayed
|
||||
// soft-wrapped lines must reflow on resize like live output.
|
||||
|
||||
interface Mounted {
|
||||
host: HTMLDivElement;
|
||||
@@ -90,12 +88,15 @@ function someRowContains(term: InspectableTerminal, text: string): boolean {
|
||||
return dumpRows(term).some((r) => r.text.includes(text));
|
||||
}
|
||||
|
||||
function replayCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
|
||||
function replayCommitted(
|
||||
runtime: TerminalEmulatorRuntime,
|
||||
state: { rows: number; cols: number; data: Uint8Array },
|
||||
) {
|
||||
return new Promise<void>((resolve) => {
|
||||
runtime.replay({ type: "begin", rows: state.rows, cols: state.cols });
|
||||
runtime.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(state)),
|
||||
data: state.data,
|
||||
});
|
||||
runtime.replay({ type: "end" });
|
||||
runtime.write({ data: new Uint8Array([0]), onCommitted: resolve });
|
||||
@@ -108,31 +109,18 @@ function writeCommitted(runtime: TerminalEmulatorRuntime, data: Uint8Array) {
|
||||
});
|
||||
}
|
||||
|
||||
// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped
|
||||
// at `cols`, represented as multiple grid rows (exactly what the daemon stores in
|
||||
// its headless xterm and ships in a snapshot).
|
||||
function buildWrappedSnapshot(cols: number): TerminalState {
|
||||
const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`;
|
||||
const grid: TerminalState["grid"] = [];
|
||||
for (let i = 0; i < longLine.length; i += cols) {
|
||||
const chunk = longLine.slice(i, i + cols);
|
||||
grid.push([...chunk].map((char) => ({ char })));
|
||||
// Serialize one long pino line from a real server-width xterm buffer.
|
||||
// The replay must retain its soft-wrap boundaries for later client reflow.
|
||||
async function buildWrappedSnapshot(cols: number) {
|
||||
const source = new ReplaySource({ rows: 24, cols, allowProposedApi: true });
|
||||
const serializer = new SerializeAddon();
|
||||
source.loadAddon(serializer);
|
||||
try {
|
||||
await new Promise<void>((resolve) => source.write(pinoLine(0), resolve));
|
||||
return { rows: 24, cols, data: new TextEncoder().encode(serializer.serialize()) };
|
||||
} finally {
|
||||
source.dispose();
|
||||
}
|
||||
// The fixed daemon ships per-row soft-wrap flags: every row of the logical line
|
||||
// continues onto the next except the last.
|
||||
const gridWrapped = grid.map((_, index) => index < grid.length - 1);
|
||||
// Cursor sits just below the restored content, exactly where the daemon's
|
||||
// headless terminal leaves it after a line break, so streamed output appends
|
||||
// cleanly below instead of overwriting the restored rows.
|
||||
return {
|
||||
rows: 24,
|
||||
cols,
|
||||
scrollback: [],
|
||||
scrollbackWrapped: [],
|
||||
grid,
|
||||
gridWrapped,
|
||||
cursor: { row: grid.length, col: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function pinoLine(seq: number): string {
|
||||
@@ -147,15 +135,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("terminal resize reflow repro (Paseo terminal)", () => {
|
||||
it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
|
||||
it("replayed soft-wrapped rows reflow after the terminal grows", async () => {
|
||||
await page.viewport(1600, 700);
|
||||
const m = mount(560, 360); // ~70 cols
|
||||
await waitFor(() => m.terminal.cols > 40);
|
||||
const narrowCols = m.terminal.cols;
|
||||
|
||||
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
|
||||
// long line wrapped at the server width as separate grid rows.
|
||||
await replayCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
|
||||
// 1) A mid-stream ANSI replay arrives, preserving wraps at the captured width.
|
||||
await replayCommitted(m.runtime, await buildWrappedSnapshot(narrowCols));
|
||||
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
|
||||
|
||||
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless";
|
||||
import { Terminal as ClientTerminal } from "@xterm/xterm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
|
||||
interface SnapshotCell {
|
||||
char: string;
|
||||
@@ -122,7 +122,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S
|
||||
}
|
||||
|
||||
describe("terminal-snapshot", () => {
|
||||
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
|
||||
it("replays upstream serialized ANSI into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
|
||||
const source = new HeadlessTerminal({
|
||||
rows: 4,
|
||||
cols: 12,
|
||||
@@ -138,6 +138,8 @@ describe("terminal-snapshot", () => {
|
||||
await writeToTerminal(source, "\u001b[2 q");
|
||||
await writeToTerminal(source, "\u001b[?25l");
|
||||
|
||||
const serializer = new SerializeAddon();
|
||||
source.loadAddon(serializer);
|
||||
const snapshot = extractState(source);
|
||||
|
||||
const client = new ClientTerminal({
|
||||
@@ -147,7 +149,7 @@ describe("terminal-snapshot", () => {
|
||||
scrollback: 100,
|
||||
});
|
||||
|
||||
await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot));
|
||||
await writeToTerminal(client, serializer.serialize() + "\x1b[2 q\x1b[?25l");
|
||||
|
||||
expect(extractState(client)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||
import type { TerminalState } from "./messages";
|
||||
|
||||
function cells(text: string): TerminalState["grid"][number] {
|
||||
return [...text].map((char) => ({ char }));
|
||||
}
|
||||
|
||||
describe("renderTerminalSnapshotToAnsi", () => {
|
||||
it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => {
|
||||
// The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into
|
||||
// two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1.
|
||||
const state: TerminalState = {
|
||||
rows: 2,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
scrollbackWrapped: [],
|
||||
grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")],
|
||||
gridWrapped: [true, false],
|
||||
cursor: { row: 1, col: 6 },
|
||||
};
|
||||
|
||||
const ansi = renderTerminalSnapshotToAnsi(state);
|
||||
|
||||
// The rows must arrive unbroken so xterm re-wraps them itself (and can later
|
||||
// reflow them) — no hard newline injected between "...IJ" and "KL...".
|
||||
expect(ansi).toContain("ABCDEFGHIJKLMNOP");
|
||||
// Auto-wrap must stay enabled; disabling it (ESC[?7l) is what makes xterm mark
|
||||
// the rows non-wrapped and refuse to reflow them on resize.
|
||||
expect(ansi).not.toContain("[?7l");
|
||||
});
|
||||
|
||||
it("falls back to verbatim per-row replay when wrap flags are absent (old daemon)", () => {
|
||||
// No gridWrapped/scrollbackWrapped: the client cannot tell soft-wraps from hard
|
||||
// newlines, so it must keep today's exact behaviour rather than guess.
|
||||
const state: TerminalState = {
|
||||
rows: 2,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")],
|
||||
cursor: { row: 1, col: 6 },
|
||||
};
|
||||
|
||||
const ansi = renderTerminalSnapshotToAnsi(state);
|
||||
|
||||
expect(ansi).toContain("[?7l");
|
||||
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTerminalSnapshotToAnsi wide chars", () => {
|
||||
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
|
||||
// The daemon captures "통" as the glyph followed by an empty continuation
|
||||
// cell. Emitting anything for that cell pushes the rest of the row right.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
|
||||
cursor: { row: 0, col: 5 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
|
||||
});
|
||||
|
||||
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
|
||||
// Old daemons already collapsed the continuation to " ". The serializer
|
||||
// cannot tell that apart from a real space, so it must not guess.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [cells("통 합 !")],
|
||||
cursor: { row: 0, col: 6 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { TerminalCell, TerminalState } from "./messages.js";
|
||||
|
||||
interface TerminalStyle {
|
||||
fg: number | undefined;
|
||||
bg: number | undefined;
|
||||
fgMode: number | undefined;
|
||||
bgMode: number | undefined;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
dim: boolean;
|
||||
inverse: boolean;
|
||||
strikethrough: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE: TerminalStyle = {
|
||||
fg: undefined,
|
||||
bg: undefined,
|
||||
fgMode: undefined,
|
||||
bgMode: undefined,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
strikethrough: false,
|
||||
};
|
||||
|
||||
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
|
||||
const rows = [...state.scrollback, ...state.grid];
|
||||
const wrapFlags = [...(state.scrollbackWrapped ?? []), ...(state.gridWrapped ?? [])];
|
||||
// Soft-wrapped lines can only be re-wrapped on resize when we know which rows
|
||||
// were continuations. With that per-row flag we replay each logical line as one
|
||||
// unbroken run (autowrap on) so xterm marks the continuations wrapped and reflows
|
||||
// them. Without it (old daemon) we keep the verbatim per-row replay: autowrap off
|
||||
// plus a hard newline per row.
|
||||
const hasWrapInfo = wrapFlags.length === rows.length;
|
||||
const lines: string[] = hasWrapInfo ? [] : ["\u001b[?7l"];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
const row = rows[rowIndex] ?? [];
|
||||
const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true;
|
||||
// A continuation row must fill the full width so the next row first cell
|
||||
// triggers xterm auto-wrap, which is what marks the row wrapped/reflowable.
|
||||
lines.push(renderTerminalRow(row, continuesToNextRow ? state.cols : undefined));
|
||||
if (rowIndex < rows.length - 1 && !continuesToNextRow) {
|
||||
lines.push("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("\u001b[0m");
|
||||
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
|
||||
if (cursorPresentationAnsi) {
|
||||
lines.push(cursorPresentationAnsi);
|
||||
}
|
||||
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
|
||||
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
|
||||
if (!hasWrapInfo) {
|
||||
lines.push("\u001b[?7h");
|
||||
}
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
|
||||
if (!cursor.style) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursorStyleCode = (() => {
|
||||
if (cursor.style === "block") {
|
||||
return cursor.blink === false ? 2 : 1;
|
||||
}
|
||||
if (cursor.style === "underline") {
|
||||
return cursor.blink === false ? 4 : 3;
|
||||
}
|
||||
return cursor.blink === false ? 6 : 5;
|
||||
})();
|
||||
|
||||
return `\u001b[${cursorStyleCode} q`;
|
||||
}
|
||||
|
||||
function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
|
||||
const output: string[] = [];
|
||||
const contentLength = getTerminalRowLength(row);
|
||||
const length = padToCols !== undefined ? Math.max(contentLength, padToCols) : contentLength;
|
||||
let previousStyle = DEFAULT_STYLE;
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const cell = row[index] ?? { char: " " };
|
||||
// An empty char marks a wide-char continuation cell. The glyph in the
|
||||
// previous cell already advances the cursor two columns, so emitting
|
||||
// anything here would shift the rest of the row one column right.
|
||||
if (cell.char === "") {
|
||||
continue;
|
||||
}
|
||||
const nextStyle = getTerminalStyle(cell);
|
||||
if (!terminalStylesEqual(previousStyle, nextStyle)) {
|
||||
output.push(styleToAnsi(nextStyle));
|
||||
previousStyle = nextStyle;
|
||||
}
|
||||
output.push(cell.char || " ");
|
||||
}
|
||||
|
||||
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
|
||||
output.push("\u001b[0m");
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function getTerminalRowLength(row: TerminalCell[]): number {
|
||||
for (let index = row.length - 1; index >= 0; index -= 1) {
|
||||
const cell = row[index];
|
||||
if (!cell) {
|
||||
continue;
|
||||
}
|
||||
if (cell.char !== " ") {
|
||||
return index + 1;
|
||||
}
|
||||
if (
|
||||
cell.fg !== undefined ||
|
||||
cell.bg !== undefined ||
|
||||
cell.fgMode !== undefined ||
|
||||
cell.bgMode !== undefined ||
|
||||
cell.bold ||
|
||||
cell.italic ||
|
||||
cell.underline ||
|
||||
cell.dim ||
|
||||
cell.inverse ||
|
||||
cell.strikethrough
|
||||
) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
|
||||
return {
|
||||
fg: cell.fg,
|
||||
bg: cell.bg,
|
||||
fgMode: cell.fgMode,
|
||||
bgMode: cell.bgMode,
|
||||
bold: Boolean(cell.bold),
|
||||
italic: Boolean(cell.italic),
|
||||
underline: Boolean(cell.underline),
|
||||
dim: Boolean(cell.dim),
|
||||
inverse: Boolean(cell.inverse),
|
||||
strikethrough: Boolean(cell.strikethrough),
|
||||
};
|
||||
}
|
||||
|
||||
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
|
||||
return (
|
||||
left.fg === right.fg &&
|
||||
left.bg === right.bg &&
|
||||
left.fgMode === right.fgMode &&
|
||||
left.bgMode === right.bgMode &&
|
||||
left.bold === right.bold &&
|
||||
left.italic === right.italic &&
|
||||
left.underline === right.underline &&
|
||||
left.dim === right.dim &&
|
||||
left.inverse === right.inverse &&
|
||||
left.strikethrough === right.strikethrough
|
||||
);
|
||||
}
|
||||
|
||||
function styleToAnsi(style: TerminalStyle): string {
|
||||
const codes = ["0"];
|
||||
|
||||
if (style.bold) {
|
||||
codes.push("1");
|
||||
}
|
||||
if (style.dim) {
|
||||
codes.push("2");
|
||||
}
|
||||
if (style.italic) {
|
||||
codes.push("3");
|
||||
}
|
||||
if (style.underline) {
|
||||
codes.push("4");
|
||||
}
|
||||
if (style.inverse) {
|
||||
codes.push("7");
|
||||
}
|
||||
if (style.strikethrough) {
|
||||
codes.push("9");
|
||||
}
|
||||
|
||||
if (style.fg !== undefined && style.fgMode !== undefined) {
|
||||
codes.push(...colorToSgr(style.fgMode, style.fg, false));
|
||||
}
|
||||
|
||||
if (style.bg !== undefined && style.bgMode !== undefined) {
|
||||
codes.push(...colorToSgr(style.bgMode, style.bg, true));
|
||||
}
|
||||
|
||||
return `\u001b[${codes.join(";")}m`;
|
||||
}
|
||||
|
||||
function colorToSgr(mode: number, value: number, background: boolean): string[] {
|
||||
if (mode === 1) {
|
||||
if (value >= 8) {
|
||||
return [String((background ? 100 : 90) + (value - 8))];
|
||||
}
|
||||
return [String((background ? 40 : 30) + value)];
|
||||
}
|
||||
|
||||
if (mode === 2) {
|
||||
return [background ? "48" : "38", "5", String(value)];
|
||||
}
|
||||
|
||||
if (mode === 3) {
|
||||
return [
|
||||
background ? "48" : "38",
|
||||
"2",
|
||||
String((value >> 16) & 0xff),
|
||||
String((value >> 8) & 0xff),
|
||||
String(value & 0xff),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -116,6 +116,7 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -1867,7 +1867,7 @@ describe("list terminals across directories", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([400, 3000])(
|
||||
test.each([3000, 12050])(
|
||||
"chunked SDK replay retains requested history for %s printed lines",
|
||||
async (count) => {
|
||||
const cwd = tmpCwd();
|
||||
@@ -1909,7 +1909,7 @@ test.each([400, 3000])(
|
||||
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
|
||||
expect(replay[0]).toMatchObject({
|
||||
type: "begin",
|
||||
retainedHistoryLines: count === 3000 ? 1000 : expect.any(Number),
|
||||
retainedHistoryLines: count === 12050 ? 10000 : expect.any(Number),
|
||||
});
|
||||
expect(replay.at(-1)).toEqual({ type: "end" });
|
||||
const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : []));
|
||||
@@ -1918,9 +1918,9 @@ test.each([400, 3000])(
|
||||
const begin = replay[0];
|
||||
if (begin?.type !== "begin" || begin.rows === undefined)
|
||||
throw new Error("Missing replay dimensions");
|
||||
expect(text.match(/replay-\d{4}/g)).toHaveLength(Math.min(count, 1000 + begin.rows - 1));
|
||||
expect(text.match(/replay-\d{4,5}/g)).toHaveLength(Math.min(count, 10000 + begin.rows - 1));
|
||||
expect(text).toContain(`replay-${String(count).padStart(4, "0")}界🙂`);
|
||||
if (count === 400) expect(text).toContain("replay-0001界🙂");
|
||||
if (count === 3000) expect(text).toContain("replay-0001界🙂");
|
||||
else {
|
||||
expect(text).not.toContain("replay-0001");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { TerminalCell } from "@getpaseo/protocol/messages";
|
||||
import type { LocalTerminalSession } from "./terminal.js";
|
||||
import type { IBuffer } from "@xterm/headless";
|
||||
|
||||
export interface CaptureTerminalLinesOptions {
|
||||
start?: number;
|
||||
@@ -13,14 +12,6 @@ export interface CaptureTerminalLinesResult {
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
|
||||
const text = cells
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
return options.stripAnsi ? stripAnsi(text) : text;
|
||||
}
|
||||
|
||||
function resolveCaptureLineIndex(
|
||||
lineNumber: number | undefined,
|
||||
totalLines: number,
|
||||
@@ -46,14 +37,10 @@ function resolveCaptureLineIndex(
|
||||
}
|
||||
|
||||
export function captureTerminalLines(
|
||||
terminal: Pick<LocalTerminalSession, "getState">,
|
||||
buffer: IBuffer,
|
||||
options: CaptureTerminalLinesOptions = {},
|
||||
): CaptureTerminalLinesResult {
|
||||
const state = terminal.getState();
|
||||
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
|
||||
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
|
||||
);
|
||||
const totalLines = allLines.length;
|
||||
const totalLines = buffer.length;
|
||||
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
|
||||
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
|
||||
|
||||
@@ -64,8 +51,10 @@ export function captureTerminalLines(
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lines: allLines.slice(startIndex, endIndex + 1),
|
||||
totalLines,
|
||||
};
|
||||
const lines: string[] = [];
|
||||
for (let row = startIndex; row <= endIndex; row++) {
|
||||
const text = buffer.getLine(row)!.translateToString(true).trimEnd();
|
||||
lines.push(options.stripAnsi === false ? text : stripAnsi(text));
|
||||
}
|
||||
return { lines, totalLines };
|
||||
}
|
||||
|
||||
@@ -457,7 +457,6 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi
|
||||
|
||||
expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
|
||||
expect(session.getTitle()).toBe("Existing title");
|
||||
expect(session.getState().title).toBe("Existing title");
|
||||
expect(snapshots).toEqual([]);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type TerminalActivityTransition,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
|
||||
import { type CaptureTerminalLinesResult } from "./terminal-capture.js";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { resolve, sep } from "node:path";
|
||||
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
|
||||
@@ -489,7 +489,7 @@ export function createTerminalManager(
|
||||
totalLines: 0,
|
||||
};
|
||||
}
|
||||
return captureTerminalLines(session, options);
|
||||
return session.captureLines(options);
|
||||
},
|
||||
|
||||
listDirectories(): string[] {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
TerminalWorkerRequest,
|
||||
TerminalWorkerToParentMessage,
|
||||
} from "./terminal-worker-protocol.js";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import type { LocalTerminalSession } from "./terminal.js";
|
||||
|
||||
export interface TerminalReplayOptions {
|
||||
@@ -28,15 +27,7 @@ export function* createTerminalReplay(
|
||||
session: LocalTerminalSession,
|
||||
options: TerminalReplayOptions | undefined,
|
||||
): Generator<TerminalReplayEvent, TerminalReplayMetadata> {
|
||||
const snapshot = session.getStateSnapshot({ ...options, includeWrapFlags: true });
|
||||
const metadata = {
|
||||
rows: snapshot.state.rows,
|
||||
cols: snapshot.state.cols,
|
||||
retainedHistoryLines: session.getRetainedHistoryLines(),
|
||||
revision: snapshot.revision,
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
};
|
||||
const data = Buffer.from(renderTerminalSnapshotToAnsi(snapshot.state), "utf8");
|
||||
const { metadata, data } = session.serializeReplay(options);
|
||||
yield { type: "begin", metadata };
|
||||
for (let offset = 0; offset < data.length; offset += TERMINAL_REPLAY_CHUNK_BYTES) {
|
||||
yield { type: "data", data: data.subarray(offset, offset + TERMINAL_REPLAY_CHUNK_BYTES) };
|
||||
|
||||
@@ -9,15 +9,9 @@ import {
|
||||
encodeTerminalRestoreFrame,
|
||||
resolveRestoreAfterOutputOverflow,
|
||||
resolveTerminalRestoreSnapshotOptions,
|
||||
resolveTerminalSubscriptionSnapshotMode,
|
||||
} from "./terminal-restore.js";
|
||||
|
||||
describe("terminal restore policy", () => {
|
||||
test("uses ready snapshots only for restore-aware subscriptions", () => {
|
||||
expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state");
|
||||
expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready");
|
||||
});
|
||||
|
||||
test("resolves bounded restore snapshot options", () => {
|
||||
expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull();
|
||||
expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined();
|
||||
|
||||
@@ -21,14 +21,6 @@ const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500;
|
||||
|
||||
export type TerminalRestoreOptions = NonNullable<SubscribeTerminalRequest["restore"]>;
|
||||
|
||||
export type TerminalSubscriptionSnapshotMode = "state" | "ready";
|
||||
|
||||
export function resolveTerminalSubscriptionSnapshotMode(
|
||||
restore: TerminalRestoreOptions | undefined,
|
||||
): TerminalSubscriptionSnapshotMode {
|
||||
return restore ? "ready" : "state";
|
||||
}
|
||||
|
||||
export function resolveRestoreAfterOutputOverflow(
|
||||
restore: TerminalRestoreOptions | undefined,
|
||||
): TerminalRestoreOptions | undefined {
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
createTerminalRestoreDelivery,
|
||||
resolveRestoreAfterOutputOverflow,
|
||||
resolveTerminalRestoreSnapshotOptions,
|
||||
resolveTerminalSubscriptionSnapshotMode,
|
||||
type TerminalRestoreOptions,
|
||||
} from "./terminal-restore.js";
|
||||
import type { TerminalReplayMetadata } from "./terminal-replay.js";
|
||||
@@ -948,48 +947,45 @@ export class TerminalSessionController {
|
||||
|
||||
this.activeStreams.set(slot, activeStream);
|
||||
|
||||
const unsubscribeOutput = terminal.subscribe(
|
||||
(message) => {
|
||||
if (this.activeStreams.get(slot) !== activeStream) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "snapshot" || message.type === "snapshotReady") {
|
||||
activeStream.snapshotReady = true;
|
||||
activeStream.outputCoalescer.flush();
|
||||
activeStream.needsSnapshot = true;
|
||||
void this.trySendSnapshot(activeStream);
|
||||
return;
|
||||
}
|
||||
if (message.type === "titleChange") {
|
||||
return;
|
||||
}
|
||||
if (message.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
|
||||
activeStream.bufferedOutputBytes += Buffer.byteLength(message.data);
|
||||
if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) {
|
||||
activeStream.owner.emit({
|
||||
type: "terminal_stream_exit",
|
||||
payload: {
|
||||
terminalId: activeStream.terminalId,
|
||||
error: "Terminal replay live tail exceeded the buffer; attach again",
|
||||
},
|
||||
});
|
||||
this.detachRegistration(activeStream);
|
||||
void activeStream.owner.release();
|
||||
return;
|
||||
}
|
||||
activeStream.bufferedOutputs.push({
|
||||
data: message.data,
|
||||
revision: message.revision,
|
||||
const unsubscribeOutput = terminal.subscribe((message) => {
|
||||
if (this.activeStreams.get(slot) !== activeStream) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "snapshotReady") {
|
||||
activeStream.snapshotReady = true;
|
||||
activeStream.outputCoalescer.flush();
|
||||
activeStream.needsSnapshot = true;
|
||||
void this.trySendSnapshot(activeStream);
|
||||
return;
|
||||
}
|
||||
if (message.type === "titleChange") {
|
||||
return;
|
||||
}
|
||||
if (message.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
|
||||
activeStream.bufferedOutputBytes += Buffer.byteLength(message.data);
|
||||
if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) {
|
||||
activeStream.owner.emit({
|
||||
type: "terminal_stream_exit",
|
||||
payload: {
|
||||
terminalId: activeStream.terminalId,
|
||||
error: "Terminal replay live tail exceeded the buffer; attach again",
|
||||
},
|
||||
});
|
||||
this.detachRegistration(activeStream);
|
||||
void activeStream.owner.release();
|
||||
return;
|
||||
}
|
||||
activeStream.outputCoalescer.handle(message.data);
|
||||
},
|
||||
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
|
||||
);
|
||||
activeStream.bufferedOutputs.push({
|
||||
data: message.data,
|
||||
revision: message.revision,
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeStream.outputCoalescer.handle(message.data);
|
||||
});
|
||||
const unsubscribeExit = terminal.onExit(() =>
|
||||
this.detachStream(terminal.id, { emitExit: true }),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { currentBootId } from "../server/pid-lock.js";
|
||||
import { PRIVATE_FILE_MODE } from "../server/private-files.js";
|
||||
import { createTerminalManager } from "./terminal-manager.js";
|
||||
import { captureTerminalLines } from "./terminal-capture.js";
|
||||
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
||||
import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js";
|
||||
import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js";
|
||||
@@ -330,29 +329,22 @@ function runWorker(argv: string[]): void {
|
||||
});
|
||||
outputCoalescerByTerminalId.set(session.id, outputCoalescer);
|
||||
|
||||
const unsubscribeMessage = session.subscribe(
|
||||
(message) => {
|
||||
if (message.type === "snapshot") return;
|
||||
if (message.type === "output") {
|
||||
pendingOutputRevision = message.revision;
|
||||
outputCoalescer.handle(message.data);
|
||||
return;
|
||||
}
|
||||
// Non-output messages (snapshot/snapshotReady/titleChange) must not jump
|
||||
// ahead of buffered output: flush the coalescer first, then forward.
|
||||
outputCoalescer.flush();
|
||||
sendToController({
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
message,
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
});
|
||||
},
|
||||
// Creation already sends an authoritative state in terminalCreated. A second
|
||||
// asynchronous state snapshot can arrive after the create response and
|
||||
// overwrite a resize issued immediately by the caller.
|
||||
{ initialSnapshot: "ready" },
|
||||
);
|
||||
const unsubscribeMessage = session.subscribe((message) => {
|
||||
if (message.type === "output") {
|
||||
pendingOutputRevision = message.revision;
|
||||
outputCoalescer.handle(message.data);
|
||||
return;
|
||||
}
|
||||
// Non-output messages (snapshotReady/titleChange) must not jump
|
||||
// ahead of buffered output: flush the coalescer first, then forward.
|
||||
outputCoalescer.flush();
|
||||
sendToController({
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
message,
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
});
|
||||
});
|
||||
const unsubscribeExit = session.onExit((info) => {
|
||||
outputCoalescer.flush();
|
||||
clearTerminalSubscriptions(session.id);
|
||||
@@ -568,7 +560,7 @@ function runWorker(argv: string[]): void {
|
||||
case "captureTerminal": {
|
||||
const session = manager.getTerminal(message.terminalId);
|
||||
const result = session
|
||||
? captureTerminalLines(session, {
|
||||
? session.captureLines({
|
||||
start: message.start,
|
||||
end: message.end,
|
||||
stripAnsi: message.stripAnsi,
|
||||
|
||||
@@ -176,7 +176,7 @@ export type TerminalWorkerEvent =
|
||||
| {
|
||||
type: "terminalMessage";
|
||||
terminalId: string;
|
||||
message: Exclude<ServerMessage, { type: "snapshot" }>;
|
||||
message: ServerMessage;
|
||||
replayPreamble: string;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readTerminalState } from "./test-utils/terminal-state.js";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
// POSIX-only: node-pty + POSIX shell assertions
|
||||
/* eslint-disable max-nested-callbacks */
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
@@ -6,7 +8,7 @@ import {
|
||||
buildTerminalEnvironment,
|
||||
createTerminal,
|
||||
resolveZshShellIntegrationDir,
|
||||
type TerminalSession,
|
||||
type LocalTerminalSession as TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import {
|
||||
chmodSync,
|
||||
@@ -28,7 +30,7 @@ if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
||||
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||
}
|
||||
|
||||
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
|
||||
type TerminalRow = TerminalState["grid"][number];
|
||||
|
||||
function rowToText(row: TerminalRow): string {
|
||||
return row
|
||||
@@ -38,12 +40,12 @@ function rowToText(row: TerminalRow): string {
|
||||
}
|
||||
|
||||
// Extract text from a single row
|
||||
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||
function getRowText(state: TerminalState, rowIndex: number): string {
|
||||
return rowToText(state.grid[rowIndex]);
|
||||
}
|
||||
|
||||
// Extract all visible lines as array (trimmed, empty lines included)
|
||||
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||
function getLines(state: TerminalState): string[] {
|
||||
return state.grid.map(rowToText);
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ async function waitForLines(
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const lines = getLines(session.getState());
|
||||
const lines = getLines(await readTerminalState(session));
|
||||
let matches = true;
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
if (lines[i] !== expectedLines[i]) {
|
||||
@@ -68,7 +70,7 @@ async function waitForLines(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
||||
const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length);
|
||||
throw new Error(
|
||||
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
|
||||
);
|
||||
@@ -76,12 +78,12 @@ async function waitForLines(
|
||||
|
||||
async function waitForState(
|
||||
session: TerminalSession,
|
||||
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
||||
predicate: (state: TerminalState) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ReturnType<TerminalSession["getState"]>> {
|
||||
): Promise<TerminalState> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
if (predicate(state)) {
|
||||
return state;
|
||||
}
|
||||
@@ -232,19 +234,19 @@ function isOsc11OkLine(line: string): boolean {
|
||||
return line.startsWith("OSC11_OK:");
|
||||
}
|
||||
|
||||
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
function hasDaOkLine(state: TerminalState): boolean {
|
||||
return getLines(state).some(isDaOkLine);
|
||||
}
|
||||
|
||||
function hasDsrOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
function hasDsrOkLine(state: TerminalState): boolean {
|
||||
return getLines(state).some(isDsrOkLine);
|
||||
}
|
||||
|
||||
function hasOsc11OkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
function hasOsc11OkLine(state: TerminalState): boolean {
|
||||
return getLines(state).some(isOsc11OkLine);
|
||||
}
|
||||
|
||||
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
function lastNonEmptyLineIsPrompt(state: TerminalState): boolean {
|
||||
const last =
|
||||
getLines(state)
|
||||
.toReversed()
|
||||
@@ -322,7 +324,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
// Line 2: "$"
|
||||
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(getRowText(state, 0)).toBe("$ echo hello");
|
||||
expect(getRowText(state, 1)).toBe("hello");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
@@ -346,7 +348,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: "echo second\r" });
|
||||
await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(getRowText(state, 0)).toBe("$ echo first");
|
||||
expect(getRowText(state, 1)).toBe("first");
|
||||
expect(getRowText(state, 2)).toBe("$ echo second");
|
||||
@@ -370,7 +372,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(getRowText(state, 0)).toBe("$ pwd");
|
||||
expect(getRowText(state, 1)).toBe("/tmp");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
@@ -431,7 +433,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
expect(seenTitles).toContain("npm run dev");
|
||||
expect(session.getTitle()).toBe("npm run dev");
|
||||
expect(session.getState().title).toBe("npm run dev");
|
||||
expect(session.getTitle()).toBe("npm run dev");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -457,7 +459,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
expect(seenTitles).toContain("Build Log");
|
||||
expect(session.getTitle()).toBe("Build Log");
|
||||
expect(session.getState().title).toBe("Build Log");
|
||||
expect(session.getTitle()).toBe("Build Log");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -478,7 +480,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
expect(session.getTitle()).toBe("typecheck");
|
||||
expect(session.getState().title).toBe("typecheck");
|
||||
expect(session.getTitle()).toBe("typecheck");
|
||||
});
|
||||
|
||||
it("emits command completion from VS Code OSC 633 without visible output", async () => {
|
||||
@@ -507,7 +509,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([7]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -541,7 +543,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([3]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -696,7 +698,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await waitForState(session, () => commandCompletions.includes(1));
|
||||
|
||||
expect(commandCompletions).toEqual([1]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -720,7 +722,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
@@ -750,7 +752,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check O cell
|
||||
@@ -776,7 +778,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check R cell
|
||||
@@ -805,7 +807,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("B");
|
||||
@@ -815,7 +817,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("receives a snapshot on initial subscription", async () => {
|
||||
it("receives readiness on initial subscription", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -831,7 +833,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messages[0].type).toBe("snapshot");
|
||||
expect(messages[0].type).toBe("snapshotReady");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -861,12 +863,14 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||
|
||||
expect(messages.some((message) => message.type === "output")).toBe(true);
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
expect(
|
||||
messages.every((message) => message.type === "output" || message.type === "titleChange"),
|
||||
).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not emit snapshot messages for resize-only updates", async () => {
|
||||
it("keeps resize updates in the live observation lifecycle", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -889,13 +893,15 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "resize", rows: 30, cols: 100 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
expect(
|
||||
messages.every((message) => message.type === "output" || message.type === "titleChange"),
|
||||
).toBe(true);
|
||||
expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("emits output only after getState reflects the new data", async () => {
|
||||
it("emits output only after text capture reflects the new data", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -912,7 +918,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
const stateText = getLines(session.getState()).join("\n");
|
||||
const stateText = session.captureLines().lines.join("\n");
|
||||
resolve(stateText.includes("state-after-output"));
|
||||
});
|
||||
});
|
||||
@@ -965,7 +971,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` });
|
||||
await waitForState(session, hasDaOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDaOkLine) ?? "";
|
||||
const ack = getLines(await readTerminalState(session)).find(isDaOkLine) ?? "";
|
||||
expect(ack).toMatch(/^DA_OK:\[\?[\d;]+c$/);
|
||||
});
|
||||
|
||||
@@ -1003,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toMatch(/^DSR_OK:\d+:\d+$/);
|
||||
});
|
||||
|
||||
@@ -1023,7 +1029,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toMatch(/^DSR_OK:\d+:\d+$/);
|
||||
});
|
||||
|
||||
@@ -1043,7 +1049,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toBe("DSR_OK:status");
|
||||
});
|
||||
|
||||
@@ -1063,7 +1069,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` });
|
||||
await waitForState(session, hasOsc11OkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isOsc11OkLine) ?? "";
|
||||
const ack = getLines(await readTerminalState(session)).find(isOsc11OkLine) ?? "";
|
||||
expect(ack).toBe("OSC11_OK:ESC]11;rgb:0b0b/0b0b/0b0bESC\\");
|
||||
});
|
||||
});
|
||||
@@ -1097,7 +1103,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
|
||||
it("announces readiness for a fresh replay instead of replaying raw live output", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -1121,26 +1127,24 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
"$",
|
||||
]);
|
||||
|
||||
let snapshotText = "";
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type !== "snapshot") {
|
||||
return;
|
||||
}
|
||||
snapshotText = [...message.state.scrollback, ...message.state.grid]
|
||||
.map(rowToText)
|
||||
.join("\n");
|
||||
let ready!: () => void;
|
||||
const readiness = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(snapshotText).toContain("before-detach");
|
||||
expect(snapshotText).toContain("after-detach");
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type === "snapshotReady") ready();
|
||||
});
|
||||
await readiness;
|
||||
const state = await readTerminalState(session);
|
||||
const text = [...state.scrollback, ...state.grid].map(rowToText).join("\n");
|
||||
expect(text).toContain("before-detach");
|
||||
expect(text).toContain("after-detach");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getState", () => {
|
||||
it("returns current terminal state with grid", async () => {
|
||||
describe("ANSI replay observation", () => {
|
||||
it("reconstructs current terminal state from ANSI", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -1152,7 +1156,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
|
||||
expect(state.rows).toBe(24);
|
||||
expect(state.cols).toBe(80);
|
||||
@@ -1179,19 +1183,16 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" });
|
||||
|
||||
const state = await waitForState(
|
||||
session,
|
||||
(current) =>
|
||||
current.cursor.style === "block" &&
|
||||
current.cursor.blink === false &&
|
||||
current.cursor.hidden === true,
|
||||
const deadline = Date.now() + 5000;
|
||||
while (
|
||||
!new TextDecoder().decode(session.serializeReplay().data).includes("\x1b[?25l\x1b[2 q")
|
||||
) {
|
||||
if (Date.now() > deadline) throw new Error("cursor presentation did not reach replay");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
expect(new TextDecoder().decode(session.serializeReplay().data)).toContain(
|
||||
"\x1b[?25l\x1b[2 q",
|
||||
);
|
||||
|
||||
expect(state.cursor).toMatchObject({
|
||||
style: "block",
|
||||
blink: false,
|
||||
hidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("grid cells have char and color attributes", async () => {
|
||||
@@ -1206,7 +1207,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
// First cell should be "$"
|
||||
expect(state.grid[0][0].char).toBe("$");
|
||||
expect(state.grid[0][0]).toHaveProperty("fg");
|
||||
@@ -1238,7 +1239,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
// The visible area will show something like: 17, 18, 19, 20, $
|
||||
await waitForLines(session, ["17", "18", "19", "20", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
|
||||
// Scrollback should contain the earlier output
|
||||
expect(state.scrollback.length).toBeGreaterThan(0);
|
||||
@@ -1269,7 +1270,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
session.kill();
|
||||
|
||||
// Should not throw when trying to get state after kill
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(state).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { readTerminalState } from "./test-utils/terminal-state.js";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
@@ -10,7 +13,7 @@ import {
|
||||
humanizeProcessTitle,
|
||||
normalizeProcessTitle,
|
||||
resolveZshShellIntegrationDir,
|
||||
type TerminalSession,
|
||||
type LocalTerminalSession as TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import {
|
||||
chmodSync,
|
||||
@@ -26,7 +29,6 @@ import {
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as pty from "node-pty";
|
||||
import xterm from "@xterm/headless";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
|
||||
@@ -34,7 +36,7 @@ import { stripVTControlCharacters } from "node:util";
|
||||
|
||||
const hasZsh = existsSync("/bin/zsh");
|
||||
|
||||
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
|
||||
type TerminalRow = TerminalState["grid"][number];
|
||||
|
||||
function rowToText(row: TerminalRow): string {
|
||||
return row
|
||||
@@ -44,12 +46,12 @@ function rowToText(row: TerminalRow): string {
|
||||
}
|
||||
|
||||
// Extract text from a single row
|
||||
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||
function getRowText(state: TerminalState, rowIndex: number): string {
|
||||
return rowToText(state.grid[rowIndex]);
|
||||
}
|
||||
|
||||
// Extract all visible lines as array (trimmed, empty lines included)
|
||||
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||
function getLines(state: TerminalState): string[] {
|
||||
return state.grid.map(rowToText);
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ async function waitForLines(
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const lines = getLines(session.getState());
|
||||
const lines = getLines(await readTerminalState(session));
|
||||
let matches = true;
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
if (lines[i] !== expectedLines[i]) {
|
||||
@@ -78,7 +80,7 @@ async function waitForLines(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
||||
const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length);
|
||||
throw new Error(
|
||||
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
|
||||
);
|
||||
@@ -86,12 +88,12 @@ async function waitForLines(
|
||||
|
||||
async function waitForState(
|
||||
session: TerminalSession,
|
||||
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
||||
predicate: (state: TerminalState) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ReturnType<TerminalSession["getState"]>> {
|
||||
): Promise<TerminalState> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
if (predicate(state)) {
|
||||
return state;
|
||||
}
|
||||
@@ -473,7 +475,7 @@ describe("createTerminal", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(state.rows).toBe(24);
|
||||
expect(state.cols).toBe(80);
|
||||
});
|
||||
@@ -493,12 +495,12 @@ describe("createTerminal", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(state.rows).toBe(40);
|
||||
expect(state.cols).toBe(120);
|
||||
});
|
||||
|
||||
it("reports per-row soft-wrap flags only when wrap flags are requested", async () => {
|
||||
it("preserves soft-wrap boundaries through public replay", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
@@ -513,21 +515,15 @@ describe("createTerminal", () => {
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5000) {
|
||||
if (rowToText(session.getState().grid[2]).startsWith("A")) {
|
||||
if (rowToText((await readTerminalState(session)).grid[2]).startsWith("A")) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Rows 0 and 1 continue onto the next row; row 2 is the end of the logical line.
|
||||
const withFlags = session.getState({ includeWrapFlags: true });
|
||||
const withFlags = await readTerminalState(session);
|
||||
expect(withFlags.gridWrapped?.slice(0, 3)).toEqual([true, true, false]);
|
||||
|
||||
// Back-compat gate: without the capability the daemon must not attach the new
|
||||
// fields, so an old strict-schema client still parses the snapshot.
|
||||
const withoutFlags = session.getState();
|
||||
expect(withoutFlags.gridWrapped).toBeUndefined();
|
||||
expect(withoutFlags.scrollbackWrapped).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => {
|
||||
@@ -555,9 +551,7 @@ describe("createTerminal", () => {
|
||||
// row back the way a selection copy would.
|
||||
const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true });
|
||||
try {
|
||||
await new Promise<void>((resolve) =>
|
||||
restored.write(renderTerminalSnapshotToAnsi(state), resolve),
|
||||
);
|
||||
await new Promise<void>((resolve) => restored.write(session.serializeReplay().data, resolve));
|
||||
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text);
|
||||
} finally {
|
||||
restored.dispose();
|
||||
@@ -615,11 +609,8 @@ describe("createTerminal", () => {
|
||||
|
||||
// Poll the parsed buffer until the final line lands, so the exit summary
|
||||
// (which reads the headless buffer) can't race the async xterm parse.
|
||||
const finalLineParsed = (): boolean => {
|
||||
const state = session.getState();
|
||||
const rowText = (row: { char: string }[]): string => row.map((cell) => cell.char).join("");
|
||||
return [...state.scrollback, ...state.grid].some((row) => rowText(row).includes("line-2999"));
|
||||
};
|
||||
const finalLineParsed = (): boolean =>
|
||||
session.captureLines({ start: -12 }).lines.some((line) => line.includes("line-2999"));
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!finalLineParsed()) {
|
||||
if (Date.now() > deadline) {
|
||||
@@ -678,7 +669,7 @@ describe("createTerminal", () => {
|
||||
);
|
||||
|
||||
await waitForLines(session, ["paseo-launch-ok"]);
|
||||
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
|
||||
expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -721,7 +712,7 @@ describe("createTerminal", () => {
|
||||
);
|
||||
|
||||
await waitForLines(session, ["paseo-launch-ok"]);
|
||||
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
|
||||
expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -743,7 +734,7 @@ describe("createTerminal", () => {
|
||||
);
|
||||
|
||||
await waitForLines(session, ["paseo-launch-ok"]);
|
||||
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
|
||||
expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok");
|
||||
});
|
||||
|
||||
it("leaves the command check to the terminal itself on Windows", async () => {
|
||||
@@ -807,7 +798,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||
// Line 2: "$"
|
||||
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(getRowText(state, 0)).toBe("$ echo hello");
|
||||
expect(getRowText(state, 1)).toBe("hello");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
@@ -829,7 +820,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||
|
||||
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(getRowText(state, 0)).toBe("$ pwd");
|
||||
expect(getRowText(state, 1)).toBe("/tmp");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
@@ -890,7 +881,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
expect(seenTitles).toContain("npm run dev");
|
||||
expect(session.getTitle()).toBe("npm run dev");
|
||||
expect(session.getState().title).toBe("npm run dev");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -916,7 +906,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
expect(seenTitles).toContain("Build Log");
|
||||
expect(session.getTitle()).toBe("Build Log");
|
||||
expect(session.getState().title).toBe("Build Log");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -937,7 +926,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
expect(session.getTitle()).toBe("typecheck");
|
||||
expect(session.getState().title).toBe("typecheck");
|
||||
});
|
||||
|
||||
it("emits command completion from VS Code OSC 633 without visible output", async () => {
|
||||
@@ -966,7 +954,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([7]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -1000,7 +988,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([3]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -1155,7 +1143,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
await waitForState(session, () => commandCompletions.includes(1));
|
||||
|
||||
expect(commandCompletions).toEqual([1]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
|
||||
expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
@@ -1189,7 +1177,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -1219,7 +1206,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -1248,7 +1234,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
@@ -1272,7 +1257,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
@@ -1302,7 +1287,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check R cell
|
||||
@@ -1331,7 +1316,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("B");
|
||||
@@ -1355,7 +1340,7 @@ describe("resize", () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(state.rows).toBe(40);
|
||||
expect(state.cols).toBe(120);
|
||||
});
|
||||
@@ -1373,7 +1358,7 @@ describe("resize", () => {
|
||||
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const state = session.getState();
|
||||
const state = await readTerminalState(session);
|
||||
expect(state.grid.length).toBe(10);
|
||||
expect(state.grid[0].length).toBe(40);
|
||||
});
|
||||
@@ -1552,7 +1537,7 @@ it.runIf(isPlatform("linux", "darwin"))(
|
||||
env: { PATH: "bin" },
|
||||
});
|
||||
await waitForLines(session, ["paseo-launch-ok"]);
|
||||
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
|
||||
expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok");
|
||||
expect(session.cwd).toBe(realpathSync(projectDir));
|
||||
expect(session.getExitInfo()).toBeNull();
|
||||
} finally {
|
||||
@@ -1574,3 +1559,118 @@ it.runIf(isPlatform("linux", "darwin"))(
|
||||
).rejects.toThrow(" node : command not found");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"serializes styled Unicode, cursor presentation and modes directly (alternate=%s)",
|
||||
async (alternate) => {
|
||||
const content =
|
||||
Array.from({ length: 8 }, (_, i) => "normal-" + i + "\r\n").join("") +
|
||||
(alternate ? "\x1b[?1049h\x1b[H" : "") +
|
||||
"\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l\x1b[2;7H";
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "serialize",
|
||||
rows: 4,
|
||||
cols: 16,
|
||||
command: process.execPath,
|
||||
args: ["-e", `process.stdout.write(${JSON.stringify(content)});setInterval(()=>{},1000)`],
|
||||
}),
|
||||
);
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) {
|
||||
if (Date.now() > deadline) throw new Error("source content did not parse");
|
||||
await delay(20);
|
||||
}
|
||||
const replay = session.serializeReplay();
|
||||
const ansi = new TextDecoder().decode(replay.data);
|
||||
expect(ansi).toContain("\x1b[?25l\x1b[5 q");
|
||||
expect(ansi).not.toContain("NaN");
|
||||
expect(replay.metadata.retainedHistoryLines).toBeGreaterThan(0);
|
||||
const receiver = new xterm.Terminal({
|
||||
rows: 4,
|
||||
cols: 16,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) => receiver.write(replay.data, resolve));
|
||||
await new Promise<void>((resolve) => receiver.write(replay.metadata.replayPreamble, resolve));
|
||||
expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal");
|
||||
const lines = Array.from({ length: receiver.buffer.active.length }, (_, i) =>
|
||||
receiver.buffer.active.getLine(i)!.translateToString(true),
|
||||
);
|
||||
expect(lines.join("\n")).toContain("界é🙂");
|
||||
expect(lines.join("\n")).toContain("VIEW-READY");
|
||||
const colored = receiver.buffer.active.getLine(receiver.buffer.active.baseY)!.getCell(0)!;
|
||||
if (alternate) {
|
||||
expect(colored.getChars()).toBe("界");
|
||||
expect(colored.getFgColor()).toBe(0xff8040);
|
||||
expect(colored.isUnderline()).not.toBe(0);
|
||||
expect(receiver.buffer.normal.getLine(0)!.translateToString(true)).toBe("normal-0");
|
||||
}
|
||||
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({
|
||||
row: 1,
|
||||
col: 6,
|
||||
});
|
||||
expect(receiver.modes).toMatchObject({
|
||||
applicationCursorKeysMode: true,
|
||||
bracketedPasteMode: true,
|
||||
wraparoundMode: false,
|
||||
mouseTrackingMode: "vt200",
|
||||
});
|
||||
const modes = new TerminalInputModeTracker();
|
||||
modes.feed(ansi + replay.metadata.replayPreamble);
|
||||
expect(modes.getState()).toMatchObject({
|
||||
kittyKeyboardFlags: 1,
|
||||
win32InputMode: true,
|
||||
applicationCursorKeys: true,
|
||||
bracketedPaste: true,
|
||||
});
|
||||
} finally {
|
||||
receiver.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("captures input modes at the same committed cut as serialized bytes", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "mode-cut",
|
||||
rows: 4,
|
||||
cols: 80,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
'process.stdin.on("data",()=>process.stdout.write("x".repeat(1024*1024)+"\x1b[?1hMODE-CUT"));setInterval(()=>{},1000)',
|
||||
],
|
||||
}),
|
||||
);
|
||||
const mismatches: string[] = [];
|
||||
let completed!: () => void;
|
||||
const output = new Promise<void>((resolve) => {
|
||||
completed = resolve;
|
||||
});
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type !== "output") return;
|
||||
if (session.getReplayPreamble().includes("\x1b[?1h")) {
|
||||
const cut = session.serializeReplay({ scrollbackLines: 0 });
|
||||
if (!new TextDecoder().decode(cut.data).includes("\x1b[?1h"))
|
||||
mismatches.push(cut.metadata.replayPreamble);
|
||||
}
|
||||
if (message.data.includes("MODE-CUT")) completed();
|
||||
});
|
||||
try {
|
||||
session.send({ type: "input", data: "go\r" });
|
||||
await Promise.race([
|
||||
output,
|
||||
delay(5000).then(() => {
|
||||
throw new Error("mode cut did not complete");
|
||||
}),
|
||||
]);
|
||||
expect(mismatches).toEqual([]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,7 +25,13 @@ import {
|
||||
findExecutable,
|
||||
findExecutableWithoutProbing,
|
||||
} from "../executable-resolution/executable-resolution.js";
|
||||
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import {
|
||||
captureTerminalLines,
|
||||
type CaptureTerminalLinesOptions,
|
||||
type CaptureTerminalLinesResult,
|
||||
} from "./terminal-capture.js";
|
||||
import type { TerminalReplayOptions, TerminalReplayMetadata } from "./terminal-replay.js";
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
|
||||
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
|
||||
@@ -66,26 +72,6 @@ export interface TerminalCommandFinishedInfo {
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface TerminalStateSnapshot {
|
||||
state: TerminalState;
|
||||
revision: number;
|
||||
// Input-mode replay preamble at snapshot time. Populated by the terminal
|
||||
// worker so the daemon main loop doesn't have to re-derive it from output.
|
||||
replayPreamble?: string;
|
||||
}
|
||||
|
||||
export interface TerminalStateSnapshotOptions {
|
||||
scrollbackLines?: number;
|
||||
// Include per-row soft-wrap flags (gridWrapped/scrollbackWrapped) so the client
|
||||
// can reflow restored content on resize. Gated on a client capability, so old
|
||||
// clients never receive the extra fields.
|
||||
includeWrapFlags?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSubscribeOptions {
|
||||
initialSnapshot?: "state" | "ready";
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; rows: number; cols: number }
|
||||
@@ -93,7 +79,6 @@ export type ClientMessage =
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "output"; data: string; revision?: number }
|
||||
| { type: "snapshot"; state: TerminalState; revision?: number }
|
||||
| { type: "snapshotReady"; revision?: number; replayPreamble?: string }
|
||||
| { type: "titleChange"; title?: string };
|
||||
|
||||
@@ -108,7 +93,7 @@ export interface TerminalSession {
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
onExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
|
||||
onTitleChange(listener: (title?: string) => void): () => void;
|
||||
@@ -129,9 +114,11 @@ export interface TerminalSession {
|
||||
/** A session that owns its PTY in this process, so it knows the program's pid. */
|
||||
export interface LocalTerminalSession extends TerminalSession {
|
||||
readonly pid: number;
|
||||
getRetainedHistoryLines(): number;
|
||||
getState(): TerminalState;
|
||||
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
|
||||
serializeReplay(options?: TerminalReplayOptions): {
|
||||
metadata: TerminalReplayMetadata;
|
||||
data: Uint8Array;
|
||||
};
|
||||
captureLines(options?: CaptureTerminalLinesOptions): CaptureTerminalLinesResult;
|
||||
}
|
||||
|
||||
function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null {
|
||||
@@ -547,177 +534,21 @@ function prependPathEntry(currentPath: string, entry: string): string {
|
||||
return [entry, ...entries].join(delimiter);
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
if (!line) {
|
||||
return { char: " ", fg: undefined, bg: undefined };
|
||||
}
|
||||
|
||||
const cell = line.getCell(col);
|
||||
if (!cell) {
|
||||
return { char: " ", fg: undefined, bg: undefined };
|
||||
}
|
||||
|
||||
// Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB
|
||||
// getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1)
|
||||
const fgModeRaw = cell.getFgColorMode();
|
||||
const bgModeRaw = cell.getBgColorMode();
|
||||
const fgMode = fgModeRaw >> 24;
|
||||
const bgMode = bgModeRaw >> 24;
|
||||
|
||||
// Only return color if not default (mode 0)
|
||||
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
|
||||
return {
|
||||
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
|
||||
// continuation. Collapsing it to " " injects a real space on restore.
|
||||
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
bgMode: bgMode !== 0 ? bgMode : undefined,
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
dim: cell.isDim() !== 0,
|
||||
inverse: cell.isInverse() !== 0,
|
||||
strikethrough: cell.isStrikethrough() !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractGrid(terminal: TerminalType): TerminalCell[][] {
|
||||
const grid: TerminalCell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// Visible viewport starts at baseY
|
||||
const baseY = buffer.baseY;
|
||||
|
||||
for (let row = 0; row < terminal.rows; row++) {
|
||||
const rowCells: TerminalCell[] = [];
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
rowCells.push(extractCell(terminal, baseY + row, col));
|
||||
// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility.
|
||||
// xterm 6 keeps the program-selected presentation in coreService, not public options.
|
||||
function serializeCursorPresentation(terminal: TerminalType): string {
|
||||
const core = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
coreService: {
|
||||
isCursorHidden: boolean;
|
||||
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
|
||||
};
|
||||
};
|
||||
}
|
||||
grid.push(rowCells);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
function extractScrollback(
|
||||
terminal: TerminalType,
|
||||
options?: { scrollbackLines?: number },
|
||||
): TerminalCell[][] {
|
||||
const scrollback: TerminalCell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// baseY is the first row of the visible viewport (0-indexed)
|
||||
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
|
||||
const scrollbackLines = buffer.baseY;
|
||||
const startRow =
|
||||
typeof options?.scrollbackLines === "number"
|
||||
? Math.max(0, scrollbackLines - options.scrollbackLines)
|
||||
: 0;
|
||||
|
||||
for (let row = startRow; row < scrollbackLines; row++) {
|
||||
const rowCells: TerminalCell[] = [];
|
||||
const line = buffer.getLine(row);
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
if (line) {
|
||||
const cell = line.getCell(col);
|
||||
if (cell) {
|
||||
const fgModeRaw = cell.getFgColorMode();
|
||||
const bgModeRaw = cell.getBgColorMode();
|
||||
const fgMode = fgModeRaw >> 24;
|
||||
const bgMode = bgModeRaw >> 24;
|
||||
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
rowCells.push({
|
||||
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
|
||||
// continuation. Collapsing it to " " injects a real space on restore.
|
||||
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
bgMode: bgMode !== 0 ? bgMode : undefined,
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
dim: cell.isDim() !== 0,
|
||||
inverse: cell.isInverse() !== 0,
|
||||
strikethrough: cell.isStrikethrough() !== 0,
|
||||
});
|
||||
} else {
|
||||
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
||||
}
|
||||
} else {
|
||||
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
||||
}
|
||||
}
|
||||
scrollback.push(rowCells);
|
||||
}
|
||||
|
||||
return scrollback;
|
||||
}
|
||||
|
||||
// xterm marks a line `isWrapped` when it is a continuation of the PREVIOUS line.
|
||||
// The snapshot carries the inverse, tmux-style flag — "this row continues onto the
|
||||
// next row" — so the client can rejoin and reflow logical lines. So row y's flag is
|
||||
// whether line y+1 is a wrapped continuation.
|
||||
function lineContinuesToNext(terminal: TerminalType, absoluteRow: number): boolean {
|
||||
return terminal.buffer.active.getLine(absoluteRow + 1)?.isWrapped === true;
|
||||
}
|
||||
|
||||
function extractGridWrapped(terminal: TerminalType): boolean[] {
|
||||
const baseY = terminal.buffer.active.baseY;
|
||||
const wrapped: boolean[] = [];
|
||||
for (let row = 0; row < terminal.rows; row++) {
|
||||
wrapped.push(lineContinuesToNext(terminal, baseY + row));
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function extractScrollbackWrapped(
|
||||
terminal: TerminalType,
|
||||
options?: { scrollbackLines?: number },
|
||||
): boolean[] {
|
||||
const scrollbackLines = terminal.buffer.active.baseY;
|
||||
const startRow =
|
||||
typeof options?.scrollbackLines === "number"
|
||||
? Math.max(0, scrollbackLines - options.scrollbackLines)
|
||||
: 0;
|
||||
const wrapped: boolean[] = [];
|
||||
for (let row = startRow; row < scrollbackLines; row++) {
|
||||
wrapped.push(lineContinuesToNext(terminal, row));
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
||||
const coreService = (terminal as unknown as { _core?: { coreService?: Record<string, unknown> } })
|
||||
._core?.coreService as
|
||||
| {
|
||||
decPrivateModes?: { cursorStyle?: unknown; cursorBlink?: unknown };
|
||||
isCursorHidden?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const cursorStyle = coreService?.decPrivateModes?.cursorStyle;
|
||||
const normalizedCursorStyle =
|
||||
cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar"
|
||||
? cursorStyle
|
||||
: undefined;
|
||||
const cursorBlink =
|
||||
typeof coreService?.decPrivateModes?.cursorBlink === "boolean"
|
||||
? coreService.decPrivateModes.cursorBlink
|
||||
: undefined;
|
||||
const hidden = Boolean(coreService?.isCursorHidden);
|
||||
|
||||
return {
|
||||
row: terminal.buffer.active.cursorY,
|
||||
col: terminal.buffer.active.cursorX,
|
||||
...(hidden ? { hidden: true } : {}),
|
||||
...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}),
|
||||
...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}),
|
||||
};
|
||||
)._core.coreService;
|
||||
const style = { block: 2, underline: 4, bar: 6 }[core.decPrivateModes.cursorStyle ?? "block"];
|
||||
return `\x1b[?25${core.isCursorHidden ? "l" : "h"}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
|
||||
}
|
||||
|
||||
function normalizeProcessToken(token: string): string {
|
||||
@@ -986,10 +817,13 @@ export async function createTerminal(
|
||||
const terminal = new Terminal({
|
||||
rows,
|
||||
cols,
|
||||
scrollback: 1000,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const serializer = new SerializeAddon();
|
||||
terminal.loadAddon(serializer);
|
||||
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
|
||||
// Create PTY
|
||||
@@ -1226,6 +1060,10 @@ export async function createTerminal(
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
// Commit protocol modes with the parsed bytes, so a replay cut cannot
|
||||
// combine an older xterm buffer with modes from queued future output.
|
||||
const inputModeUpdate = inputModeTracker.feed(data);
|
||||
for (const response of inputModeUpdate.responses) ptyProcess.write(response);
|
||||
stateRevision += 1;
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "output", data, revision: stateRevision });
|
||||
@@ -1236,10 +1074,6 @@ export async function createTerminal(
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
const inputModeUpdate = inputModeTracker.feed(data);
|
||||
for (const response of inputModeUpdate.responses) {
|
||||
ptyProcess.write(response);
|
||||
}
|
||||
recentOutputChunks.push(data);
|
||||
recentOutputLength += data.length;
|
||||
// Drop whole leading chunks while the rest still covers the char limit, so
|
||||
@@ -1297,32 +1131,18 @@ export async function createTerminal(
|
||||
}
|
||||
}
|
||||
|
||||
function getState(snapshotOptions?: TerminalStateSnapshotOptions): TerminalState {
|
||||
return {
|
||||
function serializeReplay(replayOptions?: TerminalReplayOptions) {
|
||||
const metadata = {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
grid: extractGrid(terminal),
|
||||
scrollback: extractScrollback(terminal, {
|
||||
scrollbackLines: snapshotOptions?.scrollbackLines,
|
||||
}),
|
||||
cursor: extractCursorState(terminal),
|
||||
...(title ? { title } : {}),
|
||||
...(snapshotOptions?.includeWrapFlags
|
||||
? {
|
||||
gridWrapped: extractGridWrapped(terminal),
|
||||
scrollbackWrapped: extractScrollbackWrapped(terminal, {
|
||||
scrollbackLines: snapshotOptions?.scrollbackLines,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot {
|
||||
return {
|
||||
state: getState(snapshotOptions),
|
||||
retainedHistoryLines: terminal.buffer.normal.baseY,
|
||||
revision: stateRevision,
|
||||
replayPreamble: getReplayPreamble(),
|
||||
};
|
||||
const ansi =
|
||||
serializer.serialize({ scrollback: replayOptions?.scrollbackLines }) +
|
||||
serializeCursorPresentation(terminal);
|
||||
return { metadata, data: Buffer.from(ansi, "utf8") };
|
||||
}
|
||||
|
||||
function getSize(): { rows: number; cols: number } {
|
||||
@@ -1388,14 +1208,10 @@ export async function createTerminal(
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
listener: (msg: ServerMessage) => void,
|
||||
subscribeOptions?: TerminalSubscribeOptions,
|
||||
): () => void {
|
||||
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||
let active = true;
|
||||
let snapshotDelivered = false;
|
||||
const queuedMessages: ServerMessage[] = [];
|
||||
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
|
||||
const subscriptionListener = (msg: ServerMessage): void => {
|
||||
if (!active) {
|
||||
return;
|
||||
@@ -1412,17 +1228,11 @@ export async function createTerminal(
|
||||
terminal.write("", () => {
|
||||
if (!disposed && active && listeners.has(subscriptionListener)) {
|
||||
snapshotDelivered = true;
|
||||
if (initialSnapshot === "ready") {
|
||||
// Carry the input-mode preamble so the snapshot-less "ready" path
|
||||
// (live restore) can replay it without a separate state fetch.
|
||||
listener({
|
||||
type: "snapshotReady",
|
||||
revision: stateRevision,
|
||||
replayPreamble: getReplayPreamble(),
|
||||
});
|
||||
} else {
|
||||
listener({ type: "snapshot", ...getStateSnapshot() });
|
||||
}
|
||||
listener({
|
||||
type: "snapshotReady",
|
||||
revision: stateRevision,
|
||||
replayPreamble: getReplayPreamble(),
|
||||
});
|
||||
for (const message of queuedMessages.splice(0)) {
|
||||
listener(message);
|
||||
}
|
||||
@@ -1606,13 +1416,15 @@ export async function createTerminal(
|
||||
onTitleChange,
|
||||
onActivityChange,
|
||||
getSize,
|
||||
getState,
|
||||
getStateSnapshot,
|
||||
getRetainedHistoryLines: () => terminal.buffer.active.baseY,
|
||||
hasOutput: () =>
|
||||
[...getState().scrollback, ...getState().grid].some((row) =>
|
||||
row.some((cell) => cell.char.trim().length > 0),
|
||||
),
|
||||
serializeReplay,
|
||||
captureLines: (captureOptions) => captureTerminalLines(terminal.buffer.active, captureOptions),
|
||||
hasOutput: () => {
|
||||
const buffer = terminal.buffer.active;
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
if (buffer.getLine(row)?.translateToString(true).trim()) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
getReplayPreamble,
|
||||
getTitle,
|
||||
getActivity,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import xterm from "@xterm/headless";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { LocalTerminalSession } from "../terminal.js";
|
||||
|
||||
/** Observe the public ANSI replay using a real receiver; no production cell extraction API. */
|
||||
export async function readTerminalState(session: LocalTerminalSession): Promise<TerminalState> {
|
||||
const { metadata, data } = session.serializeReplay();
|
||||
const terminal = new xterm.Terminal({
|
||||
rows: metadata.rows,
|
||||
cols: metadata.cols,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) => terminal.write(data, resolve));
|
||||
const buffer = terminal.buffer.active;
|
||||
const rows = Array.from({ length: buffer.length }, (_, row) =>
|
||||
Array.from({ length: terminal.cols }, (_cell, col) => {
|
||||
const cell = buffer.getLine(row)!.getCell(col)!;
|
||||
const fgMode = cell.getFgColorMode() >> 24;
|
||||
const bgMode = cell.getBgColorMode() >> 24;
|
||||
return {
|
||||
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
|
||||
fg: fgMode ? cell.getFgColor() : undefined,
|
||||
bg: bgMode ? cell.getBgColor() : undefined,
|
||||
fgMode: fgMode || undefined,
|
||||
bgMode: bgMode || undefined,
|
||||
bold: !!cell.isBold(),
|
||||
italic: !!cell.isItalic(),
|
||||
underline: !!cell.isUnderline(),
|
||||
dim: !!cell.isDim(),
|
||||
inverse: !!cell.isInverse(),
|
||||
strikethrough: !!cell.isStrikethrough(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const wrapped = rows.map((_, i) => buffer.getLine(i + 1)?.isWrapped === true);
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
grid: rows.slice(buffer.baseY),
|
||||
scrollback: rows.slice(0, buffer.baseY),
|
||||
gridWrapped: wrapped.slice(buffer.baseY),
|
||||
scrollbackWrapped: wrapped.slice(0, buffer.baseY),
|
||||
cursor: { row: buffer.cursorY, col: buffer.cursorX },
|
||||
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
||||
};
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import type {
|
||||
TerminalWorkerRequest,
|
||||
WorkerTerminalState,
|
||||
TerminalWorkerToParentMessage,
|
||||
} from "./terminal-worker-protocol.js";
|
||||
import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js";
|
||||
@@ -148,15 +149,8 @@ function readRenderedTokenIndexesFromState(state: TerminalState): number[] {
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function createTerminalState(): TerminalState {
|
||||
const blankCell = { char: " " };
|
||||
return {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[blankCell]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
};
|
||||
function createTerminalState(): WorkerTerminalState {
|
||||
return { rows: 1, cols: 1, hasOutput: false };
|
||||
}
|
||||
|
||||
class FakeTerminalWorker extends EventEmitter {
|
||||
@@ -442,17 +436,17 @@ it("creates a terminal through the worker and streams output", async () => {
|
||||
}),
|
||||
);
|
||||
const messages: string[] = [];
|
||||
let snapshots = 0;
|
||||
let readinessEvents = 0;
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type === "output") {
|
||||
messages.push(message.data);
|
||||
}
|
||||
if (message.type === "snapshot") {
|
||||
snapshots += 1;
|
||||
if (message.type === "snapshotReady") {
|
||||
readinessEvents += 1;
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const snapshotsBeforeOutput = snapshots;
|
||||
expect(readinessEvents).toBe(1);
|
||||
|
||||
session.send({ type: "input", data: "hello\r" });
|
||||
|
||||
@@ -461,7 +455,7 @@ it("creates a terminal through the worker and streams output", async () => {
|
||||
unsubscribe();
|
||||
|
||||
expect(messages.join("")).toContain("worker-output:hello");
|
||||
expect(snapshots).toBe(snapshotsBeforeOutput);
|
||||
expect(readinessEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("delivers rapid small writes complete and in order through worker coalescing", async () => {
|
||||
@@ -497,12 +491,12 @@ it("delivers rapid small writes complete and in order through worker coalescing"
|
||||
session.subscribe((message) => {
|
||||
if (message.type === "output") {
|
||||
events.push({ type: "output", data: message.data });
|
||||
} else if (message.type === "snapshot" || message.type === "snapshotReady") {
|
||||
} else if (message.type === "snapshotReady") {
|
||||
events.push({ type: message.type });
|
||||
}
|
||||
});
|
||||
|
||||
// Let the snapshot subscription settle, then trigger the burst.
|
||||
// Let readiness settle, then trigger the burst.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(burstGatePath, "go");
|
||||
|
||||
@@ -518,14 +512,13 @@ it("delivers rapid small writes complete and in order through worker coalescing"
|
||||
|
||||
await waitForCondition(() => events.some((event) => event.type === "output"), 10000);
|
||||
|
||||
// No snapshot may land after output it should have preceded: every snapshot
|
||||
// event must come before the first output event.
|
||||
// Readiness precedes the ordered live stream.
|
||||
const firstOutputIndex = events.findIndex((event) => event.type === "output");
|
||||
expect(firstOutputIndex).toBeGreaterThanOrEqual(0);
|
||||
const snapshotAfterOutput = events
|
||||
const readinessAfterOutput = events
|
||||
.slice(firstOutputIndex + 1)
|
||||
.find((event) => event.type === "snapshot" || event.type === "snapshotReady");
|
||||
expect(snapshotAfterOutput).toBeUndefined();
|
||||
.find((event) => event.type === "snapshotReady");
|
||||
expect(readinessAfterOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pulls fresh terminal state from the worker authority", async () => {
|
||||
@@ -1740,15 +1733,12 @@ it("replays bounded ANSI chunks before concurrent live output and reconstructs r
|
||||
const events: string[] = [];
|
||||
const chunks: Uint8Array[] = [];
|
||||
let live = "";
|
||||
const unsubscribe = session.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "output") {
|
||||
events.push("live");
|
||||
live += event.data;
|
||||
}
|
||||
},
|
||||
{ initialSnapshot: "ready" },
|
||||
);
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type === "output") {
|
||||
events.push("live");
|
||||
live += event.data;
|
||||
}
|
||||
});
|
||||
const metadata = await manager.replayTerminal(session.id, undefined, (event) => {
|
||||
events.push(event.type);
|
||||
if (event.type === "begin") session.send({ type: "input", data: "go\r" });
|
||||
@@ -2340,3 +2330,63 @@ it.each([true, false])(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3000, 12050])(
|
||||
"retains ten thousand history rows and restores exact demands after %s printed rows",
|
||||
async (count) => {
|
||||
manager = await startRealWorkerManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
cwd: createTemporaryHome(),
|
||||
workspaceId: "ten-thousand",
|
||||
rows: 4,
|
||||
cols: 80,
|
||||
...nodeTerminalCommand(
|
||||
`for(let i=0;i<${count};i++) process.stdout.write("\\x1b[31mrow-"+String(i).padStart(5,"0")+" 界é🙂\\x1b[0m\\r\\n");setInterval(()=>{},1000)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
await waitForCondition(
|
||||
async () =>
|
||||
(await manager!.captureTerminal(session.id, { start: -5 })).lines
|
||||
.join("")
|
||||
.includes("row-" + String(count - 1).padStart(5, "0")),
|
||||
15000,
|
||||
);
|
||||
for (const history of [0, 200, 1000, 10000]) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const metadata = await manager.replayTerminal(
|
||||
session.id,
|
||||
{ scrollbackLines: history },
|
||||
(event) => {
|
||||
if (event.type === "data") chunks.push(event.data);
|
||||
},
|
||||
);
|
||||
expect(metadata).toMatchObject({
|
||||
rows: 4,
|
||||
cols: 80,
|
||||
retainedHistoryLines: Math.min(10000, count - 3),
|
||||
});
|
||||
expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true);
|
||||
const restored = new xterm.Terminal({
|
||||
rows: 4,
|
||||
cols: 80,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) => restored.write(Buffer.concat(chunks), resolve));
|
||||
const buffer = restored.buffer.active;
|
||||
const labels = Array.from({ length: buffer.length }, (_, i) =>
|
||||
buffer.getLine(i)!.translateToString(true),
|
||||
).filter((line) => line.includes("row-"));
|
||||
expect(labels).toHaveLength(Math.min(count, history + 3));
|
||||
expect(labels[0]).toContain("row-" + String(count - labels.length).padStart(5, "0"));
|
||||
expect(labels.at(-1)).toContain("row-" + String(count - 1).padStart(5, "0") + " 界é🙂");
|
||||
} finally {
|
||||
restored.dispose();
|
||||
}
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
@@ -290,18 +290,11 @@ export function createWorkerTerminalManager(
|
||||
}
|
||||
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
|
||||
},
|
||||
subscribe(
|
||||
listener: (msg: ServerMessage) => void,
|
||||
options?: { initialSnapshot?: "state" | "ready" },
|
||||
): () => void {
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||
record.messageListeners.add(listener);
|
||||
if (options?.initialSnapshot === "ready") {
|
||||
queueMicrotask(() => {
|
||||
if (record.messageListeners.has(listener)) {
|
||||
listener({ type: "snapshotReady", revision: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (record.messageListeners.has(listener)) listener({ type: "snapshotReady" });
|
||||
});
|
||||
return () => {
|
||||
record.messageListeners.delete(listener);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user