Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 96e5c8a47f fix(terminal): preserve cursor position after full-width replay 2026-10-30 19:28:02 +01:00
Mohamed Boudra c0af0a8115 ci: run macOS observation checks on macOS 15 2026-10-30 19:05:29 +01:00
Mohamed Boudra 3d3b5343a2 fix(terminal): preserve visible replay history defaults 2026-10-30 18:43:42 +01:00
Mohamed Boudra 7bfadca298 test(terminal): expose cursor replay failure evidence 2026-10-30 18:22:31 +01:00
Mohamed Boudra bc86241857 fix(terminal): bound backpressure recovery history 2026-10-30 18:22:20 +01:00
Mohamed Boudra 4ce30bd90a fix(terminal): preserve client cursor defaults on replay
Leave cursor presentation to renderer defaults unless the program selected a style. Align replay and worker-upgrade fixtures with committed cursor state, independent live output, and the protocol 5 hard cut.
2026-10-30 17:45:31 +01:00
Mohamed Boudra 1557765860 test(server): isolate reconciliation responsiveness from runner work
The in-process probe charges unrelated runner callbacks against the service budget. Measure the cold workload in plain Node while continuous parent-side CPU guards isolation; retain the 20ms limit, exact asynchronous filesystem count, and archival results.
2026-10-30 17:40:19 +01:00
Mohamed Boudra b5e8601066 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.
2026-10-30 16:55:58 +01:00
Mohamed Boudra 88f644b53b docs(terminal): state the minimum ANSI replay app version
Document the intentional JSON-era app compatibility cut and its missing restored screen/history behavior, verified against the v0.1.80 and v0.1.81 decoder and request paths.
2026-10-30 12:28:32 +01:00
Mohamed Boudra 5f43ce0bae fix(terminal): preserve current modes and default ANSI restores
Keep capture-time input modes local to the restoring viewer so replay completion cannot rewind newer shared modes. Publish initial modes after terminal creation and request explicit full restores for default SDK observations on ANSI hosts.

Derive worker test ownership from terminal records and apply the short replay deadline only after program setup.
2026-10-30 12:15:51 +01:00
Mohamed Boudra f5830143f0 fix(terminal): project replay metadata onto the wire contract
Keep worker revision and input-mode preamble internal while preserving the required retained-history metadata delivered to clients.
2026-10-30 11:45:21 +01:00
Mohamed Boudra 7cef8ef43b fix(app): run isolated daemon cleanup outside the test loader
Importing the server lifecycle into the browser test runner exposes its
transitive plugin exports to the runner's transforming loader. Use the
supported CLI in ordinary Node so confirmed shutdown and terminal reaping
complete before removing the owned home.
2026-10-30 11:31:30 +01:00
Mohamed Boudra 4c2bd3527f fix(terminal): preserve replay across slow delivery and resizing
Keep app network delivery outside worker capture deadlines and hold captured geometry until parsing commits. Reconnect readiness is independent of the capture revision used for live-output deduplication.

Remove redundant replay acknowledgements and hard cut the unreleased worker contract at protocol 5 while preserving bounded transports.
2026-10-30 11:28:22 +01:00
Mohamed Boudra bdef9e9fbb fix(app): stop isolated daemon writers before removing test homes
Waiting only for the supervisor lets a surviving daemon worker recreate
its deleted home during teardown. Reuse daemon lifecycle shutdown and
terminal reaping before removing owned state, including forced cleanup.
2026-10-30 10:59:40 +01:00
Mohamed Boudra d14619e41a fix(terminal): recover paced replay and viewer geometry
Catch-up must drain through its paced sink rather than reject on the congestion that requested it. Restore measured renderer geometry after replay commits and before live writes. Require an ANSI-capable host explicitly and remove the retired snapshot entry points.
2026-10-30 10:58:50 +01:00
Mohamed Boudra fe3d65ffea fix(terminal): isolate replay failures to each viewer
A terminal-wide replay tail paused healthy viewers and discarded their live output when another viewer failed or timed out. Keep buffering and revision deduplication with each daemon stream, and pin acknowledgements to their original worker connection.
2026-10-30 10:20:25 +01:00
Mohamed Boudra bfe2b10cef feat(terminal): stream negotiated ANSI replay to clients
Restore configured client history up to host retention without collecting capable replay in the daemon. Preserve single-frame ANSI delivery for older clients and normalize older hosts at the SDK boundary.
2026-10-30 09:56:54 +01:00
Mohamed Boudra 9ba63a30fa refactor(server): stream terminal replay from the worker
Move ANSI generation and replay/live ordering to the worker while preserving
existing ANSI clients through the daemon's single-frame restore adapter.
Keep 1,000 rows of retention and hard cut the unreleased cell transport.

Pace replay writes so a healthy large restore cannot fill the shared socket.
If the bounded live tail overflows, end every viewer of that terminal with
an explicit error; a fresh attach restores current state and the PTY survives.

Credit Manav Dhindsa (DrakenKor), #6139, and Lennart Schoch, #6743, for the
prior worker-side ANSI and scrollback work informing this change.
2026-10-30 09:17:42 +01:00
94 changed files with 4437 additions and 2725 deletions
+2 -2
View File
@@ -281,10 +281,10 @@ jobs:
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-14, file observation)
name: server-tests (macos-15, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14
runs-on: macos-15
steps:
- uses: actions/checkout@v4
+14
View File
@@ -52,6 +52,20 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Terminal ANSI replay
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
only subsequent live output, without the existing screen or history.
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
advertising the capability. The SDK keeps the established subscribe request against older
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
+10 -6
View File
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ framed local socket → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
@@ -19,11 +19,15 @@ 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.** 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 `process.send` 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 the replay preamble from `getTerminalState` responses and `snapshotReady` messages. 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.
- **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.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer.
- **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.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
+15 -1
View File
@@ -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. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. 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
@@ -40,10 +42,22 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Display replay
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
+8
View File
@@ -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",
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
@@ -13,6 +14,10 @@ import {
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
);
return paseoHome;
}
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
// A stopped worker deterministically outlives the old helper's five-second
// supervisor-only kill and logs into its deleted home when resumed.
cleanupTest(
"isolated cleanup prevents a stopped worker from recreating its deleted home",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const workerPid = await readWorkerPid(daemon);
try {
suspendDaemonProcess(workerPid);
await daemon.close();
resumeDaemonProcess(workerPid);
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(workerPid);
await daemon.close();
await rm(daemon.paseoHome, { recursive: true, force: true });
}
},
);
cleanupTest(
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const supervisorPid = daemon.getPid()!;
const workerPid = await readWorkerPid(daemon);
try {
await openLongLivedTerminal(daemon);
const terminalProcesses = await readTerminalProcessIds(daemon);
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
suspendDaemonProcess(supervisorPid);
await daemon.close();
const writerStates = await Promise.all(
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
);
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(supervisorPid);
await daemon.close();
}
},
);
@@ -1,5 +1,10 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
@@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
for (const { count, retainedMarker } of [
{ count: 3000, retainedMarker: "history-0001界🙂" },
{ count: 12050, retainedMarker: "history-3000界🙂" },
]) {
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
page,
}) => {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
});
const terminal = await harness.createTerminal({
name: `history-${count}`,
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
],
});
const last = `history-${String(count).padStart(4, "0")}界🙂`;
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
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界🙂");
});
}
for (const input of [
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
{
name: "program blinking underline",
presentation: "\x1b[3 q",
shape: "underline" as const,
blinking: true,
},
]) {
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
const terminal = await harness.createTerminal({
name: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
});
}
});
@@ -1,5 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
throw error;
}
}
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
export function suspendDaemonProcess(pid: number): void {
process.kill(pid, "SIGSTOP");
}
export function resumeDaemonProcess(pid: number): void {
try {
process.kill(pid, "SIGCONT");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
try {
const state = await psField(pid, "stat");
return state.length > 0 && !state.startsWith("Z");
} catch (error) {
if ((error as { code?: unknown }).code === 1) return false;
throw error;
}
}
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
const directory = path.join(daemon.paseoHome, "terminal-worker");
const records = await Promise.all(
(await readdir(directory))
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ pid: number }>;
};
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
}),
);
return records.flat();
}
@@ -1,4 +1,11 @@
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -6,6 +13,8 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon {
serverId: string;
port: number;
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
let child: ChildProcess | null = null;
const stopDaemon = async (): Promise<void> => {
// The CLI owns graceful shutdown, forced cleanup and detached terminal
// reaping. Keep its ESM graph out of Playwright's transforming loader.
await execFileAsync(
process.execPath,
[
path.resolve(__dirname, "../../../../cli/dist/index.js"),
"daemon",
"stop",
"--home",
paseoHome,
"--force",
],
{ timeout: 30_000 },
);
await killProcessTree(child);
};
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
const child = publishedPackageRoot
const spawned = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
spawned.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, child);
return child;
await waitForServer(port, spawned);
return spawned;
} catch (error) {
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon(
}
};
let child: ChildProcess;
try {
child = await spawnDaemon();
await spawnDaemon();
} catch (error) {
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
@@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon(
serverId,
port,
paseoHome,
getPid: () => child.pid,
getPid: () => child?.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await killProcessTree(child);
child = await spawnDaemon();
await stopDaemon();
await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await killProcessTree(child);
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
closed = true;
},
};
}
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id";
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
export async function expectTerminalCursor(
page: Page,
expected: { shape: "bar" | "underline"; blinking: boolean },
): Promise<void> {
const observed = await page.evaluate(async () => {
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
if (!terminal) throw new Error("Terminal did not mount");
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
(canvas): canvas is HTMLCanvasElement =>
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
);
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
const copy = document.createElement("canvas");
const context = copy.getContext("2d");
if (!context) throw new Error("Cursor pixel capture is unavailable");
terminal.focus();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
if (!terminal.element?.contains(document.activeElement))
throw new Error("Terminal did not focus");
const started = performance.now();
const frames: Array<{ time: number; width: number; height: number }> = [];
const rendered = terminal.onRender(() => {
copy.width = source.width;
copy.height = source.height;
// WebGL's default drawing buffer must be copied before this render returns.
context.drawImage(source, 0, 0);
const cellWidth = source.width / terminal.cols;
const cellHeight = source.height / terminal.rows;
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
const width = Math.ceil(cellWidth);
const height = Math.ceil(cellHeight);
const background = context.getImageData(x + width + 2, y, 1, 1).data;
const pixels = context.getImageData(x, y, width, height).data;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const offset = (row * width + col) * 4;
if (
![0, 1, 2, 3].some(
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
)
)
continue;
minX = Math.min(minX, col);
maxX = Math.max(maxX, col);
minY = Math.min(minY, row);
maxY = Math.max(maxY, row);
}
}
frames.push({
time: performance.now() - started,
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
});
});
try {
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
try {
await new Promise((resolve) => setTimeout(resolve, 1800));
} finally {
clearInterval(refresh);
}
} finally {
rendered.dispose();
}
return {
frames,
focused: terminal.element?.contains(document.activeElement),
samples: frames.length,
width: Math.max(...frames.map((frame) => frame.width)),
height: Math.max(...frames.map((frame) => frame.height)),
blinking:
frames.filter(
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
).length >= 2,
};
});
expect(observed.samples).toBeGreaterThan(5);
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
if (expected.shape === "bar") {
expect(observed.width).toBeGreaterThan(0);
expect(observed.width).toBeLessThan(0.4);
expect(observed.height).toBeGreaterThan(0.6);
} else {
expect(observed.width).toBeGreaterThan(0.6);
expect(observed.height).toBeGreaterThan(0);
expect(observed.height).toBeLessThan(0.4);
}
}
+1
View File
@@ -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",
@@ -4,7 +4,6 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -19,8 +18,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -44,7 +42,6 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -13,7 +13,6 @@ import {
type ViewStyle,
} from "react-native";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
TerminalInputModeTracker,
terminalInputModeStatesEqual,
@@ -120,43 +119,8 @@ function resolvePanResponderPoint(
};
}
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize {
return {
rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS,
cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS,
};
}
function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState {
const visibleRows = viewport.grid.length;
const currentViewport = {
firstRow: viewport.firstRow,
lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1),
};
return {
viewport,
scroll: {
mode: "following",
firstRow: viewport.firstRow,
visibleRows,
oldestRow: viewport.oldestRow,
newestRow: viewport.newestRow,
currentViewport,
bottomViewport: currentViewport,
},
};
}
function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState {
return {
rows: state.grid.length,
cols: state.cols,
firstRow: 0,
oldestRow: 0,
newestRow: Math.max(0, state.grid.length - 1),
grid: state.grid,
cursor: state.cursor,
};
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
function NativeTerminalEmulator({
@@ -169,7 +133,6 @@ function NativeTerminalEmulator({
fontSize,
keyboardInset = 0,
isKeyboardVisible = false,
initialSnapshot = null,
onInput,
onTerminalKey,
onResize,
@@ -181,20 +144,18 @@ function NativeTerminalEmulator({
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey, initialSnapshot });
const mountInputRef = useRef({ streamKey });
if (
resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey,
}) === "mount"
) {
mountInputRef.current = { streamKey, initialSnapshot };
mountInputRef.current = { streamKey };
}
const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot));
const resizePolicyRef = useRef(
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -220,11 +181,7 @@ function NativeTerminalEmulator({
...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false,
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => {
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false);
@@ -361,10 +318,14 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({
write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk);
emitInputModeChange();
},
reset: () => {
restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
terminal.reset();
emitInputModeChange();
},
@@ -418,8 +379,7 @@ function NativeTerminalEmulator({
hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) {
terminalRef.current?.resize(policyResult.measuredSize);
schedulePaint();
outputDrainRef.current?.resize();
}
callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows,
@@ -428,68 +388,51 @@ function NativeTerminalEmulator({
forceClaim: policyResult.resizeClaim?.force,
});
},
[schedulePaint],
[],
);
const resetTerminal = useCallback(
(snapshot: TerminalState | null) => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size =
measuredSize ??
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current);
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = snapshot
? screenStateFromViewport(viewportStateFromSnapshot(snapshot))
: resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
if (snapshot) {
outputDrain.restoreSnapshot(snapshot);
}
},
[
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
const resetTerminal = useCallback(() => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size = measuredSize ?? terminalSizeRef.current;
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
clearSelection,
],
);
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
}, [
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
clearSelection,
]);
const enqueueOutputText = useCallback(
(text: string) => {
if (text.length === 0) {
return;
}
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const enqueueOutputText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
outputDrainRef.current?.enqueueText(text);
}, []);
const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1;
@@ -512,27 +455,7 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text);
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
const text = outputDecoderRef.current.decode(data, { stream: false });
outputDrainRef.current?.restoreText(text);
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
if (!state) {
resetTerminal(null);
return;
}
clearSelection();
const outputDrain = outputDrainRef.current;
if (!outputDrain) {
resetTerminal(state);
return;
}
outputDrain.restoreSnapshot(state);
},
replay: (event) => outputDrainRef.current?.replay(event),
paste: (text: string) => {
if (text.length === 0) {
return;
@@ -563,7 +486,6 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset();
clearSelection();
resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint();
},
claimSize: claimActiveTerminalSize,
@@ -582,14 +504,13 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction,
clearSelection,
resetInputModeTracker,
resetTerminal,
schedulePaint,
],
);
useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false;
resetTerminal(mountInputRef.current.initialSnapshot);
resetTerminal();
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
@@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
@@ -40,8 +39,7 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -108,7 +106,6 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -147,16 +144,6 @@ declare global {
interface Window {}
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
export default function TerminalEmulator({
ref,
streamKey,
@@ -172,7 +159,6 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
@@ -228,8 +214,6 @@ export default function TerminalEmulator({
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
@@ -253,17 +237,17 @@ export default function TerminalEmulator({
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
if (state === null) {
runtimeRef.current?.renderSnapshot({ state: null });
} else if (isTerminalState(state)) {
runtimeRef.current?.renderSnapshot({ state });
}
replay: (event) => {
if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
if (event.type === "data" && event.data instanceof Uint8Array)
runtimeRef.current?.replay({ type: "data", data: event.data });
if (event.type === "begin")
runtimeRef.current?.replay({
type: "begin",
rows: typeof event.rows === "number" ? event.rows : undefined,
cols: typeof event.cols === "number" ? event.cols : undefined,
});
},
paste: (...args) => {
const text = args[0];
@@ -299,12 +283,7 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
replay: (event) => runtimeRef.current?.replay(event),
paste: (text: string) => {
pasteText(text);
},
@@ -471,7 +450,6 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
+5 -43
View File
@@ -15,7 +15,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
@@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
@@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
@@ -316,10 +313,6 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
@@ -383,7 +376,6 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -441,16 +433,8 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
[terminalStreamKey],
);
useEffect(() => {
@@ -564,7 +548,6 @@ export function TerminalPane({
);
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS });
});
@@ -604,29 +587,10 @@ export function TerminalPane({
},
);
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
);
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
);
const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
@@ -653,8 +617,9 @@ export function TerminalPane({
client,
getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput,
onRestore: handleStreamRestore,
onSnapshot: handleStreamSnapshot,
onReplay: ({ terminalId: id, replay }) => {
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus,
@@ -674,8 +639,6 @@ export function TerminalPane({
getStreamRestoreOptions,
handleStreamControllerStatus,
handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit,
isConnected,
]);
@@ -1216,7 +1179,6 @@ export function TerminalPane({
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
+1
View File
@@ -617,6 +617,7 @@ export const ar: TranslationResources = {
terminal: {
hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
+1
View File
@@ -614,6 +614,7 @@ export const en = {
terminal: {
hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.",
restoreRequiresUpdate: "Update the host to view terminals.",
unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const es: TranslationResources = {
terminal: {
hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
restoreRequiresUpdate: "Actualiza el host para ver las terminales.",
unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const fr: TranslationResources = {
terminal: {
hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.",
unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const ja: TranslationResources = {
terminal: {
hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
+1
View File
@@ -618,6 +618,7 @@ export const ko: TranslationResources = {
terminal: {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
+1
View File
@@ -622,6 +622,7 @@ export const ptBR: TranslationResources = {
terminal: {
hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
restoreRequiresUpdate: "Atualize o host para visualizar os terminais.",
unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
+1
View File
@@ -624,6 +624,7 @@ export const ru: TranslationResources = {
terminal: {
hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
+1
View File
@@ -617,6 +617,7 @@ export const zhCN: TranslationResources = {
terminal: {
hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。",
restoreRequiresUpdate: "更新主机以查看终端。",
unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
@@ -127,7 +127,6 @@ import {
useWorkspaceSetupStore,
} from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: {
return !input.isFocusModeEnabled || input.isMobile;
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
/**
* A pane the user acted inside owns the tab: it opens there, and an existing tab
* moves there. No pane means the open has no opinion beyond the focused pane.
@@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore(
@@ -1,18 +0,0 @@
import { useEffect } from "react";
import {
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
useEffect(() => {
if (!input.scopeKey) {
return;
}
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
return () => {
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
};
}, [input.scopeKey]);
}
@@ -1,8 +1,8 @@
import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy";
import { describe, expect, test } from "vitest";
import {
createNativeHeadlessTerminal,
type TerminalCellRow,
type TerminalViewportState,
} from "./headless-terminal-state";
import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload";
@@ -11,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 "../runtime/terminal-snapshot";
import { Terminal as ReplaySource } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
const ROWS = 12;
const COLS = 80;
@@ -41,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>;
@@ -80,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(
@@ -94,6 +70,7 @@ function createScreenRestorer(
const frameRecorder = createFrameRecorder();
let screen: TerminalScreenState | null = null;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => {
@@ -107,7 +84,12 @@ function createScreenRestorer(
return {
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) {
drain.restoreSnapshot(snapshot);
drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols });
drain.replay({
type: "data",
data: snapshot.data,
});
drain.replay({ type: "end" });
await drain.flush();
frameRecorder.frames[0]?.();
return screen;
@@ -122,6 +104,7 @@ describe("native terminal output drain", () => {
const frameRecorder = createFrameRecorder();
const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2);
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async (chunk) => {
writes.push(chunk);
},
@@ -170,6 +153,7 @@ describe("native terminal output drain", () => {
const paintedStates: TerminalViewportState[] = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -202,6 +186,7 @@ describe("native terminal output drain", () => {
const scheduledWork: Array<() => void> = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -210,21 +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.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot));
drain.replay({ type: "begin" });
drain.replay({
type: "data",
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 () => {
@@ -242,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,
@@ -268,3 +254,255 @@ describe("native terminal output drain", () => {
});
});
});
test("replays split Unicode and ANSI with one reset then live output", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (text) => terminal.write(text),
reset: (size) => {
resets++;
if (size) terminal.resize(size);
terminal.reset();
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
for (let i = 0; i < bytes.length; i++)
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
drain.replay({ type: "end" });
drain.enqueueText("live");
await drain.flush();
expect(resets).toBe(1);
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("界🙂 replay");
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("live");
} finally {
drain.dispose();
terminal.dispose();
}
});
test("an empty replacement replay resets after an in-flight write finishes", async () => {
let finish!: () => void;
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
const blocked = new Promise<void>((resolve) => {
finish = resolve;
});
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async () => {
started();
await blocked;
},
reset: () => {
resets++;
},
getViewportState: () => null,
onPaint: () => {},
});
try {
drain.enqueueText("old terminal");
await writing;
drain.replay({ type: "begin" });
drain.replay({ type: "end" });
finish();
await drain.flush();
expect(resets).toBe(1);
expect(drain.getQueuedChars()).toBe(0);
} finally {
finish();
drain.dispose();
}
});
test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => {
const measured = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 });
const policy = createTerminalResizePolicy(measured);
const sizes: number[] = [];
const drain = createNativeTerminalOutputDrain({
write: async (text) => {
sizes.push(terminal.getViewportState().cols);
await terminal.write(text);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") });
drain.replay({ type: "end" });
drain.enqueueText("LIVE-" + "y".repeat(60));
await drain.flush();
expect(sizes).toEqual([120, 40]);
const focus = updateTerminalResizePolicy(policy, {
source: "claim",
size: measured,
claimToken: 1,
});
expect(focus.measuredSizeChanged).toBe(false);
expect(focus.resizeClaim).toMatchObject(measured);
expect(terminal.getViewportState()).toMatchObject(measured);
expect(terminal.getBufferBounds().cols).toBe(40);
const lines = terminal
.getViewportState()
.grid.map((row) =>
row
.map((cell) => cell.char)
.join("")
.trimEnd(),
)
.join("\n");
expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25));
} finally {
drain.dispose();
terminal.dispose();
}
});
test.each(["clear", "replacement", "dispose"] as const)(
"cancels queued replay geometry on %s",
async (action) => {
const local = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 });
let finish!: () => void;
const block = new Promise<void>((resolve) => {
finish = resolve;
});
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
let first = true;
let restores = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {
restores++;
terminal.resize(local);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
write: async (text) => {
await terminal.write(text);
if (first) {
first = false;
started();
await block;
}
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("old") });
await writing;
local.cols = 60;
drain.resize();
expect(terminal.getViewportState().cols).toBe(120);
drain.replay({ type: "end" });
if (action === "clear") drain.clear();
else if (action === "dispose") {
drain.dispose();
drain.clear();
} else {
drain.replay({ type: "begin", rows: 20, cols: 80 });
drain.replay({ type: "data", data: new TextEncoder().encode("new") });
drain.replay({ type: "end" });
}
finish();
await drain.flush();
expect(restores).toBe(action === "dispose" ? 0 : 1);
if (action !== "dispose") expect(terminal.getViewportState()).toMatchObject(local);
} finally {
finish();
drain.dispose();
terminal.dispose();
}
},
);
test("holds captured geometry across mid-replay measurement changes", async () => {
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));
expected.resize({ rows: 4, cols: 4 });
let measured = { rows: 4, cols: 8 };
const drain = createNativeTerminalOutputDrain({
write: (chunk) => terminal.write(chunk),
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 8 });
await drain.flush();
measured = { rows: 4, cols: 4 };
drain.resize();
drain.replay({ type: "data", data: ansi });
drain.replay({ type: "end" });
await drain.flush();
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();
}
});
@@ -1,5 +1,3 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
import type { TerminalViewportState } from "./headless-terminal-state";
import { nativeTerminalPerformanceNow } from "./terminal-performance";
@@ -14,9 +12,9 @@ export interface NativeTerminalParseSample {
}
export interface NativeTerminalOutputDrain {
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
enqueueText(text: string): void;
restoreText(text: string): void;
restoreSnapshot(snapshot: TerminalState): void;
resize(): void;
clear(): void;
flush(): Promise<void>;
dispose(): void;
@@ -25,7 +23,8 @@ export interface NativeTerminalOutputDrain {
export interface NativeTerminalOutputDrainOptions {
write: (chunk: string) => Promise<void>;
reset: () => void;
restoreSize: () => void;
reset: (size?: { rows: number; cols: number }) => void;
getViewportState: () => TerminalViewportState | null;
onPaint: (state: TerminalViewportState) => void;
onParse?: (sample: NativeTerminalParseSample) => void;
@@ -68,16 +67,22 @@ export function createNativeTerminalOutputDrain(
const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS;
const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN;
const idleWaiters: Array<() => void> = [];
let pendingText = "";
type Operation =
| { type: "write"; text: string }
| { type: "reset"; size?: { rows: number; cols: number } }
| { type: "restore-size" };
let operations: Operation[] = [];
let queuedChars = 0;
let drainScheduled = false;
let draining = false;
let disposed = false;
let replaying = false;
let paintFrame: number | null = null;
let generation = 0;
let resetBeforeNextWrite = false;
const replayDecoder = new TextDecoder();
function resolveIdleIfReady(): void {
if (drainScheduled || draining || pendingText.length > 0) {
if (drainScheduled || draining || operations.length > 0) {
return;
}
while (idleWaiters.length > 0) {
@@ -119,15 +124,6 @@ export function createNativeTerminalOutputDrain(
});
}
function replacePendingText(text: string): void {
generation += 1;
pendingText = text;
resetBeforeNextWrite = true;
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
}
async function drainPending(): Promise<void> {
if (disposed || draining) {
return;
@@ -138,16 +134,23 @@ export function createNativeTerminalOutputDrain(
let parsedChunks = 0;
try {
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) {
return;
while (operations.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) return;
const operation = operations[0]!;
if (operation.type !== "write") {
operations.shift();
if (operation.type === "reset") options.reset(operation.size);
else {
options.restoreSize();
replaying = false;
}
requestPaint();
continue;
}
if (resetBeforeNextWrite) {
options.reset();
resetBeforeNextWrite = false;
}
const chunk = pendingText.slice(0, chunkChars);
pendingText = pendingText.slice(chunk.length);
const chunk = operation.text.slice(0, chunkChars);
operation.text = operation.text.slice(chunk.length);
queuedChars -= chunk.length;
if (!operation.text) operations.shift();
const parseStart = nativeTerminalPerformanceNow();
await options.write(chunk);
if (disposed || activeGeneration !== generation) {
@@ -156,14 +159,14 @@ export function createNativeTerminalOutputDrain(
options.onParse?.({
durationMs: nativeTerminalPerformanceNow() - parseStart,
parsedChars: chunk.length,
queuedChars: pendingText.length,
queuedChars,
});
requestPaint();
parsedChunks += 1;
}
} finally {
draining = false;
if (!disposed && pendingText.length > 0) {
if (!disposed && operations.length > 0) {
scheduleDrain();
}
resolveIdleIfReady();
@@ -171,38 +174,56 @@ export function createNativeTerminalOutputDrain(
}
return {
enqueueText(text: string): void {
if (disposed || text.length === 0) {
return;
replay(event): void {
if (disposed) return;
if (event.type === "begin") {
replaying = true;
generation++;
replayDecoder.decode();
operations = [
{
type: "reset",
size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined,
},
];
queuedChars = 0;
cancelPendingPaint();
} else if (event.type === "data") {
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
} else {
this.enqueueText(replayDecoder.decode());
operations.push({ type: "restore-size" });
}
pendingText += text;
scheduleDrain();
},
enqueueText(text: string): void {
if (disposed || !text) return;
const last = operations.at(-1);
if (last?.type === "write") last.text += text;
else operations.push({ type: "write", text });
queuedChars += text.length;
scheduleDrain();
},
restoreText(text: string): void {
if (disposed) {
return;
}
replacePendingText(text);
},
restoreSnapshot(snapshot: TerminalState): void {
if (disposed) {
return;
}
replacePendingText(renderTerminalSnapshotToAnsi(snapshot));
resize(): void {
if (disposed || replaying) return;
operations.push({ type: "restore-size" });
scheduleDrain();
},
clear(): void {
if (disposed) return;
generation += 1;
pendingText = "";
resetBeforeNextWrite = false;
operations = [{ type: "restore-size" }, { type: "reset" }];
queuedChars = 0;
replayDecoder.decode();
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
},
flush(): Promise<void> {
if (!drainScheduled && !draining && pendingText.length === 0) {
if (!drainScheduled && !draining && operations.length === 0) {
return Promise.resolve();
}
scheduleDrain();
@@ -214,8 +235,8 @@ export function createNativeTerminalOutputDrain(
dispose(): void {
disposed = true;
generation += 1;
pendingText = "";
resetBeforeNextWrite = false;
operations = [];
queuedChars = 0;
cancelPendingPaint();
while (idleWaiters.length > 0) {
idleWaiters.shift()?.();
@@ -223,7 +244,7 @@ export function createNativeTerminalOutputDrain(
},
getQueuedChars(): number {
return pendingText.length;
return queuedChars;
},
};
}
@@ -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 () => {
@@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
theme: {
background: "#0b0b0b",
@@ -750,30 +749,11 @@ describe("terminal emulator runtime in a real browser", () => {
const reset = vi.fn(originalReset);
terminal.reset = reset;
mounted.runtime.renderSnapshot({
state: {
rows: terminal.rows,
cols: terminal.cols,
scrollback: [],
grid: [
[
{ char: "p" },
{ char: "r" },
{ char: "o" },
{ char: "m" },
{ char: "p" },
{ char: "t" },
],
],
cursor: {
row: 0,
col: 6,
},
},
});
await nextFrame();
mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols });
mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") });
mounted.runtime.replay({ type: "end" });
expect(reset).not.toHaveBeenCalled();
await nextFrame();
});
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
@@ -795,3 +775,95 @@ describe("terminal emulator runtime in a real browser", () => {
expect(confirm).not.toHaveBeenCalled();
});
});
describe("incremental replay lifecycle", () => {
it.each(["clear", "remount"] as const)(
"recovers live input after interrupted replay and %s",
async (cleanup) => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: false });
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
if (cleanup === "clear")
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
else {
mounted.runtime.unmount();
mounted.runtime.mount({
root: mounted.root,
host: mounted.host,
scrollback: 1000,
theme: {},
});
}
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
getBrowserTerminal().input("user-input", true);
expect(mounted.inputs).toContain("user-input");
expect(getBrowserTerminal().cols).toBeLessThan(60);
},
);
it("suppresses generated replies until queued replay chunks commit after end", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual([]);
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
);
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
});
it("restores fitted web geometry after replay before live output", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
const local = latestSize(mounted.sizes);
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(120);
expect(getBrowserTerminal().rows).toBe(local.rows);
mounted.runtime.resize({ forceClaim: true, shouldClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
it("defers layout fit until all replay chunks commit", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
await new Promise<void>((resolve) =>
mounted.runtime.write({
data: terminalOutput("first"),
suppressInput: true,
onCommitted: resolve,
}),
);
expect(getBrowserTerminal().cols).toBe(120);
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true });
await nextFrame();
expect(getBrowserTerminal().cols).toBe(120);
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(60);
expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true });
mounted.runtime.resize({ shouldClaim: true, forceClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
@@ -302,25 +302,14 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["first", "second", "clear", "third"]);
});
it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => {
it("unblocks queued replay writes if a begin barrier never commits", () => {
vi.useFakeTimers();
const { runtime, terminal } = createRuntimeWithTerminal();
const onCommitted = vi.fn();
// restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still
// applies (and commits) after the 5s safety timeout instead of stalling forever.
runtime.restoreOutput({
data: terminalOutput("stuck"),
onCommitted,
});
expect(onCommitted).not.toHaveBeenCalled();
expect(terminal.resetCalls).toBe(0);
vi.advanceTimersByTime(5_000);
// The barrier now runs (write submitted) but its own commit still waits on xterm; advance
// again to fire the barrier's own write timeout.
vi.advanceTimersByTime(5_000);
expect(onCommitted).toHaveBeenCalledTimes(1);
const { runtime, writeTexts } = createRuntimeWithTerminal();
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("stuck") });
expect(writeTexts).toEqual(["\x1bc"]);
vi.advanceTimersByTime(5000);
expect(writeTexts).toEqual(["\x1bc", "stuck"]);
});
it("reports input mode changes from terminal output and resets them on snapshots", () => {
@@ -343,18 +332,7 @@ describe("terminal-emulator-runtime", () => {
});
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [[{ char: "$" }, { char: " " }]],
cursor: {
row: 0,
col: 2,
},
},
});
runtime.replay({ type: "begin", rows: 2, cols: 8 });
// The plain write reports kitty flags synchronously during drain; the snapshot resets
// them only after its barrier gate (the sentinel write callback) resolves.
expect(inputModeChanges).toEqual([
@@ -410,40 +388,18 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["second", "first"]);
});
it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => {
it("keeps replay reset and data after preceding writes, without interleaving", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = [];
runtime.write({
data: terminalOutput("before-a"),
onCommitted: () => {
committed.push("before-a");
},
});
runtime.write({
data: terminalOutput("before-b"),
onCommitted: () => {
committed.push("before-b");
},
});
runtime.restoreOutput({
data: terminalOutput("snap"),
onCommitted: () => {
committed.push("snap");
},
});
// Plain writes are submitted up front; the snapshot's reset+write has not run yet.
runtime.write({ data: terminalOutput("before-a") });
runtime.write({ data: terminalOutput("before-b") });
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("snap") });
runtime.replay({ type: "end" });
expect(writeTexts).toEqual(["before-a", "before-b"]);
// writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel.
// The snapshot is gated behind the prior writes; resolving the gate runs it after them.
writeCallbacks[2]?.();
expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]);
// Snapshot commit fires only after its own write callback, strictly after the writes.
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]);
writeCallbacks[3]?.();
expect(committed).toEqual(["snap"]);
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]);
expect(terminal.resetCalls).toBe(0);
});
@@ -456,7 +412,7 @@ describe("terminal-emulator-runtime", () => {
// No plain writes precede this barrier (mount), so there is nothing to gate: it starts
// at once with no sentinel, flipping suppressInput synchronously.
runtime.restoreOutput({ data: terminalOutput("snapshot") });
runtime.replay({ type: "begin" });
expect(readSuppressInput()).toBe(true);
// writeCallbacks[0] is the barrier's own snapshot write; committing it restores input.
@@ -471,7 +427,7 @@ describe("terminal-emulator-runtime", () => {
// A plain write is now ungated, so the following barrier must wait on the sentinel.
runtime.write({ data: terminalOutput("output") });
runtime.restoreOutput({ data: terminalOutput("snapshot") });
runtime.replay({ type: "begin" });
// The plain write commits first. Only the following sentinel opens the barrier.
expect(readSuppressInput()).toBe(false);
@@ -505,57 +461,40 @@ describe("terminal-emulator-runtime", () => {
expect(onCommittedB).toHaveBeenCalledTimes(1);
});
it("clears ungated writes on unmount so remount barriers apply immediately", () => {
it("clears ungated writes on unmount so remount replay begins immediately", () => {
const { runtime } = createRuntimeWithTerminal();
runtime.write({ data: terminalOutput("before unmount") });
runtime.unmount();
const remounted = attachStubTerminal(runtime);
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]);
expect(remounted.writeCallbacks).toHaveLength(1);
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\x1bc"]);
remounted.writeCallbacks[0]?.();
expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]);
});
it("replays snapshots through a single write without first painting a reset terminal", () => {
it("parses replay chunks serially without synchronously resetting the terminal", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [
[{ char: "h" }, { char: "i" }],
[{ char: "$" }, { char: " " }],
],
cursor: {
row: 1,
col: 2,
},
},
});
// The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]).
writeCallbacks[0]?.();
runtime.replay({ type: "begin", rows: 2, cols: 8 });
runtime.replay({ type: "data", data: terminalOutput("hi") });
runtime.replay({ type: "data", data: terminalOutput("\r\n$") });
runtime.replay({ type: "end" });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toHaveLength(1);
expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true);
expect(writeTexts[0]).toContain("hi");
expect(writeTexts).toEqual(["\x1bc"]);
writeCallbacks[0]?.();
expect(writeTexts).toEqual(["\x1bc", "hi"]);
writeCallbacks[1]?.();
expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]);
});
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
it("restores worker-rendered ANSI through the replay lifecycle", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.restoreOutput({ data: terminalOutput("restored screen") });
// restoreOutput is a barrier (suppressInput); it applies after the gate sentinel.
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
runtime.replay({ type: "end" });
writeCallbacks[0]?.();
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
expect(writeTexts).toEqual(["\x1bc", "restored screen"]);
});
it("forces a refit when resize is requested", () => {
@@ -7,7 +7,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
@@ -24,7 +23,6 @@ import {
resolveMacTerminalEditingShortcut,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import {
createTerminalLocalFileLinkProvider,
type TerminalLocalFileLinkSource,
@@ -55,7 +53,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
fontFamily?: string;
@@ -134,7 +131,7 @@ interface TerminalEmulatorRuntimeDisposables {
}
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
type: "write" | "clear" | "snapshot" | "fit";
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -280,6 +277,7 @@ export class TerminalEmulatorRuntime {
},
};
private replayResize: TerminalResizeRequest | null = null;
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -652,6 +650,7 @@ export class TerminalEmulatorRuntime {
window.__paseoTerminal = terminal;
const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => {
if (this.deferReplayResize(resizeInput)) return;
const forceRefresh = resizeInput?.forceRefresh ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true;
const forceClaim = resizeInput?.forceClaim ?? false;
@@ -777,10 +776,6 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
}, 0);
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
}
this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = {
@@ -898,20 +893,19 @@ export class TerminalEmulatorRuntime {
onFilePaste?.(file);
}
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
return;
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
if (event.type === "begin") {
this.replayResize ??= { shouldClaim: false };
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
} else if (event.type === "data") {
this.write({ data: event.data, suppressInput: true });
} else {
this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT });
this.processOutputQueue();
}
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
restoreOutput(input: {
private restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -928,6 +922,16 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
private deferReplayResize(input?: TerminalResizeRequest): boolean {
if (!this.replayResize) return false;
this.replayResize = {
forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh,
shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true),
forceClaim: this.replayResize.forceClaim || input?.forceClaim,
};
return true;
}
resize(input?: TerminalResizeRequest): void {
this.fitAndEmitResize?.(input);
}
@@ -1047,6 +1051,7 @@ export class TerminalEmulatorRuntime {
commit();
}
this.hasUngatedWrites = false;
this.replayResize = null;
this.cleanup?.();
this.cleanup = null;
@@ -1176,16 +1181,40 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
};
if (operation.type === "fit") {
// xterm resize flushes its write buffer synchronously. Leave the parser's
// commit callback before fitting, or that flush consumes the next write.
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
return;
}
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
finalizeOperation(operation);
if (!this.replayResize) {
finalizeOperation(operation);
return;
}
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
return;
}
if (operation.type === "snapshot") {
this.replayResize ??= { shouldClaim: false };
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
@@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
});
@@ -38,7 +38,6 @@ function mountTerminal(left: number): TerminalView {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
fontFamily: "monospace",
fontSize: 16,
@@ -1,15 +1,14 @@
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;
@@ -57,7 +56,6 @@ function mount(width: number, height: number): Mounted {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 10_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
});
@@ -90,9 +88,18 @@ function someRowContains(term: InspectableTerminal, text: string): boolean {
return dumpRows(term).some((r) => r.text.includes(text));
}
function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
function replayCommitted(
runtime: TerminalEmulatorRuntime,
state: { rows: number; cols: number; data: Uint8Array },
) {
return new Promise<void>((resolve) => {
runtime.renderSnapshot({ state, onCommitted: resolve });
runtime.replay({ type: "begin", rows: state.rows, cols: state.cols });
runtime.replay({
type: "data",
data: state.data,
});
runtime.replay({ type: "end" });
runtime.write({ data: new Uint8Array([0]), onCommitted: resolve });
});
}
@@ -102,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 {
@@ -141,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 renderSnapshotCommitted(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).
@@ -4,25 +4,27 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
expect(() =>
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: false,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toBeUndefined();
).toThrow("Update the host to view terminals.");
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
size: { rows: 24, cols: 80 },
});
});
@@ -30,26 +32,39 @@ describe("terminal restore options", () => {
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
it("does not let a background attach resize the PTY", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: false,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
});
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
scrollbackLines,
canClaimSize: false,
size: null,
}),
).toEqual({ mode: "visible-snapshot", scrollbackLines });
});
@@ -1,23 +1,23 @@
import { i18n } from "@/i18n/i18next";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
scrollbackLines: number;
canClaimSize: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined {
): NonNullable<SubscribeTerminalRequest["restore"]> {
if (!input.supportsTerminalRestoreModes) {
return undefined;
throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate"));
}
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
scrollbackLines: input.scrollbackLines,
...(input.canClaimSize && input.size ? { size: input.size } : {}),
};
}
@@ -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 "./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 +0,0 @@
export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
@@ -1,3 +1,4 @@
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
@@ -7,34 +8,16 @@ import {
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
interface TerminalSnapshot {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
type TerminalStreamEvent = StreamEvent extends infer Event
? Event extends StreamEvent
? Omit<Event, "subscriptionId">
: never
: never;
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
@@ -115,32 +98,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
const statuses: TerminalStreamControllerStatus[] = [];
const exits: string[] = [];
const controller = new TerminalStreamController({
client,
onReplay: ({ replay }) => replays.push(replay),
onExit: (terminalId) => exits.push(terminalId),
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
});
return { client, controller, outputs, restores, snapshots, statuses, exits };
return { client, controller, outputs, replays, statuses, exits };
}
async function flushAsyncWork(): Promise<void> {
@@ -149,24 +123,20 @@ async function flushAsyncWork(): Promise<void> {
}
describe("terminal-stream-controller", () => {
it("subscribes, resizes, and forwards snapshot/output events", async () => {
it("subscribes, resizes, and forwards replay/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emit({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
{ type: "data", data: terminalOutput("hello") },
{ type: "end" },
];
for (const event of replay)
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
@@ -178,7 +148,7 @@ describe("terminal-stream-controller", () => {
expect(harness.client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
@@ -236,23 +206,19 @@ describe("terminal-stream-controller", () => {
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onReplay: ({ replay }) => harness.replays.push(replay),
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin" },
{ type: "data", data: restoreData },
{ type: "end" },
];
for (const event of replay)
client.emit({ terminalId: "term-1", type: "replay", replay: event });
controller.dispose();
expect(client.subscribeCalls).toEqual([
@@ -270,8 +236,7 @@ describe("terminal-stream-controller", () => {
expect(client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs).toEqual([]);
});
@@ -319,3 +284,28 @@ it("reports observation failure without declaring the PTY exited, then allows re
expect(h.statuses.at(-1)?.error).toBe(null);
h.controller.dispose();
});
it("does not resize or attach on a JSON-era host and reports the required update", () => {
const client = new FakeTerminalStreamClient();
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 40 }),
onOutput: () => {},
getRestoreOptions: () =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
scrollbackLines: 10000,
canClaimSize: true,
size: { rows: 24, cols: 40 },
}),
onStatusChange: (status) => statuses.push(status),
});
controller.setTerminal({ terminalId: "old-host-terminal" });
expect(client.subscribeCalls).toEqual([]);
expect(client.resizeCalls).toEqual([]);
expect(statuses.at(-1)).toMatchObject({
isAttaching: false,
error: "Update the host to view terminals.",
});
});
@@ -1,5 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
import { i18n } from "@/i18n/i18next";
@@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onExit?: (terminalId: string) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
onReplay?: (input: {
terminalId: string;
replay: import("@getpaseo/client").TerminalReplayEvent;
}) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
@@ -43,17 +45,11 @@ export class TerminalStreamController {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "snapshot") {
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
if (event.type === "replay") {
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
return;
}
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
if (event.type === "output" && event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
};
@@ -70,10 +66,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
let subscription: ReturnType<DaemonClient["observeTerminal"]>;
try {
const restore = this.options.getRestoreOptions?.();
const preferredSize = restore?.size ?? this.options.getPreferredSize();
if (preferredSize)
this.options.client.sendTerminalInput(nextTerminalId, {
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getWorkspaceTerminalSession,
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "./workspace-terminal-session";
describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
expect(second).toBe(first);
});
it("preserves snapshots across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
});
it("evicts workspace terminal session state when the retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
retainWorkspaceTerminalSession({ scopeKey });
releaseWorkspaceTerminalSession({ scopeKey });
const second = getWorkspaceTerminalSession({
scopeKey,
});
expect(second).not.toBe(first);
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
});
});
@@ -1,79 +0,0 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
export interface WorkspaceTerminalSnapshots {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
}
export interface WorkspaceTerminalSession {
scopeKey: string;
snapshots: WorkspaceTerminalSnapshots;
}
interface WorkspaceTerminalSessionRecord {
snapshotByTerminalId: Map<string, TerminalState>;
session: WorkspaceTerminalSession;
}
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
return {
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
},
clear: ({ terminalId }) => {
input.snapshotByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.snapshotByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const snapshotByTerminalId = new Map<string, TerminalState>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
snapshots: createSnapshots({
snapshotByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
snapshotByTerminalId,
session,
});
return session;
}
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
return;
}
refCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
}
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
} from "./terminal-renderer-readiness";
@@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => {
expect(current).toBeNull();
});
it("replays snapshots only for ready events from the current renderer", () => {
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(true);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: false },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(false);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-2",
}),
).toBe(false);
});
it("does not attach the stream before the matching renderer is ready", () => {
expect(
resolveTerminalStreamTarget({
@@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange(
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey;
}
export function shouldReplayTerminalSnapshotForRenderer(input: {
change: TerminalRendererReadyChange;
terminalStreamKey: string;
}): boolean {
return input.change.isReady && input.change.streamKey === input.terminalStreamKey;
}
export function resolveTerminalStreamTarget(input: {
terminalId: string;
terminalStreamKey: string;
@@ -10,11 +10,10 @@
* `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away,
* a daemon from B adopts the same worker and opens a new, working terminal in it with B's
* environment and CLI, while the first terminal keeps A's.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version
* old drains (keeps its terminal, new ones open in a second worker, it exits after its last
* terminal and its copy is deleted); one two versions old is shut down; one from a newer
* Paseo is left running, reported as terminal_worker_incompatible, and closed by
* `daemon stop`.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): protocols 0–4
* are obsolete unreleased wires, so their programs/workers/copies are closed and a current
* worker serves new terminals. A truly newer protocol is left running, reported as
* terminal_worker_incompatible, and closed by `daemon stop`.
*
* Every process it starts is stopped in `finally`.
*/
@@ -35,7 +34,7 @@ import {
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getAvailablePort } from "./helpers/network.ts";
import { createServer } from "node:net";
if (process.platform === "win32") {
console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals).");
@@ -50,7 +49,7 @@ const paseoHome = join(root, "home");
const cliHome = join(root, "os-home");
const workDir = join(root, "work");
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
const port = await getAvailablePort();
const currentProtocolVersion = 5;
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation {
@@ -336,7 +335,18 @@ async function terminalIds(installation: Installation): Promise<string[]> {
}
async function startDaemon(installation: Installation, extraEnv: Record<string, string> = {}) {
await paseo(installation, ["daemon", "start"], extraEnv);
const result = await paseo(installation, ["daemon", "start", "--json"], extraEnv);
lastListen = (JSON.parse(result.stdout) as { listen: string }).listen;
assert.match(lastListen, /^127\.0\.0\.1:[1-9]\d*$/, "start publishes the actual bound port");
if (competingListener.listening) {
const competitor = competingListener.address();
assert(competitor && typeof competitor !== "string");
assert.notEqual(
Number(lastListen.split(":").at(-1)),
competitor.port,
"each daemon binds its own endpoint while the released port belongs to a peer",
);
}
}
async function stopDaemon(installation: Installation, keepTerminals = false) {
@@ -371,10 +381,14 @@ const a = createInstallation("A", "0.98.0");
const b = createInstallation("B", "0.99.0");
let restoreA: (() => void) | null = null;
let passed = false;
let lastListen = "";
const competingListener = createServer();
try {
for (const [field, value] of [
["daemon.listen", `127.0.0.1:${port}`],
// Let the listening daemon reserve its port atomically. Clients discover the
// actual endpoint from this home's PID record after every start.
["daemon.listen", "127.0.0.1:0"],
["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
@@ -387,6 +401,7 @@ try {
const t1 = await openTerminal(a, "t1");
const [firstWorker] = workerRecords();
assert(firstWorker, "one worker runs");
assert.equal(firstWorker.protocolVersion, currentProtocolVersion, "the current wire is adopted");
assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
assert(
@@ -432,54 +447,62 @@ try {
restoreA = null;
console.log("✓ closed and cleaned up\n");
console.log("Test 4: a worker one protocol version old drains");
const old = await leaveWorkerWithProtocol(a, 0, "t3");
await startDaemon(b);
const workers = workerRecords();
assert.equal(workers.length, 2, "the old worker drains beside a new one");
const current = workers.find((record) => record.pid !== old.workerPid)!;
assert.equal(current.protocolVersion, 1);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed");
await type(b, old.terminal.id, "echo still-served");
await waitForScreen(b, old.terminal.id, "\nstill-served");
const t4 = await openTerminal(b, "t4");
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[t4.id],
"new terminals open in the current worker",
);
await paseo(b, ["terminal", "kill", old.terminal.id]);
await waitFor(
() => !isAlive(old.workerPid),
() => `the drained worker ${old.workerPid} did not exit`,
);
await waitFor(
() => !runtimeCopies().includes(old.runtime),
() => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays");
await type(b, t4.id, "echo after-drain");
await waitForScreen(b, t4.id, "\nafter-drain");
await stopDaemon(b);
console.log("✓ drained, exited, copy deleted\n");
// A released endpoint belongs to nobody. Reproduce another process taking it
// between starts; worker adoption is keyed by the home, not by a stable TCP port.
const releasedPort = Number(lastListen.split(":").at(-1));
await new Promise<void>((resolve, reject) => {
competingListener.once("error", reject);
competingListener.listen(releasedPort, "127.0.0.1", resolve);
});
console.log(`Competing listener owns released port ${releasedPort}`);
console.log("Test 5: a worker two protocol versions old is shut down");
const obsolete = await leaveWorkerWithProtocol(a, -1, "t5");
await startDaemon(b);
await waitFor(
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
() => "the obsolete worker or its terminal survived",
);
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
assert.equal(workerRecords().length, 1, "one current worker runs");
await stopDaemon(b);
console.log("✓ shut down\n");
// There is no supported previous wire at protocol 5. Future previous-version classification
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
for (const protocolVersion of [0, 1, 2, 3, 4]) {
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
const obsolete = await leaveWorkerWithProtocol(
a,
protocolVersion,
`obsolete-${protocolVersion}`,
);
await startDaemon(b);
await waitFor(
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
() => "the obsolete worker or its terminal survived",
);
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
const [current] = workerRecords();
assert.equal(workerRecords().length, 1, "one current worker runs");
assert(current);
assert.equal(current.protocolVersion, currentProtocolVersion);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
await waitFor(
() => !runtimeCopies().includes(obsolete.runtime),
() => `the obsolete worker's copy ${obsolete.runtime} was not deleted`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current copy stays");
const replacement = await openTerminal(b, `replacement-${protocolVersion}`);
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[replacement.id],
"new terminals open in the current worker",
);
await type(b, replacement.id, `echo after-protocol-cut-${protocolVersion}`);
await waitForScreen(b, replacement.id, `\nafter-protocol-cut-${protocolVersion}`);
await stopDaemon(b);
await waitFor(
() => !isAlive(replacement.pid) && !isAlive(current.pid),
() => "the replacement worker or program outlived daemon stop",
);
assert.deepEqual(workerRecords(), [], "no worker record is left");
assert.deepEqual(runtimeCopies(), [], "no copy is left");
console.log("✓ obsolete closed, current usable, copies cleaned up\n");
}
console.log("Test 6: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, 2, "t6");
console.log("Test 5: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer");
await startDaemon(b);
const listed = await run(b, ["terminal", "ls", "--all", "--json"]);
assert.notEqual(listed.exitCode, 0, "terminal ls fails");
@@ -514,6 +537,17 @@ try {
}
}
}
if (competingListener.listening) {
await new Promise<void>((resolve, reject) =>
competingListener.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
}),
);
}
restoreA?.();
for (const installation of [b, a]) {
await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]);
+1
View File
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
// Protocol support belongs to the installed client. Only browser hosting needs
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.terminalChunkedReplay]: true,
[CLIENT_CAPS.helloRejection]: true,
[CLIENT_CAPS.ownedSubscriptions]: true,
[CLIENT_CAPS.allProviders]: true,
+155 -54
View File
@@ -1101,6 +1101,7 @@ test("advertises client capabilities in hello", async () => {
projected_subagent_timeline: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
workspace_setup_blocked: true,
@@ -6387,66 +6388,80 @@ test("emits output events for the active terminal stream", async () => {
unsubscribe();
});
test("emits snapshot events for the subscribed terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
test.each([5, 500, 10000])(
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
async (history) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const snapshots: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "snapshot") {
return;
}
snapshots.push(event.state);
});
const events: unknown[] = [];
const replayEvents: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type === "replay") replayEvents.push(event.replay);
events.push(event);
});
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
const subscribePromise = client.subscribeTerminal("term-1", {
requestId: "sub-2",
restore: { mode: "visible-snapshot", scrollbackLines: history },
});
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
mode: "visible-snapshot",
scrollbackLines: history,
});
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
payload: encodeTerminalSnapshotPayload(state),
}),
);
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(snapshots).toEqual([state]);
});
expect(events).toEqual([]);
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
mock.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
);
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
},
);
test("sends explicit terminal input and resize commands", async () => {
const logger = createMockLogger();
@@ -8471,3 +8486,89 @@ test("closes before admission when a protected-capable daemon is reached in lega
expect(daemonReceived).toHaveLength(1);
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
});
test.each([false, true])(
"default terminal observation requests ANSI replay (chunked=%s)",
async (chunked) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: { ownedSubscriptions: true, "terminal-restore-modes": true },
});
await connecting;
const events: import("./terminal-stream-router").TerminalStreamEvent[] = [];
const observation = client.observeTerminal("default-terminal", (event) => events.push(event));
await vi.waitFor(() =>
expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
const request = parseSentFrame(transport.sent.at(-1));
expect(request.restore).toEqual({ mode: "full-snapshot" });
transport.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "default-terminal",
requestId: request.requestId,
subscriptionId: "default-subscription",
slot: 2,
error: null,
},
}),
);
await observation.ready;
const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m");
const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 };
if (chunked) {
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayBegin,
slot: 2,
payload: JSON.stringify(metadata),
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 2,
payload: data,
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }),
);
} else {
// An ANSI-era host selects its existing Restore path for this explicit request.
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }),
);
}
expect(events).toEqual([
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: chunked ? { type: "begin", ...metadata } : { type: "begin" },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "data", data },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "end" },
},
]);
},
);
+3 -2
View File
@@ -6414,7 +6414,8 @@ export class DaemonClient {
}
/**
* Snapshot/restore and output arrive in receive. Subscribe to the returned handle
* Defaults to a full ANSI replay. Replay begin/data/end and live output arrive in receive.
* Subscribe to the returned handle
* for terminal_stream_exit: payload.error means observation failure, not PTY exit.
* Either outcome detaches this slot and releases the handle automatically.
*/
@@ -6433,7 +6434,7 @@ export class DaemonClient {
{
type: "subscribe_terminal_request",
terminalId,
...(options?.restore ? { restore: options.restore } : {}),
restore: options?.restore ?? { mode: "full-snapshot" },
},
options,
);
+1
View File
@@ -1,3 +1,4 @@
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
export type { AgentAddress } from "@getpaseo/protocol/addresses";
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("routes restore frames as restore events", () => {
test("normalizes old ANSI restore into the replay lifecycle", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
payload,
});
expect(events).toEqual([
{
expect(events).toEqual(
["begin", "data", "end"].map((type) => ({
terminalId: "term-1",
subscriptionId: "subscription-1",
type: "restore",
data: payload,
},
]);
type: "replay",
replay: type === "data" ? { type, data: payload } : { type },
})),
);
});
});
@@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () =
router.handleFrame(frame);
expect(received).toEqual(["first", "second", "second"]);
});
test("streams chunks without joining and ignores released slots during replay", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const release = router.attach("old", "terminal", 4, (event) => events.push(event));
const payload = new TextEncoder().encode(
JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }),
);
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload });
const data = new TextEncoder().encode("\x1b[31m界🙂");
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(0, 7),
});
release();
router.attach("new", "terminal", 5, (event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(7),
});
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 4,
payload: new Uint8Array(),
});
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({
subscriptionId: "old",
type: "replay",
replay: { type: "data", data: data.subarray(0, 7) },
});
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload });
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data });
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 5,
payload: new Uint8Array(),
});
expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]);
});
+40 -15
View File
@@ -1,14 +1,17 @@
import {
decodeTerminalSnapshotPayload,
TerminalStreamOpcode,
TerminalReplayMetadataSchema,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalState } from "@getpaseo/protocol/messages";
export type TerminalReplayEvent =
| { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number }
| { type: "data"; data: Uint8Array }
| { type: "end" };
export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
| { type: "output"; data: Uint8Array }
| { type: "snapshot"; state: TerminalState }
| { type: "restore"; data: Uint8Array }
| { type: "replay"; replay: TerminalReplayEvent }
);
export class TerminalStreamRouter {
@@ -61,20 +64,42 @@ export class TerminalStreamRouter {
let event: TerminalStreamEvent;
if (frame.opcode === TerminalStreamOpcode.Output)
event = { ...identity, type: "output", data: frame.payload };
else if (frame.opcode === TerminalStreamOpcode.Restore)
event = { ...identity, type: "restore", data: frame.payload };
else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) return;
event = { ...identity, type: "snapshot", state };
} else return;
registration.receive(event);
for (const listener of this.listeners) {
else if (frame.opcode === TerminalStreamOpcode.Restore) {
// COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
for (const replay of [
{ type: "begin" },
{ type: "data", data: frame.payload },
{ type: "end" },
] as TerminalReplayEvent[])
this.deliver(registration.receive, { ...identity, type: "replay", replay });
continue;
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
let raw: unknown;
try {
listener(event);
raw = JSON.parse(new TextDecoder().decode(frame.payload));
} catch {
/* Passive diagnostics cannot interrupt delivery. */
return;
}
const parsed = TerminalReplayMetadataSchema.safeParse(raw);
if (!parsed.success) return;
const metadata = parsed.data;
event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } };
} else if (frame.opcode === TerminalStreamOpcode.ReplayData)
event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } };
else if (frame.opcode === TerminalStreamOpcode.ReplayEnd)
event = { ...identity, type: "replay", replay: { type: "end" } };
else return;
this.deliver(registration.receive, event);
}
}
private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void {
receive(event);
for (const listener of this.listeners) {
try {
listener(event);
} catch {
/* Passive diagnostics cannot interrupt delivery. */
}
}
}
@@ -89,3 +89,18 @@ describe("host route binary framing", () => {
).toThrow("Invalid host route ID");
});
});
it.each([
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
])("decodes replay opcode %s directly and inside a host route", (opcode) => {
const payload = new TextEncoder().encode("界 replay");
const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload });
const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } };
expect(decodeBinaryFrame(bytes)).toEqual(expected);
const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes));
expect(routed?.kind).toBe("host_route");
if (routed?.kind !== "host_route") throw new Error("Missing host route");
expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected);
});
@@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.ReplayBegin:
case TerminalStreamOpcode.ReplayData:
case TerminalStreamOpcode.ReplayEnd:
case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null;
@@ -1,6 +1,12 @@
import { z } from "zod";
import { TerminalStateSchema } from "../messages.js";
export const TerminalReplayMetadataSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
retainedHistoryLines: z.number().int().nonnegative(),
});
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
@@ -13,6 +19,9 @@ export const TerminalStreamOpcode = {
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
ReplayBegin: 0x06,
ReplayData: 0x07,
ReplayEnd: 0x08,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.ReplayBegin ||
value === TerminalStreamOpcode.ReplayData ||
value === TerminalStreamOpcode.ReplayEnd ||
value === TerminalStreamOpcode.Restore
);
}
@@ -1,4 +1,5 @@
export const CLIENT_CAPS = {
terminalChunkedReplay: "terminal_chunked_replay",
helloRejection: "hello_rejection",
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
ownedSubscriptions: "owned_subscriptions",
@@ -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("통 합 !");
});
});
-224
View File
@@ -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 [];
}
+1
View File
@@ -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",
@@ -1493,7 +1493,6 @@ describe("terminal MCP tools", () => {
id: "term-1",
name: "daemon",
cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}),
captureTerminal,
});
@@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from
import { expect, test } from "vitest";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -46,7 +45,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
terminalId: terminal.id,
headless: liveHeadless,
});
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot");
const initialSnapshot = waitForReplayEnd(client, terminal.id);
const liveSubscription = await subscribeTerminal(client, terminal.id);
await initialSnapshot;
@@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac
text: BYTE_DONE_MARKER,
rows: BYTE_TEST_SIZE.rows,
});
const daemonSnapshot = await readTerminalSnapshot({
const daemonSnapshot = await readReplayViewport({
port: daemon.port,
terminalId: terminal.id,
});
await liveHeadless.flush();
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot));
expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines);
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines);
@@ -79,7 +78,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
terminalId: terminal.id,
headless: restoreHeadless,
});
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore");
const restoreFrame = waitForReplayEnd(client, terminal.id);
await subscribeTerminal(client, terminal.id, {
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
@@ -116,12 +115,10 @@ class ClientHeadlessTerminal {
}
applyEvent(event: TerminalStreamEvent): void {
if (event.type === "snapshot") {
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true });
return;
}
if (event.type === "restore") {
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
if (event.type === "replay") {
if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true });
if (event.replay.type === "data")
this.enqueueText(this.decoder.decode(event.replay.data, { stream: true }));
return;
}
this.enqueueText(this.decoder.decode(event.data, { stream: true }));
@@ -187,35 +184,32 @@ async function subscribeTerminal(
return response.subscription;
}
async function readTerminalSnapshot(input: {
port: number;
terminalId: string;
}): Promise<TerminalState> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${input.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{
lines: string[];
cursor: TerminalState["cursor"];
}> {
const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" });
const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless });
try {
await client.connect();
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot");
const completed = waitForReplayEnd(client, input.terminalId);
await subscribeTerminal(client, input.terminalId);
const event = await snapshot;
return event.state;
await completed;
await headless.flush();
return { lines: headless.visibleLines(), cursor: headless.cursor() };
} finally {
stop();
await client.close();
}
}
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>(
client: DaemonClient,
terminalId: string,
type: TType,
): Promise<Extract<TerminalStreamEvent, { type: TType }>> {
return client.waitForTerminalStreamEvent(
(event) => event.terminalId === terminalId && event.type === type,
10_000,
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise<void> {
await client.waitForTerminalStreamEvent(
(event) =>
event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end",
10000,
);
}
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> {
@@ -287,24 +281,16 @@ async function waitForCondition<T>(
throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`);
}
function visibleTerminalLines(state: TerminalState): string[] {
return state.grid.map(terminalRowText);
}
function terminalRowText(row: TerminalState["grid"][number]): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
const cells: TerminalCell[] = [];
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
const cell = line?.getCell(col, reusableCell);
cells.push({ char: cell?.getChars() || " " });
}
return terminalRowText(cells);
return cells
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function packageLockStreamScript(): string {
@@ -1,3 +1,4 @@
import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
@@ -10,7 +11,6 @@ import {
type WSOutboundMessage,
} from "@getpaseo/protocol/messages";
import {
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
@@ -80,6 +80,24 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
function readHeadlessState(terminal: HeadlessTerminal): TerminalState {
const buffer = terminal.buffer.active;
const lines: TerminalState["grid"] = [];
for (let row = 0; row < buffer.length; row++) {
const cells: TerminalState["grid"][number] = [];
for (let col = 0; col < terminal.cols; col++)
cells.push({ char: buffer.getLine(row)?.getCell(col)?.getChars() ?? "" });
lines.push(cells);
}
return {
rows: terminal.rows,
cols: terminal.cols,
grid: lines.slice(buffer.baseY),
scrollback: lines.slice(0, buffer.baseY),
cursor: { row: buffer.cursorY, col: buffer.cursorX },
};
}
async function waitForTerminalSnapshot(
client: DaemonClient,
terminalId: string,
@@ -92,16 +110,34 @@ async function waitForTerminalSnapshot(
reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`));
}, timeout);
let terminal: HeadlessTerminal | null = null;
let writes = Promise.resolve();
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminalId || event.type !== "snapshot") {
return;
if (event.terminalId !== terminalId || event.type !== "replay") return;
const replay = event.replay;
if (replay.type === "begin") {
terminal?.dispose();
terminal = new HeadlessTerminal({
rows: replay.rows ?? 24,
cols: replay.cols ?? 80,
scrollback: 1000,
allowProposedApi: true,
});
} else if (replay.type === "data") {
const active = terminal!;
writes = writes.then(() => new Promise<void>((done) => active.write(replay.data, done)));
} else {
void writes.then(() => {
const active = terminal!;
const state = readHeadlessState(active);
if (!predicate(state)) return null;
clearTimeout(timeoutHandle);
unsubscribe();
active.dispose();
resolve(state);
return null;
});
}
if (!predicate(event.state)) {
return;
}
clearTimeout(timeoutHandle);
unsubscribe();
resolve(event.state);
});
});
}
@@ -386,17 +422,24 @@ async function collectRawBinaryFrames(
});
}
async function decodeRestoreState(
frame: TerminalStreamFrame,
terminalId: string,
): Promise<TerminalState> {
const size = ctx.daemon.daemon.terminalManager!.getTerminal(terminalId)!.getSize();
const terminal = new HeadlessTerminal({ ...size, scrollback: 1000, allowProposedApi: true });
try {
await new Promise<void>((resolve) => terminal.write(frame.payload, resolve));
return readHeadlessState(terminal);
} finally {
terminal.dispose();
}
}
function getFrameText(frame: TerminalStreamFrame): string {
if (frame.opcode === TerminalStreamOpcode.Output) {
return new TextDecoder().decode(frame.payload);
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
return "";
}
return extractStateText(state);
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
@@ -446,7 +489,7 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput
if (!frame || frame.slot !== slot) {
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
if (frame.opcode === TerminalStreamOpcode.Restore) {
snapshotCount += 1;
return;
}
@@ -634,7 +677,7 @@ async function measureRepeatCadenceForTerminal(input: {
const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`);
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -817,13 +860,7 @@ test("live terminal restore skips the initial snapshot", async () => {
expect(outputText).toContain("after-live");
expect(outputText).not.toContain("before-live");
expect(
outputFrames.some(
(frame) =>
frame.opcode === TerminalStreamOpcode.Snapshot ||
frame.opcode === TerminalStreamOpcode.Restore,
),
).toBe(false);
expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
} finally {
await closeWebSocket(ws);
}
@@ -901,15 +938,12 @@ test("propagates debounced terminal titles through list responses and snapshots"
}
expect(listedTitle).toBe("Build Output");
const snapshotPromise = waitForTerminalSnapshot(
ctx.client,
terminalId,
(state) => state.title === "Build Output",
const attached = await ctx.client.subscribeTerminal(terminalId);
expect(attached.error).toBeNull();
const afterAttach = await ctx.client.listTerminals(cwd);
expect(afterAttach.terminals.find((terminal) => terminal.id === terminalId)?.title).toBe(
"Build Output",
);
await ctx.client.subscribeTerminal(terminalId);
const snapshot = await snapshotPromise;
expect(snapshot.title).toBe("Build Output");
rmSync(cwd, { recursive: true, force: true });
}, 30000);
@@ -973,13 +1007,10 @@ test("subscribe response is sent before the initial snapshot frame", async () =>
return;
}
const frame = decodeTerminalStreamFrame(new Uint8Array(buffer));
if (frame?.opcode !== TerminalStreamOpcode.Snapshot) {
return;
}
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state || !extractStateText(state).includes("hello-ordering")) {
if (frame?.opcode !== TerminalStreamOpcode.Restore) {
return;
}
if (!getFrameText(frame).includes("hello-ordering")) return;
events.push("snapshot");
maybeResolve();
};
@@ -1113,7 +1144,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first");
await waitForRawBinaryFrame(
firstWs,
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore,
);
sendRawTerminalInput(firstWs, firstSlot, "e");
await waitForRawBinaryFrame(
@@ -1140,7 +1171,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second");
await waitForRawBinaryFrame(
secondWs,
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore,
);
const replay = await waitForRawBinaryFrame(
secondWs,
@@ -1172,7 +1203,7 @@ test("reconnected terminal streams do not replay input modes before they are ena
const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive");
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
);
await waitForNoRawBinaryFrame(ws, 300);
} finally {
@@ -1188,7 +1219,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
const ws = await connectRawWebSocket(ctx.daemon.port);
await subscribeRawTerminal(ws, terminalId, "sub-raw");
await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000);
await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000);
const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } })
._socket;
@@ -1203,12 +1234,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
await new Promise((resolve) => setTimeout(resolve, 750));
const catchUpFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
15000,
);
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore);
await closeWebSocket(ws);
rmSync(cwd, { recursive: true, force: true });
@@ -1260,7 +1291,7 @@ test("resize updates server dimensions without sending a live snapshot", async (
await new Promise((resolve) => setTimeout(resolve, 300));
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize();
expect(state?.rows).toBe(10);
expect(state?.cols).toBe(40);
@@ -1277,7 +1308,7 @@ test("the latest resize from any attached client owns the PTY size", async () =>
);
const readSize = () => {
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize();
return state ? { rows: state.rows, cols: state.cols } : null;
};
const expectSize = async (size: { rows: number; cols: number }) => {
@@ -1403,10 +1434,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a");
const initialSnapshot = await waitForRawBinaryFrame(
firstSocket,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
@@ -1442,9 +1473,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
try {
await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b");
const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000);
expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Restore);
const state = decodeTerminalSnapshotPayload(reconnectFrame.payload);
const state = await decodeRestoreState(reconnectFrame, terminalId);
expect(state).not.toBeNull();
expect(extractStateText(state!)).toContain("before-drop");
expect(extractStateText(state!)).toContain("while-dead");
@@ -1513,11 +1544,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl
await subscribeRawTerminal(ws, terminalId, "sub-fidelity");
const snapshotFrame = await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
const state = decodeTerminalSnapshotPayload(snapshotFrame.payload);
const state = await decodeRestoreState(snapshotFrame, terminalId);
expect(state).not.toBeNull();
expect(state).toMatchObject({
rows: 24,
@@ -1545,7 +1576,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
await subscribeRawTerminal(ws, terminalId, "sub-exit");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1561,8 +1592,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
const exitMessage = await exitMessagePromise;
expect(exitMessage.message.type).toBe("terminal_stream_exit");
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
expect(subscribeResult.error).toBe("Terminal not found");
await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found");
await waitForNoRawBinaryFrame(ws, 750);
} finally {
await closeWebSocket(ws);
@@ -1580,7 +1610,7 @@ test("empty input frame does not crash the server", async () => {
const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1619,7 +1649,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
await subscribeRawTerminal(ws, terminalId, "sub-large-output");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1636,7 +1666,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
.reduce((sum, frame) => sum + frame.payload.byteLength, 0);
return (
outputBytes >= 1024 * 1024 ||
collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)
collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)
);
},
20000,
@@ -1646,7 +1676,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
for (const frame of frames) {
expect(
frame.opcode === TerminalStreamOpcode.Output ||
frame.opcode === TerminalStreamOpcode.Snapshot,
frame.opcode === TerminalStreamOpcode.Restore,
).toBe(true);
}
@@ -1836,3 +1866,132 @@ describe("list terminals across directories", () => {
]);
});
});
test.each([3000, 12050])(
"chunked SDK replay retains requested history for %s printed lines",
async (count) => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mreplay-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 30`,
],
},
});
const id = created.terminal!.id;
await waitForCondition(
async () =>
(await ctx.client.captureTerminal(id)).lines
.join("\n")
.includes(`replay-${String(count).padStart(4, "0")}`),
10000,
);
const events: import("@getpaseo/client").TerminalStreamEvent[] = [];
let end!: () => void;
const completed = new Promise<void>((resolve) => {
end = resolve;
});
const subscription = ctx.client.observeTerminal(
id,
(event) => {
events.push(event);
if (event.type === "replay" && event.replay.type === "end") end();
},
{ restore: { mode: "visible-snapshot", scrollbackLines: 10000 } },
);
try {
await subscription.ready;
await completed;
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
expect(replay[0]).toMatchObject({
type: "begin",
retainedHistoryLines: count === 12050 ? 10000 : expect.any(Number),
});
expect(replay.at(-1)).toEqual({ type: "end" });
const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : []));
expect(chunks.every((chunk) => chunk.length <= 65536)).toBe(true);
const text = Buffer.concat(chunks).toString("utf8");
const begin = replay[0];
if (begin?.type !== "begin" || begin.rows === undefined)
throw new Error("Missing replay dimensions");
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 === 3000) expect(text).toContain("replay-0001界🙂");
else {
expect(text).not.toContain("replay-0001");
expect(chunks.length).toBeGreaterThan(1);
}
} finally {
await subscription.release();
}
},
30000,
);
test.each([false, true])(
"default SDK observation replays a terminal without waiting for output (quietScreen=%s)",
async (quietScreen) => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: process.execPath,
args: [
"-e",
`${quietScreen ? 'process.stdout.write("QUIET-SCREEN\\r\\n");' : ""} process.stdin.on("data", data => process.stdout.write("LIVE:" + data)); setInterval(() => {}, 1000);`,
],
},
});
const id = created.terminal!.id;
if (quietScreen)
await waitForCondition(
async () => (await ctx.client.captureTerminal(id)).lines.join("").includes("QUIET-SCREEN"),
10000,
);
const events: import("@getpaseo/client").TerminalStreamEvent[] = [];
const observation = ctx.client.observeTerminal(id, (event) => events.push(event));
try {
await observation.ready;
await waitForCondition(
() => events.some((event) => event.type === "replay" && event.replay.type === "end"),
10000,
);
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
const begin = replay[0];
if (begin?.type !== "begin" || !begin.rows || !begin.cols)
throw new Error("Missing replay begin geometry");
expect(replay.at(-1)).toEqual({ type: "end" });
const terminal = new HeadlessTerminal({
rows: begin.rows,
cols: begin.cols,
allowProposedApi: true,
});
try {
for (const event of replay)
if (event.type === "data")
await new Promise<void>((resolve) => terminal.write(event.data, resolve));
const text = extractStateText(readHeadlessState(terminal));
expect(text).toBe(quietScreen ? "QUIET-SCREEN" : "");
} finally {
terminal.dispose();
}
ctx.client.sendTerminalInput(id, { type: "input", data: "next\r" });
await waitForCondition(
() =>
events.some(
(event) =>
event.type === "output" && new TextDecoder().decode(event.data).includes("LIVE:next"),
),
10000,
);
} finally {
await observation.release();
}
},
30000,
);
@@ -822,86 +822,100 @@ test("same-workspace terminal directory observers do not share a teardown slot",
}
});
test("terminal output has separate server IDs and binary slots, including in a shared session", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
const peers: SubscriptionPeer[] = [];
try {
await admin.connect();
const workspace = (
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
).workspace!;
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
workspaceId: workspace.id,
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
test.each([false, true])(
"terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)",
async (chunked) => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
expect(result.error).toBe(null);
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
peers.push(idle);
const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
peers.push(peer);
for (const requestId of ["output-a", "output-b"]) {
await peer.request({
type: "subscribe_terminal_request",
requestId,
terminalId: result.terminal!.id,
const peers: SubscriptionPeer[] = [];
try {
await admin.connect();
const workspace = (
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
).workspace!;
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
workspaceId: workspace.id,
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
}
const replies = peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "subscribe_terminal_response" &&
frame.message.payload.error === null
? [frame.message.payload]
: [],
);
expect(replies).toHaveLength(2);
const ids = replies.map(
(reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId,
);
expect(ids.every((id) => typeof id === "string")).toBe(true);
expect(ids[0]).not.toBe(ids[1]);
expect(replies[0].slot).not.toBe(replies[1].slot);
await expect
.poll(() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot)
.map((frame) => frame!.slot),
)
.toEqual(expect.arrayContaining(replies.map((reply) => reply.slot)));
expect(idle.binaryFrames).toEqual([]);
await peer.request({
type: "subscription.release.request",
requestId: "release-output-a",
subscriptionId: ids[0],
});
peer.binaryFrames.length = 0;
admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" });
await expect
.poll(
() =>
expect(result.error).toBe(null);
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
terminal_chunked_replay: chunked,
});
peers.push(idle);
const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
terminal_chunked_replay: chunked,
});
peers.push(peer);
for (const requestId of ["output-a", "output-b"]) {
await peer.request({
type: "subscribe_terminal_request",
requestId,
terminalId: result.terminal!.id,
});
}
const replies = peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "subscribe_terminal_response" &&
frame.message.payload.error === null
? [frame.message.payload]
: [],
);
expect(replies).toHaveLength(2);
const ids = replies.map(
(reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId,
);
expect(ids.every((id) => typeof id === "string")).toBe(true);
expect(ids[0]).not.toBe(ids[1]);
expect(replies[0].slot).not.toBe(replies[1].slot);
await expect
.poll(() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => Buffer.from(frame!.payload).toString())
.join(""),
{ timeout: 5000 },
)
.toContain("remaining-observer");
expect(
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.every((frame) => frame?.slot === replies[1].slot),
).toBe(true);
expect(idle.binaryFrames).toEqual([]);
await admin.killTerminal(result.terminal!.id);
} finally {
for (const peer of peers) peer.close();
await admin.close();
await daemon.close();
}
});
.filter(
(frame) =>
frame?.opcode ===
(chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore),
)
.map((frame) => frame!.slot),
)
.toEqual(expect.arrayContaining(replies.map((reply) => reply.slot)));
expect(idle.binaryFrames).toEqual([]);
await peer.request({
type: "subscription.release.request",
requestId: "release-output-a",
subscriptionId: ids[0],
});
peer.binaryFrames.length = 0;
admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" });
await expect
.poll(
() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => Buffer.from(frame!.payload).toString())
.join(""),
{ timeout: 5000 },
)
.toContain("remaining-observer");
expect(
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.every((frame) => frame?.slot === replies[1].slot),
).toBe(true);
expect(idle.binaryFrames).toEqual([]);
await admin.killTerminal(result.terminal!.id);
} finally {
for (const peer of peers) peer.close();
await admin.close();
await daemon.close();
}
},
);
test("a modern connection and pure reads retain no client observation producers", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
@@ -68,13 +68,7 @@ function createStubTerminalManager(
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
kill: () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
+2 -2
View File
@@ -1129,11 +1129,11 @@ export class Session {
terminalManager,
emit: (msg) => this.emit(msg),
hasBinaryChannel: () => this.onBinaryMessage !== null,
supportsChunkedReplay: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
clientSupportsWrapReflow: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source),
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
});
this.agentDirectory = new AgentDirectory({
@@ -0,0 +1,117 @@
import type pino from "pino";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "../workspace-registry.js";
export function createTestRegistries() {
let archiveGate: {
started: ReturnType<typeof deferred>;
release: ReturnType<typeof deferred>;
} | null = null;
const holdNextArchive = () => {
archiveGate = { started: deferred(), release: deferred() };
return archiveGate;
};
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const projectRegistry: ProjectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: string) => projects.get(id) ?? null,
getOrCreateActiveByRoot: async (input) => {
const existing = Array.from(projects.values()).find(
(project) => !project.archivedAt && project.rootPath === input.rootPath,
);
if (existing) return existing;
const record = createPersistedProjectRecord({
projectId: `prj_${projects.size}`,
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
projects.set(record.projectId, record);
return record;
},
update: async (id, updater) => {
const current = projects.get(id);
if (!current) return null;
const updated = updater(current);
projects.set(id, updated);
return updated;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.projectId, record);
},
archive: async (id: string, archivedAt: string) => {
const existing = projects.get(id);
if (existing) {
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: string) => {
projects.delete(id);
},
};
const workspaceRegistry: WorkspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: string) => workspaces.get(id) ?? null,
update: async (id, updater) => {
const existing = workspaces.get(id);
if (!existing) return null;
const updated = updater(existing);
workspaces.set(id, updated);
return updated;
},
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
archive: async (id: string, archivedAt: string) => {
const gate = archiveGate;
archiveGate = null;
if (gate) {
gate.started.resolve();
await gate.release.promise;
}
const existing = workspaces.get(id);
if (!existing || existing.archivedAt) return false;
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
return true;
},
remove: async (id: string) => {
workspaces.delete(id);
},
};
return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive };
}
export function createTestLogger() {
const logger = {
child: () => logger,
trace: () => undefined,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
function deferred(): { promise: Promise<void>; resolve(): void } {
let resolve!: () => void;
const promise = new Promise<void>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { createHook, executionAsyncResource } from "node:async_hooks";
import path from "node:path";
import { setImmediate as nextTurn } from "node:timers/promises";
import { MessagePort } from "node:worker_threads";
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { deriveProjectKey } from "../project-key.js";
import { WorkspaceReconciliationService } from "../workspace-reconciliation-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "../workspace-registry.js";
import { createTestRegistries, createTestLogger } from "./reconciliation-registries.js";
// Bundle before launch: neither the runner nor a TypeScript loader belongs to
// the measured thread. Keep fixture construction and its garbage outside the
// cold run; GC caused by reconciliation itself remains in the budget.
const rootPath = process.argv[2];
assert.ok(rootPath, "Project root is required");
const timestamp = "2025-01-01T00:00:00.000Z";
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
for (let i = 0; i < 20000; i++) {
const workspaceId = `w${i}`;
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
assert.ok(global.gc, "Launch the probe with --expose-gc");
global.gc();
await nextTurn();
let workerMessages = 0;
const activity = createHook({
before() {
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
},
}).enable();
try {
const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () =>
service.runOnce(),
);
activity.disable();
process.stdout.write(
JSON.stringify({
blockingMs: measured.blockingMs,
filesystemRequests: measured.filesystemRequests,
yields: measured.yields,
changesAppliedCount: measured.result.changesApplied.length,
allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null),
workerMessages,
}),
);
} finally {
activity.disable();
service.dispose();
}
@@ -1,8 +1,11 @@
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { build } from "esbuild";
import { createTestRegistries, createTestLogger } from "./test-utils/reconciliation-registries.js";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
@@ -10,12 +13,6 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
type ReconciliationChange,
WorkspaceReconciliationService,
@@ -31,107 +28,6 @@ function canonicalLocalProjectKey(rootPath: string): string {
});
}
function createTestRegistries() {
let archiveGate: {
started: ReturnType<typeof deferred>;
release: ReturnType<typeof deferred>;
} | null = null;
const holdNextArchive = () => {
archiveGate = { started: deferred(), release: deferred() };
return archiveGate;
};
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const projectRegistry: ProjectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: string) => projects.get(id) ?? null,
getOrCreateActiveByRoot: async (input) => {
const existing = Array.from(projects.values()).find(
(project) => !project.archivedAt && project.rootPath === input.rootPath,
);
if (existing) return existing;
const record = createPersistedProjectRecord({
projectId: `prj_${projects.size}`,
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
projects.set(record.projectId, record);
return record;
},
update: async (id, updater) => {
const current = projects.get(id);
if (!current) return null;
const updated = updater(current);
projects.set(id, updated);
return updated;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.projectId, record);
},
archive: async (id: string, archivedAt: string) => {
const existing = projects.get(id);
if (existing) {
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: string) => {
projects.delete(id);
},
};
const workspaceRegistry: WorkspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: string) => workspaces.get(id) ?? null,
update: async (id, updater) => {
const existing = workspaces.get(id);
if (!existing) return null;
const updated = updater(existing);
workspaces.set(id, updated);
return updated;
},
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
archive: async (id: string, archivedAt: string) => {
const gate = archiveGate;
archiveGate = null;
if (gate) {
gate.started.resolve();
await gate.release.promise;
}
const existing = workspaces.get(id);
if (!existing || existing.archivedAt) return false;
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
return true;
},
remove: async (id: string) => {
workspaces.delete(id);
},
};
return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive };
}
function createTestLogger() {
const logger = {
child: () => logger,
trace: () => undefined,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
interface CapturedLogRecord {
message: string;
payload: unknown;
@@ -626,52 +522,63 @@ describe("WorkspaceReconciliationService", () => {
test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
for (let i = 0; i < 20000; i++) {
const workspaceId = `w${i}`;
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
const probePath = path.join(rootPath, "reconciliation-responsiveness.mjs");
await build({
entryPoints: [
fileURLToPath(new URL("./test-utils/reconciliation-responsiveness.ts", import.meta.url)),
],
outfile: probePath,
bundle: true,
platform: "node",
format: "esm",
});
try {
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
"workspace reconciliation main-thread work",
() => service.runOnce(),
// This CPU belongs to the runner, not to reconciliation. Keep it present
// throughout the read so a later loop turn cannot contaminate the budget.
let running = true;
const unrelatedWork = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 60_000);
if (running) unrelated = setImmediate(unrelatedWork);
};
let unrelated = setImmediate(unrelatedWork);
try {
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", probePath, rootPath],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
const {
changesAppliedCount,
blockingMs,
filesystemRequests,
yields,
allArchived,
workerMessages,
} = JSON.parse(stdout) as {
changesAppliedCount: number;
blockingMs: number;
filesystemRequests: number;
yields: number;
allArchived: boolean;
workerMessages: number;
};
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBe(20001);
expect(result.changesApplied).toHaveLength(20000);
expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(
true,
);
expect(changesAppliedCount).toBe(20000);
expect(allArchived).toBe(true);
expect(yields).toBeGreaterThan(0);
expect(workerMessages).toBe(0);
} finally {
service.dispose();
running = false;
clearImmediate(unrelated);
}
}, 30000);
@@ -209,13 +209,8 @@ describe("runWorktreeAutoTerminals", () => {
getSize: () => ({ rows: 0, cols: 0 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({
rows: 0,
cols: 0,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => false,
kill: () => {},
killAndWait: async () => {},
};
@@ -312,13 +307,8 @@ describe("runWorktreeAutoTerminals", () => {
}
};
},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
kill: () => {},
onTitleChange: () => () => {},
onActivityChange: () => () => {},
@@ -269,9 +269,9 @@ export function buildWorktreeSetupDetail(input: {
}
async function waitForTerminalBootstrapReadiness(
terminal: Pick<TerminalSession, "getState" | "subscribe">,
terminal: Pick<TerminalSession, "hasOutput" | "subscribe">,
): Promise<void> {
if (terminalHasOutput(terminal.getState())) {
if (terminal.hasOutput()) {
return;
}
@@ -304,7 +304,7 @@ async function waitForTerminalBootstrapReadiness(
finish();
});
if (terminalHasOutput(terminal.getState())) {
if (terminal.hasOutput()) {
finish();
return;
}
@@ -313,17 +313,6 @@ async function waitForTerminalBootstrapReadiness(
});
}
function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): boolean {
for (const row of [...state.scrollback, ...state.grid]) {
for (const cell of row) {
if (cell.char.trim().length > 0) {
return true;
}
}
}
return false;
}
export async function runWorktreeAutoTerminals(options: {
workspaceId: string;
worktree: WorktreeConfig;
@@ -212,13 +212,7 @@ function createTerminalManagerStub(options?: {
id: `terminal-${terminals.length + 1}`,
name: input.name ?? "Terminal",
cwd: input.cwd,
getState: () => ({
rows: 1,
cols: 1,
scrollback: [[{ char: "$" }]],
grid: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
@@ -1,6 +1,5 @@
import stripAnsi from "strip-ansi";
import type { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalSession } 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: TerminalSession,
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();
@@ -1,12 +1,16 @@
import {
replayTerminal,
type TerminalReplaySink,
type TerminalReplayMetadata,
type TerminalReplayOptions,
} from "./terminal-replay.js";
import {
createTerminal,
type LocalTerminalSession,
type TerminalActivityTransition,
type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} 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";
@@ -76,10 +80,11 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid";
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(
replayTerminal(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null>;
options: TerminalReplayOptions | undefined,
sink: TerminalReplaySink,
): Promise<TerminalReplayMetadata | null>;
setTerminalTitle(id: string, title: string): boolean;
setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>;
clearTerminalAttention(id: string): Promise<boolean>;
@@ -107,6 +112,10 @@ export interface TerminalManagerOptions {
/** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */
export interface LocalTerminalManager extends TerminalManager {
createTerminal(
options: Parameters<TerminalManager["createTerminal"]>[0],
): Promise<LocalTerminalSession>;
getTerminal(id: string): LocalTerminalSession | undefined;
getTerminalProcessId(id: string): number | null;
getTerminalActivityToken(id: string): string | null;
}
@@ -337,7 +346,7 @@ export function createTerminalManager(
activityUrl?: string | null;
baseEnv?: Record<string, string>;
paseoCliPath?: string | null;
}): Promise<TerminalSession> {
}): Promise<LocalTerminalSession> {
assertAbsolutePath(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
@@ -357,7 +366,7 @@ export function createTerminalManager(
...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}),
};
terminalActivityTokenById.set(terminalId, activityToken);
let session: TerminalSession;
let session: LocalTerminalSession;
try {
session = registerSession(
await createTerminal({
@@ -404,7 +413,7 @@ export function createTerminalManager(
return expected === token ? "valid" : "invalid";
},
getTerminal(id: string): TerminalSession | undefined {
getTerminal(id: string): LocalTerminalSession | undefined {
return terminalsById.get(id);
},
@@ -416,11 +425,9 @@ export function createTerminalManager(
return terminalActivityTokenById.get(id) ?? null;
},
async getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
async replayTerminal(id, options, sink) {
const session = terminalsById.get(id);
return session ? replayTerminal(session, options, sink) : null;
},
setTerminalTitle(id: string, title: string): boolean {
@@ -482,7 +489,7 @@ export function createTerminalManager(
totalLines: 0,
};
}
return captureTerminalLines(session, options);
return session.captureLines(options);
},
listDirectories(): string[] {
@@ -0,0 +1,135 @@
import type { FramedSocket } from "./terminal-worker-frames.js";
import type {
TerminalWorkerRequest,
TerminalWorkerToParentMessage,
} from "./terminal-worker-protocol.js";
import type { LocalTerminalSession } from "./terminal.js";
export interface TerminalReplayOptions {
scrollbackLines?: number;
}
export interface TerminalReplayMetadata {
rows: number;
cols: number;
retainedHistoryLines: number;
revision: number;
replayPreamble: string;
}
export type TerminalReplayEvent =
| { type: "begin"; metadata: TerminalReplayMetadata }
| { type: "data"; data: Uint8Array }
| { type: "end" };
export type TerminalReplaySink = (event: TerminalReplayEvent) => void;
export const TERMINAL_REPLAY_CHUNK_BYTES = 64 * 1024;
export function* createTerminalReplay(
session: LocalTerminalSession,
options: TerminalReplayOptions | undefined,
): Generator<TerminalReplayEvent, TerminalReplayMetadata> {
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) };
}
yield { type: "end" };
return metadata;
}
export async function replayTerminal(
session: LocalTerminalSession,
options: TerminalReplayOptions | undefined,
sink: TerminalReplaySink,
): Promise<TerminalReplayMetadata> {
const replay = createTerminalReplay(session, options);
let next = replay.next();
while (!next.done) {
sink(next.value);
next = replay.next();
}
return next.value;
}
interface TerminalReplayPorts {
getSession(id: string): LocalTerminalSession | undefined;
flushOutput(id: string): void;
}
/** Worker-owned ANSI capture and paced delivery; live observations never wait for a replay. */
export function createTerminalReplayController(ports: TerminalReplayPorts) {
let controller: FramedSocket | null = null;
function sendToController(message: TerminalWorkerToParentMessage): void {
// A stalled physical controller is different from a stalled viewer: all its
// observations must reconnect to the surviving worker after this socket closes.
if (controller && controller.socket.writableLength > 4 * 1024 * 1024) {
controller.socket.destroy();
return;
}
controller?.send(message);
}
async function deliverReplay(
connection: FramedSocket,
message: Extract<TerminalWorkerRequest, { type: "replayTerminal" }>,
): Promise<void> {
ports.flushOutput(message.terminalId);
const session = ports.getSession(message.terminalId);
if (!session) {
await connection.sendAndWait({
type: "response",
requestId: message.requestId,
ok: true,
result: null,
});
return;
}
// Flush and capture share one synchronous turn. Each daemon viewer buffers
// its own live tail and discards revisions already included in this cut.
const replay = createTerminalReplay(session, message.options);
let next = replay.next();
while (!next.done) {
if (controller !== connection) throw new Error("Terminal replay controller disconnected");
await connection.sendAndWait({
type: "terminalReplay",
requestId: message.requestId,
event: next.value,
});
next = replay.next();
}
await connection.sendAndWait({
type: "response",
requestId: message.requestId,
ok: true,
result: next.value,
});
}
return {
setConnection(connection: FramedSocket | null): void {
if (controller === connection) return;
const previous = controller;
controller = connection;
previous?.socket.destroy();
},
send: sendToController,
async replay(
request: Extract<TerminalWorkerRequest, { type: "replayTerminal" }>,
): Promise<void> {
const connection = controller;
if (!connection) return;
try {
await deliverReplay(connection, request);
} catch (error) {
await connection
.sendAndWait({
type: "response",
requestId: request.requestId,
ok: false,
error: error instanceof Error ? error.message : String(error),
})
.catch(() => {});
}
},
};
}
@@ -4,56 +4,45 @@ import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import {
createTerminalRestoreDelivery,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js";
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
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();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({
expect(
resolveTerminalRestoreSnapshotOptions({ restore: { mode: "live" }, chunked: false }),
).toBeNull();
expect(
resolveTerminalRestoreSnapshotOptions({ restore: { mode: "full-snapshot" }, chunked: false }),
).toBeUndefined();
expect(
resolveTerminalRestoreSnapshotOptions({
restore: { mode: "visible-snapshot" },
chunked: false,
}),
).toEqual({
scrollbackLines: 200,
});
expect(
resolveTerminalRestoreSnapshotOptions({
mode: "visible-snapshot",
scrollbackLines: 999,
restore: { mode: "visible-snapshot", scrollbackLines: 999 },
chunked: false,
}),
).toEqual({ scrollbackLines: 500 });
});
test("promotes live restore to visible restore after output overflow", () => {
test("bounds pressure recovery for every restore mode", () => {
expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({
mode: "visible-snapshot",
scrollbackLines: 500,
});
expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({
mode: "full-snapshot",
mode: "visible-snapshot",
scrollbackLines: 500,
});
});
@@ -61,10 +50,7 @@ describe("terminal restore policy", () => {
const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({
slot: 4,
snapshot: {
state: terminalState("restored"),
revision: 1,
},
data: new TextEncoder().encode("restored"),
}),
);
@@ -73,3 +59,40 @@ describe("terminal restore policy", () => {
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored");
});
});
test("projects chunked replay begin onto the declared wire metadata", () => {
const frames: Uint8Array[] = [];
const deliver = createTerminalRestoreDelivery({
slot: 7,
chunked: true,
send: (frame) => {
frames.push(frame);
},
});
deliver({
type: "begin",
metadata: {
rows: 30,
cols: 120,
retainedHistoryLines: 842,
revision: 5123,
replayPreamble: "\x1b[?2004h",
},
});
const data = new TextEncoder().encode("\x1b[31m界 replay\x1b[0m");
deliver({ type: "data", data });
deliver({ type: "end" });
const decoded = frames.map((frame) => decodeTerminalStreamFrame(frame)!);
expect(decoded.map((frame) => ({ slot: frame.slot, opcode: frame.opcode }))).toEqual([
{ slot: 7, opcode: TerminalStreamOpcode.ReplayBegin },
{ slot: 7, opcode: TerminalStreamOpcode.ReplayData },
{ slot: 7, opcode: TerminalStreamOpcode.ReplayEnd },
]);
expect(JSON.parse(new TextDecoder().decode(decoded[0]!.payload))).toEqual({
rows: 30,
cols: 120,
retainedHistoryLines: 842,
});
expect(decoded[1]!.payload).toEqual(data);
expect(decoded[2]!.payload.byteLength).toBe(0);
});
@@ -1,11 +1,11 @@
import type { z } from "zod";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
type TerminalReplayMetadataSchema,
encodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
@@ -21,62 +21,84 @@ 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 {
if (restore?.mode === "live") {
return { mode: "visible-snapshot" };
}
return restore;
): TerminalRestoreOptions {
return {
mode: "visible-snapshot",
scrollbackLines: Math.min(
restore?.scrollbackLines ?? MAX_VISIBLE_RESTORE_SCROLLBACK_LINES,
MAX_VISIBLE_RESTORE_SCROLLBACK_LINES,
),
};
}
export function resolveTerminalRestoreSnapshotOptions(
restore: TerminalRestoreOptions,
): TerminalStateSnapshotOptions | null | undefined {
export function resolveTerminalRestoreSnapshotOptions({
restore,
chunked,
}: {
restore: TerminalRestoreOptions;
chunked: boolean;
}): TerminalReplayOptions | null | undefined {
if (restore.mode === "live") {
return null;
}
if (restore.mode === "visible-snapshot") {
return {
scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines),
};
if (restore.mode === "full-snapshot") {
return chunked ? { scrollbackLines: restore.scrollbackLines } : undefined;
}
return undefined;
const scrollbackLines = restore.scrollbackLines ?? DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES;
return {
scrollbackLines: chunked
? scrollbackLines
: Math.min(scrollbackLines, MAX_VISIBLE_RESTORE_SCROLLBACK_LINES),
};
}
export function encodeLegacyTerminalSnapshotFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: input.slot,
payload: encodeTerminalSnapshotPayload(input.snapshot.state),
});
}
export function encodeTerminalRestoreFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore,
slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state),
payload: input.data,
});
}
function resolveVisibleRestoreScrollbackLines(value: number | undefined): number {
if (typeof value !== "number") {
return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES;
}
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
/** The daemon's only old-app adapter. Capable delivery never assembles the replay. */
export function createTerminalRestoreDelivery(input: {
slot: number;
chunked: boolean;
send(frame: Uint8Array): void;
}): TerminalReplaySink {
// COMPAT(terminalSingleRestore): added after v0.14.0; remove after 2027-04-30 when client floor supports chunked replay.
const legacyChunks: Uint8Array[] = [];
return (event) => {
if (!input.chunked) {
if (event.type === "data") legacyChunks.push(event.data);
if (event.type === "end")
input.send(
encodeTerminalRestoreFrame({
slot: input.slot,
data: Buffer.concat(legacyChunks),
}),
);
return;
}
const opcodes = {
begin: TerminalStreamOpcode.ReplayBegin,
data: TerminalStreamOpcode.ReplayData,
end: TerminalStreamOpcode.ReplayEnd,
};
let payload: string | Uint8Array | undefined;
if (event.type === "begin") {
const begin = {
rows: event.metadata.rows,
cols: event.metadata.cols,
retainedHistoryLines: event.metadata.retainedHistoryLines,
} satisfies z.infer<typeof TerminalReplayMetadataSchema>;
payload = JSON.stringify(begin);
}
if (event.type === "data") payload = event.data;
input.send(
encodeTerminalStreamFrame({ slot: input.slot, opcode: opcodes[event.type], payload }),
);
};
}
@@ -0,0 +1,42 @@
import type { Terminal as TerminalType } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import type { TerminalReplayOptions } from "./terminal-replay.js";
/** Serialize the owned model; the PTY is a transport, not an ANSI parser oracle. */
export function createTerminalSerializer(
terminal: TerminalType,
): (options?: TerminalReplayOptions) => Uint8Array {
const addon = new SerializeAddon();
terminal.loadAddon(addon);
return (options) => {
const data = addon.serialize({ scrollback: options?.scrollbackLines });
const buffer = terminal.buffer.active;
// Addon 0.14 applies a horizontal offset after vertical movement has already
// clamped a pending-wrap column, losing a column. Restore a valid position
// absolutely; an x==cols cursor must keep its pending wrap for the next byte.
const position =
buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : "";
return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8");
};
}
// 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 };
};
};
}
)._core.coreService;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
// Unset presentation belongs to each renderer's configured defaults.
if (cursorStyle === undefined) return visibility;
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
@@ -1,3 +1,6 @@
import type { TerminalReplayOptions } from "./terminal-replay.js";
import type { TerminalRestoreOptions } from "./terminal-restore.js";
import type { TerminalReplayMetadata } from "./terminal-replay.js";
import { describe, expect, test, vi } from "vitest";
import pino from "pino";
@@ -7,8 +10,7 @@ import {
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import type { ServerMessage, TerminalSession } from "./terminal.js";
import {
TerminalSessionController,
type TerminalSessionControllerOptions,
@@ -19,8 +21,33 @@ import { isSameOrDescendantPath } from "../server/path-utils.js";
import { PluginSessionSocket } from "../server/plugins/session-socket.js";
import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js";
interface ReplayFixture {
data: Uint8Array;
metadata: TerminalReplayMetadata;
}
function terminalReplay(text: string, revision: number, replayPreamble = ""): ReplayFixture {
return {
data: Buffer.from(text),
metadata: { rows: 1, cols: 80, retainedHistoryLines: 0, revision, replayPreamble },
};
}
function replayFixture(
read: () => Promise<ReplayFixture | null>,
): TerminalManager["replayTerminal"] {
return async (_id, _options, sink) => {
const replay = await read();
if (!replay) return null;
await sink({ type: "begin", metadata: replay.metadata });
await sink({ type: "data", data: replay.data });
await sink({ type: "end" });
return replay.metadata;
};
}
function createController(
options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void },
options: TerminalSessionControllerOptions & {
emitBinary(frame: Uint8Array): void | Promise<void>;
},
) {
const source = {};
const ownership = new SessionDelivery(
@@ -59,22 +86,6 @@ function deferred<T>(): {
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
@@ -85,7 +96,7 @@ function createLogger(): pino.Logger {
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
@@ -104,8 +115,7 @@ describe("terminal-session-controller restore", () => {
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
hasOutput: () => true,
getReplayPreamble: () => "\x1b[?1h\x1b[?2004h",
getTitle: () => undefined,
getActivity: () => null,
@@ -121,7 +131,7 @@ describe("terminal-session-controller restore", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
replayTerminal: replayFixture(() => snapshot.promise),
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -157,10 +167,10 @@ describe("terminal-session-controller restore", () => {
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
expect(binaryFrames).toEqual([]);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h"));
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -202,8 +212,7 @@ function listSession(input: {
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""),
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
@@ -236,7 +245,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -311,7 +320,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -362,7 +371,7 @@ describe("terminal-session-controller with terminals held by a newer Paseo", ()
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -428,99 +437,6 @@ async function waitForCoalescerFlush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 30));
}
describe("terminal-session-controller wrap-flag gating", () => {
function setup(clientSupportsWrapReflow?: () => boolean): {
controller: ReturnType<typeof createController>;
getTerminalState: ReturnType<typeof vi.fn>;
} {
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
send: vi.fn(),
subscribe: (listener) => {
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("hello"),
getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
setActivity: vi.fn(),
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const getTerminalState = vi.fn(() =>
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("hello"), revision: 1 }),
);
const terminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(() => terminal),
getTerminalState,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
} as unknown as TerminalManager;
const controller = createController({
terminalManager,
emit: vi.fn(),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}),
});
return { controller, getTerminalState };
}
async function subscribe(controller: ReturnType<typeof createController>): Promise<void> {
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: { mode: "visible-snapshot", scrollbackLines: 200 },
});
await flushMicrotasks();
}
test("requests wrap flags when the client supports reflowable snapshots", async () => {
const { controller, getTerminalState } = setup(() => true);
await subscribe(controller);
expect(getTerminalState).toHaveBeenCalledWith(
"term-1",
expect.objectContaining({ includeWrapFlags: true }),
);
});
test("omits wrap flags when the client does not advertise support", async () => {
const { controller, getTerminalState } = setup();
await subscribe(controller);
expect(getTerminalState).toHaveBeenCalledWith(
"term-1",
expect.objectContaining({ includeWrapFlags: false }),
);
});
});
describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo";
@@ -541,7 +457,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -609,7 +525,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -701,7 +617,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -764,9 +680,17 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
});
describe("terminal-session-controller backpressure snapshot fallback", () => {
async function setup(getClientBufferedAmount: () => number | null): Promise<{
async function setup(
getClientBufferedAmount: () => number | null,
chunked = false,
pace?: () => Promise<void>,
restore?: TerminalRestoreOptions,
): Promise<{
messages: SessionOutboundMessage[];
pushOutput: (data: string) => void;
frames: TerminalStreamFrame[];
replayOptions: Array<TerminalReplayOptions | undefined>;
ready: () => void;
}> {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const terminal: TerminalSession = {
@@ -777,19 +701,15 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
// Legacy stream: a snapshot arrives on subscribe (one Snapshot frame),
// after which output streams through the coalescer as Output frames.
queueMicrotask(() =>
listener({ type: "snapshot", state: terminalState("live"), revision: 1 }),
);
// Readiness starts ANSI replay; later bytes use Output frames.
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("live"),
getStateSnapshot: () => ({ state: terminalState("live"), revision: 1 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
@@ -797,14 +717,16 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager = {
const replayOptions: Array<TerminalReplayOptions | undefined> = [];
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() =>
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("live"), revision: 1 }),
),
replayTerminal: (id, options, sink) => {
replayOptions.push(options);
return replayFixture(async () => terminalReplay("live", 1))(id, options, sink);
},
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
@@ -812,16 +734,26 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
} as unknown as TerminalManager;
validateTerminalActivityToken: () => "unknown",
setTerminalActivity: async () => true,
clearTerminalAttention: async () => false,
subscribeTerminalActivity: () => () => {},
subscribeTerminalWorkspaceContributionChanged: () => () => {},
};
const frames: TerminalStreamFrame[] = [];
const messages: SessionOutboundMessage[] = [];
const controller = createController({
terminalManager,
emit: vi.fn(),
emit: (message) => {
messages.push(message);
},
supportsChunkedReplay: () => chunked,
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
frames.push(frame);
if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.();
}
},
hasBinaryChannel: () => true,
@@ -834,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore,
});
await waitForCoalescerFlush();
// Drop the initial subscribe snapshot frame so each test only sees frames
@@ -841,11 +774,83 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
frames.length = 0;
return {
replayOptions,
ready: () => terminalListener?.({ type: "snapshotReady" }),
pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }),
frames,
messages,
};
}
test.each([false, true])(
"preserves visible history defaults and explicit requests across delivery (chunked=%s)",
async (chunked) => {
const replayOpcodes = chunked
? [
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
]
: [TerminalStreamOpcode.Restore];
for (const [restore, expected] of [
[{ mode: "visible-snapshot" }, [{ scrollbackLines: 200 }]],
[{ mode: "visible-snapshot", scrollbackLines: 0 }, [{ scrollbackLines: 0 }]],
[{ mode: "visible-snapshot", scrollbackLines: 5 }, [{ scrollbackLines: 5 }]],
[{ mode: "visible-snapshot", scrollbackLines: 500 }, [{ scrollbackLines: 500 }]],
[
{ mode: "visible-snapshot", scrollbackLines: 10000 },
[{ scrollbackLines: chunked ? 10000 : 500 }],
],
[{ mode: "full-snapshot" }, [chunked ? { scrollbackLines: undefined } : undefined]],
[{ mode: "live" }, []],
] as const) {
const fixture = await setup(() => 0, chunked, undefined, restore);
expect(fixture.replayOptions).toEqual(expected);
fixture.ready();
await waitForCoalescerFlush();
expect(fixture.replayOptions).toEqual([...expected, ...expected]);
expect(fixture.frames.map((frame) => frame.opcode)).toEqual(
restore.mode === "live" ? [] : replayOpcodes,
);
fixture.pushOutput("after-restore");
await waitForCoalescerFlush();
expect(fixture.frames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Output);
expect(new TextDecoder().decode(fixture.frames.at(-1)!.payload)).toBe("after-restore");
}
},
);
test.each([false, true])(
"bounds recovery without changing attach demand (chunked=%s)",
async (chunked) => {
for (const [restore, history] of [
[{ mode: "visible-snapshot" }, 500],
[{ mode: "visible-snapshot", scrollbackLines: 10000 }, 500],
[{ mode: "full-snapshot" }, 500],
[{ mode: "visible-snapshot", scrollbackLines: 5 }, 5],
[{ mode: "visible-snapshot", scrollbackLines: 0 }, 0],
[{ mode: "full-snapshot", scrollbackLines: 0 }, 0],
[{ mode: "live", scrollbackLines: 0 }, 0],
[{ mode: "live" }, 500],
[undefined, 500],
] as const) {
const fixture = await setup(() => 8 * 1024 * 1024, chunked, undefined, restore);
const initial = fixture.replayOptions.slice();
fixture.pushOutput("pressure".repeat(40000));
await waitForCoalescerFlush();
expect(fixture.replayOptions.at(-1)).toEqual({ scrollbackLines: history });
expect(fixture.replayOptions).toHaveLength(initial.length + 1);
fixture.ready(); // Worker adoption must use the original demand, even on the same stream.
await waitForCoalescerFlush();
expect(fixture.replayOptions.slice(initial.length + 1)).toEqual(initial);
fixture.frames.length = 0;
fixture.pushOutput("after-catch-up");
await waitForCoalescerFlush();
expect(fixture.frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]);
}
},
);
test("streams all output without a snapshot when the client keeps up", async () => {
const { pushOutput, frames } = await setup(() => 0);
@@ -853,7 +858,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput(chunk);
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
const outputFrames = frames.filter((frame) => frame.opcode === TerminalStreamOpcode.Output);
expect(outputFrames.length).toBeGreaterThan(0);
const receivedBytes = outputFrames.reduce(
@@ -869,7 +874,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("y".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
// After the snapshot the byte counter is reset, so a small follow-up chunk
// streams as Output rather than tripping the fallback again.
@@ -877,7 +882,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("z".repeat(1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true);
});
@@ -893,7 +898,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("p".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
});
test("falls back to a snapshot at the byte threshold when no backpressure signal exists", async () => {
@@ -905,11 +910,41 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("r".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
});
test("queues capable catch-up without waiting for the direct socket to drain", async () => {
let buffered = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const { pushOutput, frames, messages } = await setup(
() => buffered,
true,
() => (buffered ? gate : Promise.resolve()),
);
buffered = 8 * 1024 * 1024;
pushOutput("y".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(messages.filter((message) => message.type === "terminal_stream_exit")).toEqual([]);
expect(frames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
]);
buffered = 0;
release();
await waitForCoalescerFlush();
expect(frames.map((frame) => frame.opcode)).toContain(TerminalStreamOpcode.ReplayEnd);
frames.length = 0;
buffered = 8 * 1024 * 1024; // Small output must stay live after the replay resets its byte counter.
pushOutput("live-after-recovery");
await waitForCoalescerFlush();
expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]);
});
});
function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>, backedUp = false) {
function exitFixture(readReplay: () => Promise<ReplayFixture | null>, backedUp = false) {
const outputs = new Set<(message: ServerMessage) => void>();
const exits = new Set<Parameters<TerminalSession["onExit"]>[0]>();
const terminal: TerminalSession = {
@@ -934,8 +969,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
onTitleChange: () => () => {},
onActivityChange: () => () => {},
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("snapshot"),
getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
@@ -948,7 +982,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
};
const manager: TerminalManager = {
getTerminal: () => terminal,
getTerminalState: readSnapshot,
replayTerminal: replayFixture(readReplay),
getTerminals: async () => [terminal],
createTerminal: async () => terminal,
registerCwdEnv() {},
@@ -1026,7 +1060,7 @@ describe("terminal output before natural exit", () => {
"flushes each observer's final bytes before exit (modern=%s)",
async (modern) => {
vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 }));
const f = exitFixture(async () => terminalReplay("snapshot", 0));
try {
const source = {};
const owners = [
@@ -1048,7 +1082,7 @@ describe("terminal output before natural exit", () => {
row.message.payload.subscriptionId === owner.id)),
);
expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot,
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
TerminalStreamOpcode.Output,
"terminal_stream_exit",
@@ -1068,7 +1102,7 @@ describe("terminal output before natural exit", () => {
test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(() => {
reads++;
@@ -1081,10 +1115,10 @@ describe("terminal output before natural exit", () => {
f.exit();
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
snapshot.resolve({ state: terminalState("snapshot"), revision: 0 });
snapshot.resolve(terminalReplay("snapshot", 0));
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot,
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
"terminal_stream_exit",
]);
@@ -1104,11 +1138,10 @@ describe("terminal output before natural exit", () => {
"finishes an in-flight backpressure snapshot (available=%s)",
async (available) => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(
async () =>
++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise,
async () => (++reads === 1 ? terminalReplay("initial", 0) : snapshot.promise),
true,
);
try {
@@ -1122,10 +1155,7 @@ describe("terminal output before natural exit", () => {
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false);
if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit"));
else
snapshot.resolve(
available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null,
);
else snapshot.resolve(available ? terminalReplay("pressure-snapshot", 1) : null);
await vi.advanceTimersByTimeAsync(0);
const output = f.frames
.filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output)
@@ -1145,7 +1175,7 @@ describe("terminal output before natural exit", () => {
test("release during exit's pending snapshot suppresses late delivery", async () => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const f = exitFixture(() => snapshot.promise);
const source = {};
try {
@@ -1158,7 +1188,7 @@ describe("terminal output before natural exit", () => {
subscriptionId: owner.id!,
};
const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!));
snapshot.resolve({ state: terminalState("snapshot"), revision: 0 });
snapshot.resolve(terminalReplay("snapshot", 0));
await release;
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
@@ -1173,7 +1203,7 @@ describe("terminal output before natural exit", () => {
test("explicit release discards only that observer's trailing output", async () => {
vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 }));
const f = exitFixture(async () => terminalReplay("snapshot", 0));
const source = {};
try {
const a = await f.subscribe(source, true, "a");
@@ -1221,8 +1251,8 @@ describe("terminal snapshot failure", () => {
let reads = 0;
const f = exitFixture(async () => {
if (++reads === (phase === "backpressure" ? 2 : 1))
throw new Error("Terminal worker request timed out: getTerminalState");
return { state: terminalState("healthy"), revision: 0 };
throw new Error("Terminal worker request timed out: replayTerminal");
return terminalReplay("healthy", 0);
}, phase === "backpressure");
try {
const source = {};
@@ -1250,7 +1280,7 @@ describe("terminal snapshot failure", () => {
payload: {
terminalId: "exit-terminal",
subscriptionId: failed.id,
error: "Terminal worker request timed out: getTerminalState",
error: "Terminal worker request timed out: replayTerminal",
},
},
]);
@@ -1281,7 +1311,7 @@ describe("terminal snapshot failure", () => {
);
test("release during pending rejection suppresses the failure and completes cleanup", async () => {
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const f = exitFixture(() => snapshot.promise);
const source = {};
try {
@@ -1293,7 +1323,7 @@ describe("terminal snapshot failure", () => {
subscriptionId: owner.id!,
};
const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!));
snapshot.reject(new Error("Terminal worker request timed out: getTerminalState"));
snapshot.reject(new Error("Terminal worker request timed out: replayTerminal"));
await release;
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
expect(f.delivery.registrationCount).toBe(0);
@@ -1313,7 +1343,7 @@ test.each([false, true])(
let reads = 0;
const f = exitFixture(async () => {
if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable");
return { state: terminalState("recovered"), revision: 0 };
return terminalReplay("recovered", 0);
}, backpressure);
try {
await f.subscribe({}, false, "legacy-recovery");
@@ -1327,7 +1357,7 @@ test.each([false, true])(
for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 });
await vi.advanceTimersByTimeAsync(5);
expect(reads).toBe(backpressure ? 3 : 2);
expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe(
expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Restore)).toBe(
true,
);
f.exit();
@@ -1347,3 +1377,51 @@ test.each([false, true])(
}
},
);
test("bounds a capturing viewer tail without ending a healthy viewer", async () => {
const pending = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(() =>
++reads === 2 ? pending.promise : Promise.resolve(terminalReplay("retained", 0)),
);
const desktop = {},
phone = {};
try {
await f.subscribe(desktop, true, "desktop");
await waitForCoalescerFlush();
await f.subscribe(phone, true, "phone");
f.output("x".repeat(4 * 1024 * 1024 + 1), 1);
await waitForCoalescerFlush();
expect(
f.frames.filter(
(row) => row.source === phone && row.message?.type === "terminal_stream_exit",
),
).toHaveLength(1);
expect(
f.frames.filter(
(row) => row.source === desktop && row.message?.type === "terminal_stream_exit",
),
).toHaveLength(0);
expect(
f.frames.some(
(row) => row.source === desktop && row.binary?.opcode === TerminalStreamOpcode.Output,
),
).toBe(true);
pending.resolve(terminalReplay("phone", 1));
await waitForCoalescerFlush();
f.output("AFTER-OVERFLOW", 2);
await waitForCoalescerFlush();
expect(
f.frames.some(
(row) =>
row.source === desktop &&
row.binary?.opcode === TerminalStreamOpcode.Output &&
Buffer.from(row.binary.payload).toString().includes("AFTER-OVERFLOW"),
),
).toBe(true);
expect(f.delivery.registrationCount).toBe(1);
} finally {
pending.resolve(null);
await f.delivery.close();
}
});
@@ -28,13 +28,12 @@ import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import {
MAX_CLIENT_BUFFERED_BYTES,
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame,
encodeTerminalRestoreFrame,
createTerminalRestoreDelivery,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions,
} from "./terminal-restore.js";
import type { TerminalReplayMetadata } from "./terminal-replay.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { applyTerminalSize } from "./terminal-size-ownership.js";
@@ -68,31 +67,28 @@ interface ActiveTerminalStream {
needsSnapshot: boolean;
snapshotInFlight: boolean;
retrySnapshotErrors: boolean;
readyRevision?: number;
// Readiness is independent of revision: reconnect adoption has no revision.
snapshotReady: boolean;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[];
bufferedOutputBytes: number;
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer;
}
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
type SnapshotSendResult =
| { shouldContinue: false }
| { shouldContinue: true; replay: TerminalReplayMetadata | null };
export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void;
hasBinaryChannel: () => boolean;
supportsChunkedReplay?: (source: object) => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger;
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// Whether the connected client can reflow restored snapshots. When true the
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
// so old (strict-schema) clients still parse the snapshot.
clientSupportsWrapReflow?: (source: object) => boolean;
// Current max bytes queued on the client's transport(s) but not yet sent.
// Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and
// keeps streaming; a backed-up client trips the snapshot path. Defaults to a
// constant 0 (no backpressure signal) so callers without a transport always
@@ -145,7 +141,6 @@ export class TerminalSessionController {
private readonly sessionLogger: pino.Logger;
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
private readonly clientSupportsWrapReflow: (source: object) => boolean;
private readonly getClientBufferedAmount: (source: object) => number | null;
private readonly subscribedDirectories = new Map<string, TerminalDirectorySubscription>();
@@ -153,7 +148,7 @@ export class TerminalSessionController {
private readonly activeStreams = new Map<number, ActiveTerminalStream>();
private nextSlot = 0;
constructor(options: TerminalSessionControllerOptions) {
constructor(private readonly options: TerminalSessionControllerOptions) {
this.terminalManager = options.terminalManager;
this.emit = options.emit;
this.hasBinaryChannel = options.hasBinaryChannel;
@@ -163,7 +158,6 @@ export class TerminalSessionController {
this.listTerminalWorkspaceRoots =
options.listTerminalWorkspaceRoots ??
(async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd));
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
}
@@ -904,9 +898,10 @@ export class TerminalSessionController {
snapshotInFlight: false,
retrySnapshotErrors: options.retrySnapshotErrors,
exiting: false,
readyRevision: undefined,
snapshotReady: false,
restore: options?.restore,
bufferedOutputs: [],
bufferedOutputBytes: 0,
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout },
@@ -934,7 +929,6 @@ export class TerminalSessionController {
// The snapshot replaces this batch only after it succeeds. If the
// terminal disappears during the read, completion still owns these bytes.
activeStream.snapshotOutput = payload;
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
@@ -952,35 +946,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.readyRevision = message.revision;
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.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 }),
);
@@ -1017,22 +1021,27 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
if (activeStream.restore && activeStream.readyRevision === undefined) {
if (activeStream.restore && !activeStream.snapshotReady) {
return;
}
activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true;
try {
const restore = activeStream.restore;
// A pressure catch-up replaces the skipped batch with a recent window.
// Keep the subscription demand intact for later adoption/full restores.
const restore =
activeStream.snapshotOutput !== undefined
? resolveRestoreAfterOutputOverflow(activeStream.restore)
: activeStream.restore;
const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
: await this.emitRestoreSnapshot(activeStream, terminalManager, { mode: "full-snapshot" });
if (!snapshotResult.shouldContinue) {
return;
}
activeStream.snapshotOutput = undefined;
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replay);
activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) {
@@ -1070,47 +1079,31 @@ export class TerminalSessionController {
}
}
private async emitLegacySnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
): Promise<SnapshotSendResult> {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source),
});
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
activeStream.owner.emitBinary(
encodeLegacyTerminalSnapshotFrame({
slot: activeStream.slot,
snapshot,
}),
);
// The snapshot frame went out-of-band; keep the replay that follows on the
// coalescer's trailing path so it doesn't flush back-to-back with it.
activeStream.outputCoalescer.markFlushed();
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true;
const snapshotOptions = resolveTerminalRestoreSnapshotOptions({ restore, chunked });
if (snapshotOptions === null) {
return { shouldContinue: true };
return { shouldContinue: true, replay: null };
}
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
...snapshotOptions,
includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source),
const deliver = createTerminalRestoreDelivery({
slot: activeStream.slot,
chunked,
// Network delivery belongs to the bounded application transport, not the worker request.
send: (frame) => activeStream.owner.emitBinary(frame),
});
const snapshot = await terminalManager.replayTerminal(
activeStream.terminalId,
snapshotOptions,
(event) => {
if (this.activeStreams.get(activeStream.slot) !== activeStream) return;
deliver(event);
},
);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
@@ -1119,38 +1112,31 @@ export class TerminalSessionController {
return { shouldContinue: false };
}
activeStream.owner.emitBinary(
encodeTerminalRestoreFrame({
slot: activeStream.slot,
snapshot,
}),
);
// The restore frame went out-of-band; keep the replay that follows on the
// coalescer's trailing path so it doesn't flush back-to-back with it.
activeStream.outputCoalescer.markFlushed();
return { shouldContinue: true, replayRevision: snapshot.revision };
return { shouldContinue: true, replay: snapshot };
}
private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
replayRevision: number | undefined,
replay: TerminalReplayMetadata | null,
): void {
const replayPreamble = terminal.getReplayPreamble();
// A captured viewer must start from its cut, then apply the buffered mode changes.
// Live-only subscriptions have no capture and start from the current shared modes.
const replayPreamble = replay === null ? terminal.getReplayPreamble() : replay.replayPreamble;
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
activeStream.bufferedOutputBytes = 0;
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
if (replay !== null && output.revision !== undefined && output.revision <= replay.revision) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
@@ -72,3 +72,46 @@ describe("terminal worker authentication", () => {
await waitUntil(() => !existsSync(recordPath), 10_000);
});
});
it("bounds a stalled framed writer to one chunk and resumes in order", async () => {
const { createServer, createConnection } = await import("node:net");
const { attachFramedSocket } = await import("./terminal-worker-frames.js");
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test address");
const accepted = new Promise<import("node:net").Socket>((resolve) =>
server.once("connection", resolve),
);
const reader = createConnection(address.port, "127.0.0.1");
reader.pause();
const writer = await accepted;
const framed = attachFramedSocket(writer, () => {});
let completed = 0;
let peakQueuedBytes = 0;
const received: number[] = [];
attachFramedSocket(reader, (value) => received.push((value as { index: number }).index));
const sending = (async () => {
for (let index = 0; index < 512; index++) {
const pending = framed.sendAndWait({ index, data: Buffer.alloc(64 * 1024) });
peakQueuedBytes = Math.max(peakQueuedBytes, writer.writableLength);
await pending;
completed++;
}
})();
try {
await waitUntil(() => writer.writableLength > 0, 10000);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(completed).toBeLessThan(512);
expect(peakQueuedBytes).toBeLessThan(65 * 1024);
reader.resume();
await sending;
await waitUntil(() => received.length === 512, 10000);
expect(received).toEqual(Array.from({ length: 512 }, (_, index) => index));
expect(writer.destroyed).toBe(false);
} finally {
reader.destroy();
writer.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -50,6 +50,7 @@ export class FrameDecoder {
export interface FramedSocket {
readonly socket: Socket;
send(message: unknown, callback?: (error: Error | null) => void): boolean;
sendAndWait(message: unknown): Promise<void>;
close(): void;
}
@@ -91,6 +92,17 @@ export function attachFramedSocket(
}
return socket.write(frame, (error) => callback?.(error ?? null));
},
sendAndWait(message) {
return new Promise<void>((resolve, reject) => {
this.send(message, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
close() {
socket.end(() => socket.destroy());
},
@@ -5,7 +5,7 @@ import type {
TerminalsChangedListener,
TerminalWorkspaceContributionChangedListener,
} from "./terminal-manager.js";
import type { TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import type { TerminalSession } from "./terminal.js";
import type { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js";
import type { DaemonTerminalManager } from "./worker-terminal-manager.js";
@@ -217,8 +217,8 @@ export function createTerminalWorkerPool(
return undefined;
},
async getTerminalState(id, stateOptions): Promise<TerminalStateSnapshot | null> {
return routed(id).getTerminalState(id, stateOptions);
async replayTerminal(id, replayOptions, sink) {
return routed(id).replayTerminal(id, replayOptions, sink);
},
setTerminalTitle(id, title) {
@@ -1,3 +1,4 @@
import { createTerminalReplayController } from "./terminal-replay.js";
import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs";
@@ -6,13 +7,8 @@ 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,
TerminalSession,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js";
import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js";
import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js";
import {
@@ -30,7 +26,6 @@ import {
type TerminalWorkerHelloAck,
type TerminalWorkerListResult,
type TerminalWorkerRequest,
type TerminalWorkerStateResult,
type TerminalWorkerToParentMessage,
type WorkerTerminalInfo,
} from "./terminal-worker-protocol.js";
@@ -177,7 +172,7 @@ function runWorker(argv: string[]): void {
const protocolVersion = reportedProtocolVersion();
const manager = createTerminalManager();
const inspector = createTerminalWorkerProcessInspector();
const sessionsById = new Map<string, TerminalSession>();
const sessionsById = new Map<string, LocalTerminalSession>();
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
const outputCoalescerByTerminalId = new Map<string, TerminalOutputCoalescer>();
// A terminal is published (terminalCreated) only once its program is recorded. An exit that
@@ -211,10 +206,12 @@ function runWorker(argv: string[]): void {
reportInFlightTerminalCreateFailure(error);
});
const replayController = createTerminalReplayController({
getSession: (id) => sessionsById.get(id),
flushOutput: (id) => outputCoalescerByTerminalId.get(id)?.flush(),
});
function sendToController(message: TerminalWorkerToParentMessage): void {
// With no controller the event is dropped; the headless terminal keeps the screen, and the
// next daemon reads it through listTerminals.
controller?.send(message);
replayController.send(message);
}
function persistRecord(): void {
@@ -259,17 +256,11 @@ function runWorker(argv: string[]): void {
}
}
function buildTerminalStateResult(
session: TerminalSession | undefined,
options?: TerminalStateSnapshotOptions,
): TerminalWorkerStateResult {
if (!session) {
return null;
}
return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() };
function terminalMetadata(session: LocalTerminalSession) {
return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() };
}
function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo {
return {
id: session.id,
name: session.name,
@@ -316,7 +307,7 @@ function runWorker(argv: string[]): void {
}
}
function watchTerminal(session: TerminalSession): void {
function watchTerminal(session: LocalTerminalSession): void {
clearTerminalSubscriptions(session.id);
// Coalesce pty output chunks into a single frame per ~5ms window so a burst of small
@@ -338,28 +329,22 @@ function runWorker(argv: string[]): void {
});
outputCoalescerByTerminalId.set(session.id, outputCoalescer);
const unsubscribeMessage = session.subscribe(
(message) => {
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);
@@ -413,7 +398,7 @@ function runWorker(argv: string[]): void {
// A terminal enters the record only with its program's birth time, so cleanup can always
// tell the program from a later process that reuses its pid. A program that already exited
// needs no entry; one whose birth time cannot be read is killed and the create fails.
async function recordTerminalProgram(session: TerminalSession): Promise<void> {
async function recordTerminalProgram(session: LocalTerminalSession): Promise<void> {
if (session.getExitInfo()) return;
// The manager drops a session as soon as its program exits, so only a live one has a pid.
const pid = manager.getTerminalProcessId(session.id);
@@ -476,11 +461,19 @@ function runWorker(argv: string[]): void {
await session.killAndWait().catch(() => undefined);
throw error;
}
const initialSnapshot = session.getStateSnapshot();
const state = terminalMetadata(session);
sendToController({
type: "terminalCreated",
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
state,
});
// Startup output/readiness may have arrived while recording the program,
// before the daemon knew this terminal. Publish its current modes after creation.
sendToController({
type: "terminalMessage",
terminalId: session.id,
message: { type: "snapshotReady" },
replayPreamble: session.getReplayPreamble(),
});
sendToController({
type: "response",
@@ -488,7 +481,7 @@ function runWorker(argv: string[]): void {
ok: true,
result: {
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
state,
},
});
const heldExit = exitsHeldDuringCreation.get(session.id);
@@ -513,7 +506,7 @@ function runWorker(argv: string[]): void {
outputCoalescerByTerminalId.get(session.id)?.flush();
return {
terminal: toTerminalInfo(session),
state: session.getStateSnapshot().state,
state: terminalMetadata(session),
activityToken: manager.getTerminalActivityToken(session.id),
replayPreamble: session.getReplayPreamble(),
};
@@ -559,28 +552,15 @@ function runWorker(argv: string[]): void {
return;
}
case "getTerminalState": {
// Flush buffered output before snapshotting: the headless state already includes it,
// so if the coalescer emitted it afterward (in a batch carrying a revision past the
// snapshot's) the controller's revision dedup wouldn't drop it and the client would
// see the bytes twice. Flushing first sends them with a revision <= the snapshot's.
outputCoalescerByTerminalId.get(message.terminalId)?.flush();
sendToController({
type: "response",
requestId: message.requestId,
ok: true,
result: buildTerminalStateResult(
manager.getTerminal(message.terminalId),
message.options,
),
});
case "replayTerminal": {
await replayController.replay(message);
return;
}
case "captureTerminal": {
const session = manager.getTerminal(message.terminalId);
const result = session
? captureTerminalLines(session, {
? session.captureLines({
start: message.start,
end: message.end,
stripAnsi: message.stripAnsi,
@@ -659,10 +639,9 @@ function runWorker(argv: string[]): void {
}
authenticated = true;
// One controller at a time: a new authenticated connection replaces the old one.
const previous = controller;
controller = framed;
replayController.setConnection(framed);
everControlled = true;
previous?.socket.destroy();
const ack: TerminalWorkerHelloAck = {
type: "helloAck",
pid: process.pid,
@@ -688,6 +667,7 @@ function runWorker(argv: string[]): void {
clearTimeout(helloTimer);
if (controller === framed) {
controller = null;
replayController.setConnection(null);
exitIfIdle();
}
});
@@ -2,18 +2,24 @@ import { describe, expect, it } from "vitest";
import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
describe("classifyTerminalWorkerProtocol", () => {
it("rejects the unreleased cell protocol without a draining adapter", () => {
expect(classifyTerminalWorkerProtocol(1, 2)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(2, 3)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(3, 4)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(4, 5)).toBe("obsolete");
});
it.each([
[5, "current"],
[4, "previous"],
[3, "obsolete"],
[6, "current"],
[5, "previous"],
[4, "obsolete"],
[-1, "obsolete"],
[6, "newer"],
[7, "newer"],
// A version this daemon cannot read comes from a Paseo it does not know.
["6", "newer"],
[4.5, "newer"],
[Number.NaN, "newer"],
[undefined, "newer"],
])("a worker reporting %s to a daemon at 5 is %s", (reported, fit) => {
expect(classifyTerminalWorkerProtocol(reported, 5)).toBe(fit);
])("a worker reporting %s to a daemon at 6 is %s", (reported, fit) => {
expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit);
});
});
@@ -1,11 +1,11 @@
import type {
TerminalExitInfo,
ServerMessage,
ClientMessage,
TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
export interface WorkerTerminalState {
rows: number;
cols: number;
hasOutput: boolean;
title?: string;
}
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -43,7 +43,7 @@ export interface WorkerCreateTerminalOptions {
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
// worker running untouched. See docs/terminal-worker.md.
export const TERMINAL_WORKER_PROTOCOL_VERSION = 1;
export const TERMINAL_WORKER_PROTOCOL_VERSION = 5;
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
// workers from one build.
@@ -59,7 +59,8 @@ export function classifyTerminalWorkerProtocol(
// A version this daemon cannot read comes from a Paseo it does not know: treat it as newer.
if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer";
if (reported === current) return "current";
if (reported === current - 1) return "previous";
// Protocols 1 through 4 were unreleased; they have no draining adapter.
if (reported === current - 1 && reported >= 5) return "previous";
return reported < current ? "obsolete" : "newer";
}
@@ -77,7 +78,7 @@ export interface TerminalWorkerHelloAck {
export interface WorkerListedTerminal {
terminal: WorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
activityToken: string | null;
replayPreamble: string;
}
@@ -122,10 +123,10 @@ export type TerminalWorkerRequest =
options?: WorkerKillAndWaitOptions;
}
| {
type: "getTerminalState";
type: "replayTerminal";
requestId: string;
terminalId: string;
options?: TerminalStateSnapshotOptions;
options?: TerminalReplayOptions;
}
| {
type: "captureTerminal";
@@ -166,10 +167,11 @@ export type TerminalWorkerResponse =
};
export type TerminalWorkerEvent =
| { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent }
| {
type: "terminalCreated";
terminal: WorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
}
| {
type: "terminalMessage";
@@ -206,6 +208,3 @@ export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWor
export type TerminalWorkerListResult = WorkerListedTerminal[];
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;
// The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so
// the parent can cache the input-mode preamble instead of re-deriving it.
export type TerminalWorkerStateResult = TerminalStateSnapshot | null;
@@ -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();
});
+341 -49
View File
@@ -1,3 +1,7 @@
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { createTerminalSerializer } from "./terminal-serializer.js";
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 +14,7 @@ import {
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
type TerminalSession,
type LocalTerminalSession as TerminalSession,
} from "./terminal.js";
import {
chmodSync,
@@ -26,7 +30,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 +37,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 +47,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 +68,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 +81,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 +89,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 +476,7 @@ describe("createTerminal", () => {
}),
);
const state = session.getState();
const state = await readTerminalState(session);
expect(state.rows).toBe(24);
expect(state.cols).toBe(80);
});
@@ -493,12 +496,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 +516,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 +552,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 +610,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 +670,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 +713,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 +735,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 +799,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 +821,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 +882,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 +907,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 +927,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 +955,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 +989,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 +1144,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 +1178,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 +1207,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 +1235,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 +1258,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 +1288,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 +1317,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 +1341,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 +1359,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 +1538,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 +1560,309 @@ 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) => {
// Exercise the serializer's input model directly. ConPTY renders program
// output: it consumes input modes and need not publish a hidden cursor move.
const terminal = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const serialize = createTerminalSerializer(terminal);
const inputModes = new TerminalInputModeTracker();
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";
const output = content + "\x1b[2;7H";
await new Promise<void>((resolve) => terminal.write(output, resolve));
inputModes.feed(output);
const data = serialize();
const ansi = new TextDecoder().decode(data);
expect(ansi).toContain("\x1b[?25l\x1b[5 q");
expect(ansi).not.toContain("NaN");
expect(terminal.buffer.normal.baseY).toBeGreaterThan(0);
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => receiver.write(data, resolve));
await new Promise<void>((resolve) => receiver.write(inputModes.getPreamble(), 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 coloredIndex = lines.findIndex((line) => line.includes("界"));
const colored = receiver.buffer.active.getLine(coloredIndex)!.getCell(0)!;
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 + inputModes.getPreamble());
expect(modes.getState()).toMatchObject({
kittyKeyboardFlags: 1,
win32InputMode: true,
applicationCursorKeys: true,
bracketedPaste: true,
});
} finally {
receiver.dispose();
terminal.dispose();
}
},
);
it.each([
{ name: "unset", presentation: "", style: undefined, visible: true },
{
name: "reset to client default",
presentation: "\x1b[5 q\x1b[0 q",
style: undefined,
visible: true,
},
{ name: "unset hidden", presentation: "\x1b[?25l", style: undefined, visible: false },
...[1, 2, 3, 4, 5, 6].map((style) => ({
name: `explicit style ${style}`,
presentation: `\x1b[${style} q\x1b[?25l`,
style,
visible: false,
})),
])("replays cursor presentation without replacing client defaults ($name)", async (input) => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
}),
);
await waitForLines(session, ["CURSOR-READY"]);
const { data } = session.serializeReplay();
const ansi = new TextDecoder().decode(data);
const receiver = new xterm.Terminal({
cursorStyle: "bar",
cursorBlink: true,
allowProposedApi: true,
});
const styles: number[] = [];
receiver.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => {
styles.push(Number(params[0]));
return false;
});
try {
await new Promise<void>((resolve) => receiver.write(data, resolve));
expect(styles).toEqual(input.style === undefined ? [] : [input.style]);
expect(ansi).toContain(`\x1b[?25${input.visible ? "h" : "l"}`);
} 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();
}
});
it.each(
[false, true].flatMap((alternate) => [
{
alternate,
pendingWrap: false,
position: "\x1b[3;16H",
cursor: { row: 2, col: 15 },
after: { row: 2, col: 16 },
},
{
alternate,
pendingWrap: true,
position: "",
cursor: { row: 0, col: 16 },
after: { row: 1, col: 1 },
},
]),
)(
"replays a full-width row without losing cursor state ($alternate, pendingWrap=$pendingWrap)",
async (input) => {
const source = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const serialize = createTerminalSerializer(source);
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) =>
source.write(
(input.alternate ? "\x1b[?1049h" : "") + "x".repeat(16) + input.position,
resolve,
),
);
expect({ row: source.buffer.active.cursorY, col: source.buffer.active.cursorX }).toEqual(
input.cursor,
);
await new Promise<void>((resolve) => receiver.write(serialize(), resolve));
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual(
input.cursor,
);
await new Promise<void>((resolve) => receiver.write("Z", resolve));
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual(
input.after,
);
} finally {
receiver.dispose();
source.dispose();
}
},
);
it.each([false, true])(
"replays the observed PTY projection including startup modes (alternate=%s)",
async (alternate) => {
const gate = mkdtempSync(join(tmpdir(), "terminal-projection-"));
temporaryDirs.push(gate);
const release = join(gate, "output");
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: "projection",
rows: 4,
cols: 16,
command: process.execPath,
args: [
"-e",
`const fs=require("node:fs");let sent=false;
function emit(){if(!sent&&fs.existsSync(${JSON.stringify(release)})){sent=true;watcher.close();process.stdout.write(${JSON.stringify(content)});}}
const watcher=fs.watch(${JSON.stringify(gate)},emit);emit();setInterval(()=>{},1000);`,
],
}),
);
const bootstrap = session.serializeReplay();
const output: string[] = [];
const unsubscribe = session.subscribe((message) => {
if (message.type === "output") output.push(message.data);
});
const reference = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => reference.write(bootstrap.data, resolve));
await new Promise<void>((resolve) =>
reference.write(bootstrap.metadata.replayPreamble, resolve),
);
writeFileSync(release, "release");
const deadline = Date.now() + 5000;
while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) {
if (Date.now() > deadline) throw new Error("PTY did not render fixture output");
await delay(20);
}
// Bootstrap and these committed output events cover precisely this source cut.
// Program bytes are not the oracle: ConPTY consumes modes and renders cells.
const replay = session.serializeReplay();
const committed = output.join("");
await new Promise<void>((resolve) => reference.write(committed, resolve));
await new Promise<void>((resolve) => receiver.write(replay.data, resolve));
await new Promise<void>((resolve) => receiver.write(replay.metadata.replayPreamble, resolve));
const text = (terminal: typeof receiver) =>
Array.from({ length: terminal.buffer.active.length }, (_, i) =>
terminal.buffer.active.getLine(i)!.translateToString(true).trimEnd(),
);
expect(text(receiver)).toEqual(text(reference));
expect(text(receiver).join("\n")).toContain("界é🙂");
expect(text(receiver).join("\n")).toContain("VIEW-READY");
expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal");
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({
row: reference.buffer.active.cursorY,
col: reference.buffer.active.cursorX,
});
expect(receiver.modes).toEqual(reference.modes);
const expectedModes = new TerminalInputModeTracker();
expectedModes.feed(bootstrap.metadata.replayPreamble + committed);
const actualModes = new TerminalInputModeTracker();
actualModes.feed(new TextDecoder().decode(replay.data) + replay.metadata.replayPreamble);
expect(actualModes.getState()).toEqual(expectedModes.getState());
} finally {
unsubscribe();
receiver.dispose();
reference.dispose();
}
},
);
+41 -243
View File
@@ -25,7 +25,13 @@ import {
findExecutable,
findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { createTerminalSerializer } from "./terminal-serializer.js";
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,14 +93,13 @@ 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;
onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
hasOutput(): boolean;
getReplayPreamble(): string;
getTitle(): string | undefined;
getActivity(): TerminalActivity | null;
@@ -130,6 +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;
serializeReplay(options?: TerminalReplayOptions): {
metadata: TerminalReplayMetadata;
data: Uint8Array;
};
captureLines(options?: CaptureTerminalLinesOptions): CaptureTerminalLinesResult;
}
function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null {
@@ -545,179 +534,6 @@ 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));
}
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 } : {}),
};
}
function normalizeProcessToken(token: string): string {
if (token.length === 0) {
return token;
@@ -984,10 +800,12 @@ export async function createTerminal(
const terminal = new Terminal({
rows,
cols,
scrollback: 1000,
scrollback: 10000,
allowProposedApi: true,
});
const serialize = createTerminalSerializer(terminal);
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY
@@ -1224,6 +1042,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 });
@@ -1234,10 +1056,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
@@ -1295,32 +1113,15 @@ 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(),
};
return { metadata, data: serialize(replayOptions) };
}
function getSize(): { rows: number; cols: number } {
@@ -1386,14 +1187,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;
@@ -1410,17 +1207,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);
}
@@ -1604,8 +1395,15 @@ export async function createTerminal(
onTitleChange,
onActivityChange,
getSize,
getState,
getStateSnapshot,
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();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import type { TerminalReplaySink, TerminalReplayMetadata } from "./terminal-replay.js";
import { randomBytes, randomUUID } from "node:crypto";
import { access } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
@@ -5,7 +6,7 @@ import type { Logger } from "pino";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
import { writtenDuringThisBoot } from "../server/pid-lock.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { WorkerTerminalState } from "./terminal-worker-protocol.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
import type {
@@ -15,7 +16,6 @@ import type {
TerminalCommandFinishedInfo,
TerminalExitInfo,
TerminalSession,
TerminalStateSnapshot,
} from "./terminal.js";
import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -59,7 +59,6 @@ import type {
TerminalWorkerResponse,
TerminalWorkerToParentMessage,
WorkerCreateTerminalOptions,
TerminalWorkerStateResult,
WorkerTerminalInfo,
} from "./terminal-worker-protocol.js";
@@ -93,7 +92,7 @@ interface PendingRequest {
interface WorkerTerminalRecord {
info: RequiredWorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
activity: TerminalActivity | null;
// Cached input-mode preamble from the worker (the authoritative tracker lives
// in the worker process). Updated with streamed output and snapshot responses.
@@ -172,6 +171,7 @@ export function createWorkerTerminalManager(
const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
const resolveCreateEnvironment =
managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment;
const replaySinks = new Map<string, TerminalReplaySink>();
const pendingRequests = new Map<string, PendingRequest>();
const inFlight = new Set<Promise<void>>();
const recordsById = new Map<string, WorkerTerminalRecord>();
@@ -244,7 +244,7 @@ export function createWorkerTerminalManager(
function registerRecord(input: {
info: RequiredWorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
}): TerminalSession {
const existing = recordsById.get(input.info.id);
if (existing) {
@@ -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);
};
@@ -362,19 +355,8 @@ export function createWorkerTerminalManager(
cols: record.state.cols,
};
},
getState(): TerminalState {
return record.state;
},
getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot {
const scrollbackLines = options?.scrollbackLines;
const scrollback =
typeof scrollbackLines === "number"
? record.state.scrollback.slice(-scrollbackLines)
: record.state.scrollback;
return {
state: { ...record.state, scrollback },
revision: 0,
};
hasOutput(): boolean {
return record.state.hasOutput;
},
getReplayPreamble(): string {
return record.replayPreamble;
@@ -442,9 +424,7 @@ export function createWorkerTerminalManager(
if (!record) {
return;
}
if (message.message.type === "snapshot") {
record.state = message.message.state;
}
if (message.message.type === "output") record.state.hasOutput = true;
record.replayPreamble = message.replayPreamble;
for (const listener of Array.from(record.messageListeners)) {
listener(message.message);
@@ -610,7 +590,23 @@ export function createWorkerTerminalManager(
}
function attachWorker(): void {
worker.on("message", (message: TerminalWorkerToParentMessage) => {
const connection = worker;
connection.on("message", (message: TerminalWorkerToParentMessage) => {
if (message.type === "terminalReplay") {
// Consumption queues into the application's bounded transport synchronously.
// Never wait for app network delivery or accumulate a second replay queue here.
if (worker !== connection || !pendingRequests.has(message.requestId)) return;
try {
replaySinks.get(message.requestId)?.(message.event);
} catch (error) {
const pending = pendingRequests.get(message.requestId)!;
clearTimeout(pending.timeout);
pendingRequests.delete(message.requestId);
replaySinks.delete(message.requestId);
pending.reject(error instanceof Error ? error : new Error(String(error)));
}
return;
}
if (isResponse(message)) {
const pending = pendingRequests.get(message.requestId);
if (!pending) {
@@ -628,14 +624,15 @@ export function createWorkerTerminalManager(
handleWorkerEvent(message);
});
worker.on("close", () => {
connection.on("close", () => {
if (worker !== connection) return;
workerDisconnected = true;
rejectPendingRequests(new Error("Terminal worker connection closed"));
const reconnect = managerOptions.reconnect;
if (!detached && reconnect) {
reconnecting = (async () => {
const connection = await reconnect();
if (!connection) {
const recovered = await reconnect();
if (!recovered) {
for (const terminalId of Array.from(recordsById.keys())) {
handleTerminalExitEvent({
type: "terminalExit",
@@ -646,10 +643,10 @@ export function createWorkerTerminalManager(
return;
}
if (detached) {
connection.disconnect();
recovered.disconnect();
return;
}
worker = connection;
worker = recovered;
workerDisconnected = false;
attachWorker();
await adoptTerminals();
@@ -672,12 +669,18 @@ export function createWorkerTerminalManager(
}
attachWorker();
function sendRequest(input: TerminalWorkerRequestInput): Promise<unknown> {
if (reconnecting && workerDisconnected) return reconnecting.then(() => sendRequest(input));
function sendRequest(
input: TerminalWorkerRequestInput,
sink?: TerminalReplaySink,
): Promise<unknown> {
if (reconnecting && workerDisconnected)
return reconnecting.then(() => sendRequest(input, sink));
if (workerDisconnected || !worker.connected) {
return Promise.reject(new Error("Terminal worker is not running"));
}
const connection = worker;
const requestId = randomUUID();
if (sink) replaySinks.set(requestId, sink);
const message = { ...input, requestId } as TerminalWorkerRequest;
const request = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -685,7 +688,7 @@ export function createWorkerTerminalManager(
reject(new Error(`Terminal worker request timed out: ${input.type}`));
}, requestTimeoutMs);
pendingRequests.set(requestId, { resolve, reject, timeout });
worker.send(message, (error) => {
connection.send(message, (error) => {
if (!error) {
return;
}
@@ -700,7 +703,10 @@ export function createWorkerTerminalManager(
() => undefined,
);
inFlight.add(settled);
void settled.finally(() => inFlight.delete(settled));
void settled.finally(() => {
inFlight.delete(settled);
replaySinks.delete(requestId);
});
return request;
}
@@ -867,22 +873,12 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session;
},
async getTerminalState(
id: string,
options?: { scrollbackLines?: number },
): Promise<TerminalStateSnapshot | null> {
const snapshot = (await sendRequest({
type: "getTerminalState",
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
if (snapshot && snapshot.replayPreamble !== undefined) {
const record = recordsById.get(id);
if (record) {
record.replayPreamble = snapshot.replayPreamble;
}
}
return snapshot;
async replayTerminal(id, options, sink): Promise<TerminalReplayMetadata | null> {
const metadata = (await sendRequest(
{ type: "replayTerminal", terminalId: id, options },
sink,
)) as TerminalReplayMetadata | null;
return metadata;
},
setTerminalTitle(id: string, title: string): boolean {
+1 -1
View File
@@ -24,7 +24,7 @@ const gatedCiJobs = new Map([
"server-integration-windows",
{ name: "server-integration (windows-latest)", contract: "server" },
],
["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }],
["server-tests-macos", { name: "server-tests (macos-15, file observation)", contract: "server" }],
["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }],
["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }],
["app-tests", { name: "app-tests", contract: "app" }],