Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andEgg3901 9b3f1bfffd Start custom Codex providers with reserved names (#6954)
* fix(codex): start custom providers with reserved names

Codex rejects custom provider keys such as ollama before a turn starts.
Namespace the generated keys without changing user-facing provider ids.
Keep older sessions discoverable and select the new key explicitly when
resuming them, so the startup fix does not strand existing conversations.

Closes #6874

Co-authored-by: Egg3901 <rainfordmason@gmail.com>

* fix(codex): keep native sessions out of custom provider imports

* fix(providers): align custom provider example with generated config

Correct the documented internal provider key and simplify import filtering while preserving native and legacy session selection.

* fix(codex): exclude native GCP sessions from custom imports

* test(codex): seed sessions through the portable launcher

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Egg3901 <rainfordmason@gmail.com>
2026-10-31 05:02:14 +00:00
paseo-ai[bot] 5ea639d9b2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-31 04:01:29 +00:00
Mohamed Boudra 9c926ed126 fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282)
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering.

Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering.

All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven.

Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change.

Closes #6137.
Closes #6147.
2026-10-31 10:51:45 +07:00
Mohamed Boudra 7b03f0ad21 fix(ci): migrate to the latest macOS runners (#8335)
* fix(ci): replace retiring macOS 14 runners

Use the supported macOS 15 ARM64 image before the November 2 retirement. Keep the Intel release runner and existing test/build/release commands unchanged.

* fix(ci): use the latest macOS runner images
2026-10-31 10:05:16 +07:00
101 changed files with 4612 additions and 2702 deletions
+2 -2
View File
@@ -281,10 +281,10 @@ jobs:
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to # otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime. # bound runtime.
server-tests-macos: server-tests-macos:
name: server-tests (macos-14, file observation) name: server-tests (macos-latest, file observation)
needs: changes needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14 runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+2 -2
View File
@@ -100,10 +100,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- runner: macos-14 - runner: macos-latest
electron_arch: arm64 electron_arch: arm64
node_heap_mb: 4096 node_heap_mb: 4096
- runner: macos-15-intel - runner: macos-26-intel
electron_arch: x64 electron_arch: x64
node_heap_mb: 8192 node_heap_mb: 8192
permissions: permissions:
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
run: nix build .#desktop -o result-desktop run: nix build .#desktop -o result-desktop
build-desktop-darwin: build-desktop-darwin:
runs-on: macos-14 runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+3 -3
View File
@@ -232,7 +232,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:<provider-id>`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name.
### Setup ### Setup
@@ -260,9 +260,9 @@ Paseo passes those variables through to the Codex app-server process **and** map
Under the hood, for each custom Codex provider Paseo injects this into Codex's config: Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml ```toml
model_provider = "my-codex" model_provider = "paseo:my-codex"
[model_providers.my-codex] [model_providers."paseo:my-codex"]
name = "My Codex" name = "My Codex"
base_url = "https://custom-relay.example.com/v1" base_url = "https://custom-relay.example.com/v1"
wire_api = "responses" wire_api = "responses"
+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 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. 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.
## Binary chunking ## Binary chunking
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1` `binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
+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) pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity) → headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal) → TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ process.send IPC → daemon main process → framed local socket → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts) → TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes) → binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime → 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) ## 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. - **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). - **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. - **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches. - **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. - **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. - **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. - **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). - 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. - 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. 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 ## 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. 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. 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 ## 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: 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:
+1 -1
View File
@@ -1 +1 @@
sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg= sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
+8
View File
@@ -14740,6 +14740,12 @@
"node": ">=10.0.0" "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": { "node_modules/@xterm/headless": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
@@ -37703,6 +37709,7 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7", "@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7", "@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213", "@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16", "babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -40682,6 +40689,7 @@
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10", "@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
"ajv": "^8.20.0", "ajv": "^8.20.0",
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; 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 { tmpdir } from "node:os";
import path from "node:path"; 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 { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { import {
openDaemonOverview, openDaemonOverview,
@@ -13,6 +14,10 @@ import {
readWorkerPid, readWorkerPid,
openLongLivedTerminal, openLongLivedTerminal,
listTerminalIds, listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle"; } from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({ 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; 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 type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window { interface AttachOverlayProbeWindow extends Window {
@@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false); 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 { 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 path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
throw error; 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 { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -6,6 +13,8 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env"; import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node"; import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon { export interface IsolatedHostDaemon {
serverId: string; serverId: string;
port: number; port: number;
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server") ? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../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 spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = { const spawnOptions: SpawnOptions = {
cwd: serverDir, cwd: serverDir,
@@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
detached: false, detached: false,
}; };
const child = publishedPackageRoot const spawned = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions) ? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions); : spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = ""; let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => { spawned.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8"); stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n"); stderr = stderr.split("\n").slice(-40).join("\n");
}); });
try { try {
await waitForServer(port, child); await waitForServer(port, spawned);
return child; return spawned;
} catch (error) { } catch (error) {
await killProcessTree(child);
throw new Error( throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error }, { cause: error },
@@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon(
} }
}; };
let child: ChildProcess;
try { try {
child = await spawnDaemon(); await spawnDaemon();
} catch (error) { } catch (error) {
await stopDaemon();
if (!options.preserveHome) { if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
} }
@@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon(
serverId, serverId,
port, port,
paseoHome, paseoHome,
getPid: () => child.pid, getPid: () => child?.pid,
restart: async () => { restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await killProcessTree(child); await stopDaemon();
child = await spawnDaemon(); await spawnDaemon();
}, },
close: async () => { close: async () => {
if (closed) return; if (closed) return;
closed = true; await stopDaemon();
await killProcessTree(child);
if (!options.preserveHome) { if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
} }
if (publishedPackageRoot) { if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true }); await rm(publishedPackageRoot, { recursive: true, force: true });
} }
closed = true;
}, },
}; };
} }
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number { export function round2(value: number): number {
return Math.round(value * 100) / 100; 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", "@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7", "@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7", "@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213", "@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16", "babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -4,7 +4,6 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime"; } from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react"; import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type { import type {
@@ -19,8 +18,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
find?: TerminalFindHandle; find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void; replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void; paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>; copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void; clear: () => void;
@@ -44,7 +42,6 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void; onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void; onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
@@ -13,7 +13,6 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { import {
TerminalInputModeTracker, TerminalInputModeTracker,
terminalInputModeStatesEqual, terminalInputModeStatesEqual,
@@ -120,43 +119,8 @@ function resolvePanResponderPoint(
}; };
} }
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { function initialNativeTerminalSize(): TerminalMeasuredSize {
return { return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
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 NativeTerminalEmulator({ function NativeTerminalEmulator({
@@ -169,7 +133,6 @@ function NativeTerminalEmulator({
fontSize, fontSize,
keyboardInset = 0, keyboardInset = 0,
isKeyboardVisible = false, isKeyboardVisible = false,
initialSnapshot = null,
onInput, onInput,
onTerminalKey, onTerminalKey,
onResize, onResize,
@@ -181,20 +144,18 @@ function NativeTerminalEmulator({
resizeRequestToken = 0, resizeRequestToken = 0,
}: TerminalEmulatorProps) { }: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null); const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey, initialSnapshot }); const mountInputRef = useRef({ streamKey });
if ( if (
resolveNativeTerminalMountAction({ resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey, mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey, nextStreamKey: streamKey,
}) === "mount" }) === "mount"
) { ) {
mountInputRef.current = { streamKey, initialSnapshot }; mountInputRef.current = { streamKey };
} }
const inputRef = useRef<TerminalInputHandle>(null); const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot)); const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef( const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const activeResizeClaimTokenRef = useRef(0); const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null); const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null); const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -220,11 +181,7 @@ function NativeTerminalEmulator({
...inputModeTrackerRef.current.getState(), ...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false, applicationCursorKeys: false,
}); });
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => { const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null); const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null); const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false); const hasNotifiedSelectionRef = useRef(false);
@@ -361,10 +318,14 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({ createNativeTerminalOutputDrain({
write: async (chunk) => { write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk); await terminal.write(chunk);
emitInputModeChange(); emitInputModeChange();
}, },
reset: () => { restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
terminal.reset(); terminal.reset();
emitInputModeChange(); emitInputModeChange();
}, },
@@ -419,8 +380,7 @@ function NativeTerminalEmulator({
hasNotifiedMeasuredSizeRef.current = true; hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize; terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) { if (policyResult.measuredSizeChanged) {
terminalRef.current?.resize(policyResult.measuredSize); outputDrainRef.current?.resize();
schedulePaint();
} }
callbacksRef.current.onResize?.({ callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows, rows: policyResult.measuredSize.rows,
@@ -437,68 +397,51 @@ function NativeTerminalEmulator({
}); });
} }
}, },
[schedulePaint], [],
); );
const resetTerminal = useCallback( const resetTerminal = useCallback(() => {
(snapshot: TerminalState | null) => { outputDrainRef.current?.dispose();
outputDrainRef.current?.dispose(); outputDrainRef.current = null;
outputDrainRef.current = null; terminalRef.current?.dispose();
terminalRef.current?.dispose(); screenModelRef.current = null;
screenModelRef.current = null; latestResolvedScreenRef.current = null;
latestResolvedScreenRef.current = null; clearSelection();
clearSelection(); const measuredSize = resolveMeasuredNativeTerminalSize({
const measuredSize = resolveMeasuredNativeTerminalSize({ layout: layoutRef.current,
layout: layoutRef.current, metrics: metricsRef.current,
metrics: metricsRef.current, });
}); const size = measuredSize ?? terminalSizeRef.current;
const size = terminalSizeRef.current = size;
measuredSize ?? resizePolicyRef.current = createTerminalResizePolicy(size);
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); outputDecoderRef.current.decode();
terminalSizeRef.current = size; const terminal = createNativeHeadlessTerminal({
resizePolicyRef.current = createTerminalResizePolicy(size); rows: size.rows,
outputDecoderRef.current.decode(); cols: size.cols,
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,
scrollbackLines, 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( const enqueueOutputText = useCallback((text: string) => {
(text: string) => { if (text.length === 0) {
if (text.length === 0) { return;
return; }
} outputDrainRef.current?.enqueueText(text);
const inputModeUpdate = inputModeTrackerRef.current.feed(text); }, []);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const claimActiveTerminalSize = useCallback(() => { const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1; activeResizeClaimTokenRef.current += 1;
@@ -521,27 +464,7 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true }); const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text); enqueueOutputText(text);
}, },
restoreOutput: (data: TerminalOutputData) => { replay: (event) => outputDrainRef.current?.replay(event),
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);
},
paste: (text: string) => { paste: (text: string) => {
if (text.length === 0) { if (text.length === 0) {
return; return;
@@ -572,7 +495,6 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset(); screenModelRef.current?.reset();
clearSelection(); clearSelection();
resetInputModeTracker(); resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint(); schedulePaint();
}, },
claimSize: claimActiveTerminalSize, claimSize: claimActiveTerminalSize,
@@ -591,14 +513,13 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction, claimActiveTerminalSizeForAction,
clearSelection, clearSelection,
resetInputModeTracker, resetInputModeTracker,
resetTerminal,
schedulePaint, schedulePaint,
], ],
); );
useEffect(() => { useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false; hasNotifiedMeasuredSizeRef.current = false;
resetTerminal(mountInputRef.current.initialSnapshot); resetTerminal();
emitMeasuredSize({ source: "measure" }); emitMeasuredSize({ source: "measure" });
return () => { return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
@@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { import {
@@ -40,8 +39,7 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
find?: TerminalFindHandle; find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void; replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void; paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>; copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void; clear: () => void;
@@ -108,7 +106,6 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void; onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void; onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
@@ -147,16 +144,6 @@ declare global {
interface Window {} 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({ export default function TerminalEmulator({
ref, ref,
streamKey, streamKey,
@@ -172,7 +159,6 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false, swipeGesturesEnabled = false,
onSwipeLeft, onSwipeLeft,
onSwipeRight, onSwipeRight,
initialSnapshot = null,
onFindRequest, onFindRequest,
onFindResult, onFindResult,
onInput, onInput,
@@ -228,8 +214,6 @@ export default function TerminalEmulator({
onOpenLocalFileLink, onOpenLocalFileLink,
onFilePaste, onFilePaste,
}; };
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers); const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers; pendingModifiersRef.current = pendingModifiers;
@@ -253,17 +237,17 @@ export default function TerminalEmulator({
const data = args[0]; const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data }); if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
}, },
restoreOutput: (...args) => { replay: (event) => {
const data = args[0]; if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
}, if (event.type === "data" && event.data instanceof Uint8Array)
renderSnapshot: (...args) => { runtimeRef.current?.replay({ type: "data", data: event.data });
const state = args[0]; if (event.type === "begin")
if (state === null) { runtimeRef.current?.replay({
runtimeRef.current?.renderSnapshot({ state: null }); type: "begin",
} else if (isTerminalState(state)) { rows: typeof event.rows === "number" ? event.rows : undefined,
runtimeRef.current?.renderSnapshot({ state }); cols: typeof event.cols === "number" ? event.cols : undefined,
} });
}, },
paste: (...args) => { paste: (...args) => {
const text = args[0]; const text = args[0];
@@ -299,12 +283,7 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => { writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data }); runtimeRef.current?.write({ data });
}, },
restoreOutput: (data: TerminalOutputData) => { replay: (event) => runtimeRef.current?.replay(event),
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => { paste: (text: string) => {
pasteText(text); pasteText(text);
}, },
@@ -471,7 +450,6 @@ export default function TerminalEmulator({
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current, scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current, theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { import {
DEFAULT_TERMINAL_INPUT_MODE_STATE, DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState, type TerminalInputModeState,
@@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import { import {
EMPTY_FOCUS_CLAIM_STATE, EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim, canRequestFocusClaim,
@@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform";
import { import {
applyTerminalRendererReadyChange, applyTerminalRendererReadyChange,
resolveTerminalStreamTarget, resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay, shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange, type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness"; } from "@/utils/terminal-renderer-readiness";
@@ -316,10 +313,6 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null); const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false); const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null); const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = 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 keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null); const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE); const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => { useEffect(() => {
terminalIdRef.current = terminalId; terminalIdRef.current = terminalId;
@@ -441,16 +433,8 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => { (change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset(); if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); 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(() => { useEffect(() => {
@@ -564,7 +548,6 @@ export function TerminalPane({
); );
const handleStreamExit = useStableEvent((exitedTerminalId: string) => { const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS }); 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(() => const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({ resolveTerminalRestoreOptions({
supportsTerminalRestoreModes, supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({ canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current, isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused, isPaneFocused,
@@ -653,8 +617,9 @@ export function TerminalPane({
client, client,
getPreferredSize: getPreferredStreamSize, getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput, onOutput: handleStreamOutput,
onRestore: handleStreamRestore, onReplay: ({ terminalId: id, replay }) => {
onSnapshot: handleStreamSnapshot, if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onExit: handleStreamExit, onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions, getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus, onStatusChange: handleStreamControllerStatus,
@@ -674,8 +639,6 @@ export function TerminalPane({
getStreamRestoreOptions, getStreamRestoreOptions,
handleStreamControllerStatus, handleStreamControllerStatus,
handleStreamOutput, handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit, handleStreamExit,
isConnected, isConnected,
]); ]);
@@ -1216,7 +1179,6 @@ export function TerminalPane({
keyboardInset={keyboardInset} keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible} isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled} swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange} onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight} onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft} onSwipeLeft={handleSwipeLeft}
+1
View File
@@ -617,6 +617,7 @@ export const ar: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "Host غير متصل", hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة", unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف", fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}", pasteFileFailed: "تعذر لصق الملف: {{reason}}",
+1
View File
@@ -614,6 +614,7 @@ export const en = {
terminal: { terminal: {
hostDisconnected: "Host is not connected", hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.", updateHost: "Update the host to use the native terminal renderer.",
restoreRequiresUpdate: "Update the host to view terminals.",
unableToSubscribe: "Unable to subscribe to terminal", unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded", fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}", pasteFileFailed: "Failed to paste file: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const es: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "Hostno está conectado", hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", 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", unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido", fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const fr: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "L’hôte n’est pas connecté", hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", 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", unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé", fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}", pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const ja: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "ホストが接続されていません", hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません", unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました", fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
+1
View File
@@ -618,6 +618,7 @@ export const ko: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "호스트가 연결되어 있지 않습니다", hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
unableToSubscribe: "터미널을 구독할 수 없습니다", unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다", fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
+1
View File
@@ -622,6 +622,7 @@ export const ptBR: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "Host não está conectado", hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.", 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", unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado", fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
+1
View File
@@ -624,6 +624,7 @@ export const ru: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "Хост не подключён", hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
unableToSubscribe: "Не удалось подключиться к терминалу", unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен", fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}", pasteFileFailed: "Не удалось вставить файл: {{reason}}",
+1
View File
@@ -617,6 +617,7 @@ export const zhCN: TranslationResources = {
terminal: { terminal: {
hostDisconnected: "Host 未连接", hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。", updateHost: "请更新主机以使用原生终端渲染器。",
restoreRequiresUpdate: "更新主机以查看终端。",
unableToSubscribe: "无法订阅 Terminal", unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传", fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}", pasteFileFailed: "无法粘贴文件:{{reason}}",
@@ -127,7 +127,6 @@ import {
useWorkspaceSetupStore, useWorkspaceSetupStore,
} from "@/stores/workspace-setup-store"; } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks"; 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 type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: {
return !input.isFocusModeEnabled || input.isMobile; 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 * 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. * moves there. No pane means the open has no opinion beyond the focused pane.
@@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId); useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore( 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 { describe, expect, test } from "vitest";
import { import {
createNativeHeadlessTerminal, createNativeHeadlessTerminal,
type TerminalCellRow,
type TerminalViewportState, type TerminalViewportState,
} from "./headless-terminal-state"; } from "./headless-terminal-state";
import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload";
@@ -11,7 +11,8 @@ import {
NATIVE_TERMINAL_PARSE_CHUNK_CHARS, NATIVE_TERMINAL_PARSE_CHUNK_CHARS,
} from "./headless-terminal-output-drain"; } from "./headless-terminal-output-drain";
import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model"; 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 ROWS = 12;
const COLS = 80; 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: { async function runScheduledWork(input: {
callbacks: Array<() => void>; callbacks: Array<() => void>;
flush: Promise<void>; flush: Promise<void>;
@@ -80,11 +54,13 @@ async function runScheduledWork(input: {
} }
async function benchmarkSnapshot(lineCount: number) { async function benchmarkSnapshot(lineCount: number) {
const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); return {
await terminal.write( rows: ROWS,
createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), cols: COLS,
); data: new TextEncoder().encode(
return terminal.getState(); createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }),
),
};
} }
function createScreenRestorer( function createScreenRestorer(
@@ -94,6 +70,7 @@ function createScreenRestorer(
const frameRecorder = createFrameRecorder(); const frameRecorder = createFrameRecorder();
let screen: TerminalScreenState | null = null; let screen: TerminalScreenState | null = null;
const drain = createNativeTerminalOutputDrain({ const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk), write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(), reset: () => terminal.reset(),
getViewportState: () => { getViewportState: () => {
@@ -107,7 +84,12 @@ function createScreenRestorer(
return { return {
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) { 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(); await drain.flush();
frameRecorder.frames[0]?.(); frameRecorder.frames[0]?.();
return screen; return screen;
@@ -122,6 +104,7 @@ describe("native terminal output drain", () => {
const frameRecorder = createFrameRecorder(); const frameRecorder = createFrameRecorder();
const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2); const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2);
const drain = createNativeTerminalOutputDrain({ const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async (chunk) => { write: async (chunk) => {
writes.push(chunk); writes.push(chunk);
}, },
@@ -170,6 +153,7 @@ describe("native terminal output drain", () => {
const paintedStates: TerminalViewportState[] = []; const paintedStates: TerminalViewportState[] = [];
const frameRecorder = createFrameRecorder(); const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({ const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk), write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(), reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(), getViewportState: () => terminal.getViewportState(),
@@ -202,6 +186,7 @@ describe("native terminal output drain", () => {
const scheduledWork: Array<() => void> = []; const scheduledWork: Array<() => void> = [];
const frameRecorder = createFrameRecorder(); const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({ const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk), write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(), reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(), getViewportState: () => terminal.getViewportState(),
@@ -210,21 +195,22 @@ describe("native terminal output drain", () => {
scheduleFrame: frameRecorder.scheduleFrame, scheduleFrame: frameRecorder.scheduleFrame,
cancelFrame: frameRecorder.cancelFrame, cancelFrame: frameRecorder.cancelFrame,
}); });
const restoredSnapshot = terminalStateFromLines({ const restoredCursor = { row: 1, col: "restored-two".length };
rows: 4, const replay = new TextEncoder().encode("restored-one\r\nrestored-two");
cols: 32,
lines: ["restored-one", "restored-two"],
cursor: { row: 1, col: "restored-two".length },
});
await terminal.write("old-one\r\nold-two\r\nold-three\r\n"); await terminal.write("old-one\r\nold-two\r\nold-three\r\n");
drain.enqueueText("stale-queued\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() }); await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() });
const state = terminal.getViewportState(); const state = terminal.getViewportState();
expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]); 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 () => { 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, paintedMode: screen?.scroll.mode,
paintedTopRow: rowText(screen?.viewport.grid[0] ?? []), paintedTopRow: rowText(screen?.viewport.grid[0] ?? []),
beforeTopRow, beforeTopRow,
restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText), restoredBottomRows: terminal.getViewportState().grid.slice(-2).map(rowText),
}).toEqual({ }).toEqual({
paintedMode: "scrolled", paintedMode: "scrolled",
paintedTopRow: beforeTopRow, 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 type { TerminalViewportState } from "./headless-terminal-state";
import { nativeTerminalPerformanceNow } from "./terminal-performance"; import { nativeTerminalPerformanceNow } from "./terminal-performance";
@@ -14,9 +12,9 @@ export interface NativeTerminalParseSample {
} }
export interface NativeTerminalOutputDrain { export interface NativeTerminalOutputDrain {
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
enqueueText(text: string): void; enqueueText(text: string): void;
restoreText(text: string): void; resize(): void;
restoreSnapshot(snapshot: TerminalState): void;
clear(): void; clear(): void;
flush(): Promise<void>; flush(): Promise<void>;
dispose(): void; dispose(): void;
@@ -25,7 +23,8 @@ export interface NativeTerminalOutputDrain {
export interface NativeTerminalOutputDrainOptions { export interface NativeTerminalOutputDrainOptions {
write: (chunk: string) => Promise<void>; write: (chunk: string) => Promise<void>;
reset: () => void; restoreSize: () => void;
reset: (size?: { rows: number; cols: number }) => void;
getViewportState: () => TerminalViewportState | null; getViewportState: () => TerminalViewportState | null;
onPaint: (state: TerminalViewportState) => void; onPaint: (state: TerminalViewportState) => void;
onParse?: (sample: NativeTerminalParseSample) => void; onParse?: (sample: NativeTerminalParseSample) => void;
@@ -68,16 +67,22 @@ export function createNativeTerminalOutputDrain(
const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS; const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS;
const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN; const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN;
const idleWaiters: Array<() => void> = []; 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 drainScheduled = false;
let draining = false; let draining = false;
let disposed = false; let disposed = false;
let replaying = false;
let paintFrame: number | null = null; let paintFrame: number | null = null;
let generation = 0; let generation = 0;
let resetBeforeNextWrite = false; const replayDecoder = new TextDecoder();
function resolveIdleIfReady(): void { function resolveIdleIfReady(): void {
if (drainScheduled || draining || pendingText.length > 0) { if (drainScheduled || draining || operations.length > 0) {
return; return;
} }
while (idleWaiters.length > 0) { 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> { async function drainPending(): Promise<void> {
if (disposed || draining) { if (disposed || draining) {
return; return;
@@ -138,16 +134,23 @@ export function createNativeTerminalOutputDrain(
let parsedChunks = 0; let parsedChunks = 0;
try { try {
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) { while (operations.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) { if (disposed || activeGeneration !== generation) return;
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) { const chunk = operation.text.slice(0, chunkChars);
options.reset(); operation.text = operation.text.slice(chunk.length);
resetBeforeNextWrite = false; queuedChars -= chunk.length;
} if (!operation.text) operations.shift();
const chunk = pendingText.slice(0, chunkChars);
pendingText = pendingText.slice(chunk.length);
const parseStart = nativeTerminalPerformanceNow(); const parseStart = nativeTerminalPerformanceNow();
await options.write(chunk); await options.write(chunk);
if (disposed || activeGeneration !== generation) { if (disposed || activeGeneration !== generation) {
@@ -156,14 +159,14 @@ export function createNativeTerminalOutputDrain(
options.onParse?.({ options.onParse?.({
durationMs: nativeTerminalPerformanceNow() - parseStart, durationMs: nativeTerminalPerformanceNow() - parseStart,
parsedChars: chunk.length, parsedChars: chunk.length,
queuedChars: pendingText.length, queuedChars,
}); });
requestPaint(); requestPaint();
parsedChunks += 1; parsedChunks += 1;
} }
} finally { } finally {
draining = false; draining = false;
if (!disposed && pendingText.length > 0) { if (!disposed && operations.length > 0) {
scheduleDrain(); scheduleDrain();
} }
resolveIdleIfReady(); resolveIdleIfReady();
@@ -171,38 +174,56 @@ export function createNativeTerminalOutputDrain(
} }
return { return {
enqueueText(text: string): void { replay(event): void {
if (disposed || text.length === 0) { if (disposed) return;
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(); scheduleDrain();
}, },
restoreText(text: string): void { resize(): void {
if (disposed) { if (disposed || replaying) return;
return; operations.push({ type: "restore-size" });
} scheduleDrain();
replacePendingText(text);
},
restoreSnapshot(snapshot: TerminalState): void {
if (disposed) {
return;
}
replacePendingText(renderTerminalSnapshotToAnsi(snapshot));
}, },
clear(): void { clear(): void {
if (disposed) return;
generation += 1; generation += 1;
pendingText = ""; operations = [{ type: "restore-size" }, { type: "reset" }];
resetBeforeNextWrite = false; queuedChars = 0;
replayDecoder.decode();
cancelPendingPaint(); cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady(); resolveIdleIfReady();
}, },
flush(): Promise<void> { flush(): Promise<void> {
if (!drainScheduled && !draining && pendingText.length === 0) { if (!drainScheduled && !draining && operations.length === 0) {
return Promise.resolve(); return Promise.resolve();
} }
scheduleDrain(); scheduleDrain();
@@ -214,8 +235,8 @@ export function createNativeTerminalOutputDrain(
dispose(): void { dispose(): void {
disposed = true; disposed = true;
generation += 1; generation += 1;
pendingText = ""; operations = [];
resetBeforeNextWrite = false; queuedChars = 0;
cancelPendingPaint(); cancelPendingPaint();
while (idleWaiters.length > 0) { while (idleWaiters.length > 0) {
idleWaiters.shift()?.(); idleWaiters.shift()?.();
@@ -223,7 +244,7 @@ export function createNativeTerminalOutputDrain(
}, },
getQueuedChars(): number { getQueuedChars(): number {
return pendingText.length; return queuedChars;
}, },
}; };
} }
@@ -21,7 +21,6 @@ export interface NativeHeadlessTerminalOptions {
export interface NativeHeadlessTerminal { export interface NativeHeadlessTerminal {
write(data: NativeTerminalWriteData): Promise<void>; write(data: NativeTerminalWriteData): Promise<void>;
getState(): TerminalState;
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState; getViewportState(input?: TerminalViewportStateInput): TerminalViewportState;
getInputModeState(): NativeHeadlessTerminalInputModeState; getInputModeState(): NativeHeadlessTerminalInputModeState;
getBufferBounds(): TerminalBufferBounds; getBufferBounds(): TerminalBufferBounds;
@@ -165,35 +164,6 @@ function extractRow(
return rowCells; 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( function extractBufferWindow(
terminal: HeadlessTerminalInstance, terminal: HeadlessTerminalInstance,
oldestRow: number, 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( function extractViewportState(
terminal: HeadlessTerminalInstance, terminal: HeadlessTerminalInstance,
coordinateEpoch: number, coordinateEpoch: number,
@@ -380,9 +337,6 @@ export function createNativeHeadlessTerminal(
}); });
}); });
}, },
getState(): TerminalState {
return extractState(terminal, { scrollbackLines: options.scrollbackLines });
},
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState { getViewportState(input?: TerminalViewportStateInput): TerminalViewportState {
return extractViewportState( return extractViewportState(
terminal, terminal,
@@ -10,6 +10,8 @@ import {
} from "./colors"; } from "./colors";
import { createNativeHeadlessTerminal } from "./headless-terminal-state"; import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import { buildTerminalFrame } from "./terminal-frame"; import { buildTerminalFrame } from "./terminal-frame";
import { createNativeTerminalOutputDrain } from "./headless-terminal-output-drain";
import { resolveMeasuredNativeTerminalSize } from "./terminal-size-measurement";
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const RED = parseTerminalColor(DEFAULT_TERMINAL_THEME.red, 0); const RED = parseTerminalColor(DEFAULT_TERMINAL_THEME.red, 0);
@@ -197,3 +199,48 @@ describe("native terminal frame", () => {
expect(actual).toEqual(graphemeRows); expect(actual).toEqual(graphemeRows);
}); });
}); });
test("builds native frames after replay commits at captured geometry and the latest measurement precedes live text", async () => {
let measured = resolveMeasuredNativeTerminalSize({
layout: { width: 400, height: 240 },
metrics: { cellWidth: 10, cellHeight: 10 },
})!;
const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 10000 });
const parsedColumns: number[] = [];
const drain = createNativeTerminalOutputDrain({
write: async (text) => {
parsedColumns.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") });
measured = resolveMeasuredNativeTerminalSize({
layout: { width: 500, height: 100 },
metrics: { cellWidth: 10, cellHeight: 10 },
})!;
drain.resize();
drain.replay({ type: "end" });
drain.enqueueText("\x1b[H\x1b[2K\x1b[31mLIVE-界🙂\x1b[0m");
await drain.flush();
expect(parsedColumns).toEqual([120, 50]);
const state = terminal.getViewportState();
const frame = buildTerminalFrame({ state, columns: state.cols, resolver, selection: null });
expect(frame.columns).toBe(50);
expect(frame.rows).toHaveLength(10);
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]);
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
} finally {
drain.dispose();
terminal.dispose();
}
});
@@ -54,16 +54,19 @@ describe("native terminal scrollback retention", () => {
scrollbackLines: SCROLLBACK, scrollbackLines: SCROLLBACK,
lineCount: SCROLLBACK + ROWS + 10, lineCount: SCROLLBACK + ROWS + 10,
}); });
const state = terminal.getState();
const bounds = terminal.getBufferBounds(); const bounds = terminal.getBufferBounds();
expect(state.scrollback.length).toBeGreaterThan(0); const historyRows = bounds.bottomViewport.firstRow - bounds.oldestRow;
expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK); expect(historyRows).toBeGreaterThan(0);
expect(historyRows).toBeLessThanOrEqual(SCROLLBACK);
expect(bounds.newestRow).toBeGreaterThan(ROWS); expect(bounds.newestRow).toBeGreaterThan(ROWS);
expect(bounds.oldestRow).toBeGreaterThan(0); expect(bounds.oldestRow).toBeGreaterThan(0);
expect(bounds.newestRow - bounds.oldestRow + 1).toBe( const retained = terminal.getBufferWindow({
state.scrollback.length + state.grid.length, 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 () => { test("observable scrollback count is bounded by configured lines", async () => {
@@ -73,9 +76,9 @@ describe("native terminal scrollback retention", () => {
scrollbackLines: SCROLLBACK, scrollbackLines: SCROLLBACK,
lineCount: SCROLLBACK * 3, 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 () => { test("scroll up moves viewport into retained history", async () => {
@@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000, scrollback: input.scrollback ?? 10_000,
theme: { theme: {
background: "#0b0b0b", background: "#0b0b0b",
@@ -750,30 +749,11 @@ describe("terminal emulator runtime in a real browser", () => {
const reset = vi.fn(originalReset); const reset = vi.fn(originalReset);
terminal.reset = reset; terminal.reset = reset;
mounted.runtime.renderSnapshot({ mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols });
state: { mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") });
rows: terminal.rows, mounted.runtime.replay({ type: "end" });
cols: terminal.cols,
scrollback: [],
grid: [
[
{ char: "p" },
{ char: "r" },
{ char: "o" },
{ char: "m" },
{ char: "p" },
{ char: "t" },
],
],
cursor: {
row: 0,
col: 6,
},
},
});
await nextFrame();
expect(reset).not.toHaveBeenCalled(); expect(reset).not.toHaveBeenCalled();
await nextFrame();
}); });
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { 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(); 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"]); 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(); vi.useFakeTimers();
const { runtime, terminal } = createRuntimeWithTerminal(); const { runtime, writeTexts } = createRuntimeWithTerminal();
const onCommitted = vi.fn(); runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("stuck") });
// restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still expect(writeTexts).toEqual(["\x1bc"]);
// applies (and commits) after the 5s safety timeout instead of stalling forever. vi.advanceTimersByTime(5000);
runtime.restoreOutput({ expect(writeTexts).toEqual(["\x1bc", "stuck"]);
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);
}); });
it("reports input mode changes from terminal output and resets them on snapshots", () => { 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.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({ runtime.replay({ type: "begin", rows: 2, cols: 8 });
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [[{ char: "$" }, { char: " " }]],
cursor: {
row: 0,
col: 2,
},
},
});
// The plain write reports kitty flags synchronously during drain; the snapshot resets // The plain write reports kitty flags synchronously during drain; the snapshot resets
// them only after its barrier gate (the sentinel write callback) resolves. // them only after its barrier gate (the sentinel write callback) resolves.
expect(inputModeChanges).toEqual([ expect(inputModeChanges).toEqual([
@@ -410,40 +388,18 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["second", "first"]); 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 { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = []; runtime.write({ data: terminalOutput("before-a") });
runtime.write({ data: terminalOutput("before-b") });
runtime.write({ runtime.replay({ type: "begin" });
data: terminalOutput("before-a"), runtime.replay({ type: "data", data: terminalOutput("snap") });
onCommitted: () => { runtime.replay({ type: "end" });
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.
expect(writeTexts).toEqual(["before-a", "before-b"]); 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]?.(); writeCallbacks[2]?.();
expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]); expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]);
// Snapshot commit fires only after its own write callback, strictly after the writes.
writeCallbacks[3]?.(); writeCallbacks[3]?.();
expect(committed).toEqual(["snap"]); expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]);
expect(terminal.resetCalls).toBe(0); 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 // No plain writes precede this barrier (mount), so there is nothing to gate: it starts
// at once with no sentinel, flipping suppressInput synchronously. // at once with no sentinel, flipping suppressInput synchronously.
runtime.restoreOutput({ data: terminalOutput("snapshot") }); runtime.replay({ type: "begin" });
expect(readSuppressInput()).toBe(true); expect(readSuppressInput()).toBe(true);
// writeCallbacks[0] is the barrier's own snapshot write; committing it restores input. // 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. // A plain write is now ungated, so the following barrier must wait on the sentinel.
runtime.write({ data: terminalOutput("output") }); 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. // The plain write commits first. Only the following sentinel opens the barrier.
expect(readSuppressInput()).toBe(false); expect(readSuppressInput()).toBe(false);
@@ -505,57 +461,40 @@ describe("terminal-emulator-runtime", () => {
expect(onCommittedB).toHaveBeenCalledTimes(1); 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(); const { runtime } = createRuntimeWithTerminal();
runtime.write({ data: terminalOutput("before unmount") }); runtime.write({ data: terminalOutput("before unmount") });
runtime.unmount(); runtime.unmount();
const remounted = attachStubTerminal(runtime); const remounted = attachStubTerminal(runtime);
runtime.restoreOutput({ data: terminalOutput("restored screen") }); runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]); expect(remounted.writeTexts).toEqual(["\x1bc"]);
expect(remounted.writeCallbacks).toHaveLength(1); 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(); const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.replay({ type: "begin", rows: 2, cols: 8 });
runtime.renderSnapshot({ runtime.replay({ type: "data", data: terminalOutput("hi") });
state: { runtime.replay({ type: "data", data: terminalOutput("\r\n$") });
rows: 2, runtime.replay({ type: "end" });
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]?.();
expect(terminal.resetCalls).toBe(0); expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toHaveLength(1); expect(writeTexts).toEqual(["\x1bc"]);
expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true); writeCallbacks[0]?.();
expect(writeTexts[0]).toContain("hi"); 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(); const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.replay({ type: "begin" });
runtime.restoreOutput({ data: terminalOutput("restored screen") }); runtime.replay({ type: "data", data: terminalOutput("restored screen") });
runtime.replay({ type: "end" });
// restoreOutput is a barrier (suppressInput); it applies after the gate sentinel.
writeCallbacks[0]?.(); writeCallbacks[0]?.();
expect(terminal.resetCalls).toBe(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", () => { 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 { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm"; import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { import {
type TerminalInputModeState, type TerminalInputModeState,
TerminalInputModeTracker, TerminalInputModeTracker,
@@ -24,7 +23,6 @@ import {
resolveMacTerminalEditingShortcut, resolveMacTerminalEditingShortcut,
shouldInterceptDomTerminalKey, shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys"; } from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import { import {
createTerminalLocalFileLinkProvider, createTerminalLocalFileLinkProvider,
type TerminalLocalFileLinkSource, type TerminalLocalFileLinkSource,
@@ -55,7 +53,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000;
export interface TerminalEmulatorRuntimeMountInput { export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement; root: HTMLDivElement;
host: HTMLDivElement; host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number; scrollback: number;
theme: ITheme; theme: ITheme;
fontFamily?: string; fontFamily?: string;
@@ -134,7 +131,7 @@ interface TerminalEmulatorRuntimeDisposables {
} }
interface TerminalOutputOperation { interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot"; type: "write" | "clear" | "snapshot" | "fit";
data: TerminalOutputData; data: TerminalOutputData;
rows?: number; rows?: number;
cols?: number; cols?: number;
@@ -280,6 +277,7 @@ export class TerminalEmulatorRuntime {
}, },
}; };
private replayResize: TerminalResizeRequest | null = null;
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null; private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null; private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
@@ -652,6 +650,7 @@ export class TerminalEmulatorRuntime {
window.__paseoTerminal = terminal; window.__paseoTerminal = terminal;
const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => { const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => {
if (this.deferReplayResize(resizeInput)) return;
const forceRefresh = resizeInput?.forceRefresh ?? false; const forceRefresh = resizeInput?.forceRefresh ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true; const shouldClaim = resizeInput?.shouldClaim ?? true;
const forceClaim = resizeInput?.forceClaim ?? false; const forceClaim = resizeInput?.forceClaim ?? false;
@@ -777,10 +776,6 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
}, 0); }, 0);
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
}
this.processOutputQueue(); this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = { const disposables: TerminalEmulatorRuntimeDisposables = {
@@ -898,20 +893,19 @@ export class TerminalEmulatorRuntime {
onFilePaste?.(file); onFilePaste?.(file);
} }
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
if (!input.state) { if (event.type === "begin") {
this.clear(input); this.replayResize ??= { shouldClaim: false };
return; 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; data: TerminalOutputData;
rows?: number; rows?: number;
cols?: number; cols?: number;
@@ -928,6 +922,16 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue(); 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 { resize(input?: TerminalResizeRequest): void {
this.fitAndEmitResize?.(input); this.fitAndEmitResize?.(input);
} }
@@ -1047,6 +1051,7 @@ export class TerminalEmulatorRuntime {
commit(); commit();
} }
this.hasUngatedWrites = false; this.hasUngatedWrites = false;
this.replayResize = null;
this.cleanup?.(); this.cleanup?.();
this.cleanup = null; this.cleanup = null;
@@ -1176,16 +1181,40 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue(); 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") { if (operation.type === "clear") {
this.inputModeDecoder.decode(); this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
this.emitInputModeChange(); this.emitInputModeChange();
terminal.reset(); 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; return;
} }
if (operation.type === "snapshot") { if (operation.type === "snapshot") {
this.replayResize ??= { shouldClaim: false };
this.inputModeDecoder.decode(); this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
this.emitInputModeChange(); this.emitInputModeChange();
@@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) {
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: null,
scrollback: 1000, scrollback: 1000,
theme: { background: "#ffffff", foreground: "#111111" }, theme: { background: "#ffffff", foreground: "#111111" },
}); });
@@ -38,7 +38,6 @@ function mountTerminal(left: number): TerminalView {
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: null,
scrollback: 1000, scrollback: 1000,
fontFamily: "monospace", fontFamily: "monospace",
fontSize: 16, 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 { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
// Regression: "streaming pino log, resized the Paseo terminal, old logs stayed // Regression: "streaming pino log, resized the Paseo terminal, old logs stayed
// narrow and new logs drew on top of the old ones." // 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 // Backpressure triggers an ANSI replay serialized directly from xterm. Replayed
// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi. // soft-wrapped lines must reflow on resize like live output.
// 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.
interface Mounted { interface Mounted {
host: HTMLDivElement; host: HTMLDivElement;
@@ -57,7 +56,6 @@ function mount(width: number, height: number): Mounted {
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: null,
scrollback: 10_000, scrollback: 10_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" }, 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)); 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) => { 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 // Serialize one long pino line from a real server-width xterm buffer.
// at `cols`, represented as multiple grid rows (exactly what the daemon stores in // The replay must retain its soft-wrap boundaries for later client reflow.
// its headless xterm and ships in a snapshot). async function buildWrappedSnapshot(cols: number) {
function buildWrappedSnapshot(cols: number): TerminalState { const source = new ReplaySource({ rows: 24, cols, allowProposedApi: true });
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 serializer = new SerializeAddon();
const grid: TerminalState["grid"] = []; source.loadAddon(serializer);
for (let i = 0; i < longLine.length; i += cols) { try {
const chunk = longLine.slice(i, i + cols); await new Promise<void>((resolve) => source.write(pinoLine(0), resolve));
grid.push([...chunk].map((char) => ({ char }))); 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 { function pinoLine(seq: number): string {
@@ -141,15 +135,14 @@ afterEach(() => {
}); });
describe("terminal resize reflow repro (Paseo terminal)", () => { 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); await page.viewport(1600, 700);
const m = mount(560, 360); // ~70 cols const m = mount(560, 360); // ~70 cols
await waitFor(() => m.terminal.cols > 40); await waitFor(() => m.terminal.cols > 40);
const narrowCols = m.terminal.cols; const narrowCols = m.terminal.cols;
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the // 1) A mid-stream ANSI replay arrives, preserving wraps at the captured width.
// long line wrapped at the server width as separate grid rows. await replayCommitted(m.runtime, await buildWrappedSnapshot(narrowCols));
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]")); await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped). // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
@@ -4,25 +4,27 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => { describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => { it("omits restore options for daemons without terminal restore modes", () => {
expect( expect(() =>
resolveTerminalRestoreOptions({ resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: false, supportsTerminalRestoreModes: false,
canClaimSize: true, canClaimSize: true,
size: { rows: 24, cols: 80 }, 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( expect(
resolveTerminalRestoreOptions({ resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true, supportsTerminalRestoreModes: true,
canClaimSize: true, canClaimSize: true,
size: { rows: 24, cols: 80 }, size: { rows: 24, cols: 80 },
}), }),
).toEqual({ ).toEqual({
mode: "visible-snapshot", mode: "visible-snapshot",
scrollbackLines: 200, scrollbackLines: 10000,
size: { rows: 24, cols: 80 }, size: { rows: 24, cols: 80 },
}); });
}); });
@@ -30,26 +32,39 @@ describe("terminal restore options", () => {
it("omits size until the terminal has been measured", () => { it("omits size until the terminal has been measured", () => {
expect( expect(
resolveTerminalRestoreOptions({ resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true, supportsTerminalRestoreModes: true,
canClaimSize: true, canClaimSize: true,
size: null, size: null,
}), }),
).toEqual({ ).toEqual({
mode: "visible-snapshot", mode: "visible-snapshot",
scrollbackLines: 200, scrollbackLines: 10000,
}); });
}); });
it("does not let a background attach resize the PTY", () => { it("does not let a background attach resize the PTY", () => {
expect( expect(
resolveTerminalRestoreOptions({ resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true, supportsTerminalRestoreModes: true,
canClaimSize: false, canClaimSize: false,
size: { rows: 24, cols: 80 }, size: { rows: 24, cols: 80 },
}), }),
).toEqual({ ).toEqual({
mode: "visible-snapshot", 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"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput { export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean; supportsTerminalRestoreModes: boolean;
scrollbackLines: number;
canClaimSize: boolean; canClaimSize: boolean;
size: { rows: number; cols: number } | null; size: { rows: number; cols: number } | null;
} }
export function resolveTerminalRestoreOptions( export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput, input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined { ): NonNullable<SubscribeTerminalRequest["restore"]> {
if (!input.supportsTerminalRestoreModes) { if (!input.supportsTerminalRestoreModes) {
return undefined; throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate"));
} }
return { return {
mode: "visible-snapshot", mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES, scrollbackLines: input.scrollbackLines,
...(input.canClaimSize && input.size ? { size: input.size } : {}), ...(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 { Terminal as ClientTerminal } from "@xterm/xterm";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; import { SerializeAddon } from "@xterm/addon-serialize";
interface SnapshotCell { interface SnapshotCell {
char: string; char: string;
@@ -122,7 +122,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S
} }
describe("terminal-snapshot", () => { 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({ const source = new HeadlessTerminal({
rows: 4, rows: 4,
cols: 12, cols: 12,
@@ -138,6 +138,8 @@ describe("terminal-snapshot", () => {
await writeToTerminal(source, "\u001b[2 q"); await writeToTerminal(source, "\u001b[2 q");
await writeToTerminal(source, "\u001b[?25l"); await writeToTerminal(source, "\u001b[?25l");
const serializer = new SerializeAddon();
source.loadAddon(serializer);
const snapshot = extractState(source); const snapshot = extractState(source);
const client = new ClientTerminal({ const client = new ClientTerminal({
@@ -147,7 +149,7 @@ describe("terminal-snapshot", () => {
scrollback: 100, scrollback: 100,
}); });
await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot)); await writeToTerminal(client, serializer.serialize() + "\x1b[2 q\x1b[?25l");
expect(extractState(client)).toEqual(snapshot); 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 type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -7,34 +8,16 @@ import {
type TerminalStreamControllerStatus, type TerminalStreamControllerStatus,
} from "./terminal-stream-controller"; } 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 { function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text); return new TextEncoder().encode(text);
} }
type TerminalStreamEvent = type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
| { terminalId: string; type: "output"; data: Uint8Array } type TerminalStreamEvent = StreamEvent extends infer Event
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot } ? Event extends StreamEvent
| { terminalId: string; type: "restore"; data: Uint8Array }; ? Omit<Event, "subscriptionId">
: never
: never;
class FakeTerminalStreamClient implements TerminalStreamControllerClient { class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
@@ -115,32 +98,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) { function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient(); const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = []; const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = []; const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = []; const statuses: TerminalStreamControllerStatus[] = [];
const exits: string[] = []; const exits: string[] = [];
const controller = new TerminalStreamController({ const controller = new TerminalStreamController({
client, client,
onReplay: ({ replay }) => replays.push(replay),
onExit: (terminalId) => exits.push(terminalId), onExit: (terminalId) => exits.push(terminalId),
getPreferredSize: () => ({ rows: 24, cols: 80 }), getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => { onOutput: (output) => {
outputs.push(output); outputs.push(output);
}, },
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => { onStatusChange: (status) => {
statuses.push(status); statuses.push(status);
}, },
}); });
return { client, controller, outputs, restores, snapshots, statuses, exits }; return { client, controller, outputs, replays, statuses, exits };
} }
async function flushAsyncWork(): Promise<void> { async function flushAsyncWork(): Promise<void> {
@@ -149,24 +123,20 @@ async function flushAsyncWork(): Promise<void> {
} }
describe("terminal-stream-controller", () => { 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(); const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" }); harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork(); await flushAsyncWork();
harness.client.emit({ const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
terminalId: "term-1", { type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
type: "snapshot", { type: "data", data: terminalOutput("hello") },
state: { { type: "end" },
rows: 1, ];
cols: 5, for (const event of replay)
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world"); const outputData = terminalOutput(" world");
harness.client.emit({ harness.client.emit({
terminalId: "term-1", terminalId: "term-1",
@@ -178,7 +148,7 @@ describe("terminal-stream-controller", () => {
expect(harness.client.resizeCalls).toEqual([ expect(harness.client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, { 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[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]); expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({ expect(harness.statuses.at(-1)).toEqual({
@@ -236,23 +206,19 @@ describe("terminal-stream-controller", () => {
size: { rows: 24, cols: 80 }, size: { rows: 24, cols: 80 },
}), }),
onOutput: (output) => harness.outputs.push(output), onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore), onReplay: ({ replay }) => harness.replays.push(replay),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
}); });
controller.setTerminal({ terminalId: "term-1" }); controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork(); await flushAsyncWork();
const restoreData = terminalOutput("restored"); const restoreData = terminalOutput("restored");
client.emit({ const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
terminalId: "term-1", { type: "begin" },
type: "restore", { type: "data", data: restoreData },
data: restoreData, { type: "end" },
}); ];
for (const event of replay)
client.emit({ terminalId: "term-1", type: "replay", replay: event });
controller.dispose(); controller.dispose();
expect(client.subscribeCalls).toEqual([ expect(client.subscribeCalls).toEqual([
@@ -270,8 +236,7 @@ describe("terminal-stream-controller", () => {
expect(client.resizeCalls).toEqual([ expect(client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]); ]);
expect(harness.restores[0]?.data).toBe(restoreData); expect(harness.replays).toEqual(replay);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.outputs).toEqual([]); 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); expect(h.statuses.at(-1)?.error).toBe(null);
h.controller.dispose(); 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 { 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 type { TerminalOutputData } from "./terminal-emulator-runtime";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
@@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient; client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null; getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onExit?: (terminalId: string) => 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; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void; onStatusChange?: (status: TerminalStreamControllerStatus) => void;
} }
@@ -43,17 +45,11 @@ export class TerminalStreamController {
if (this.disposed || event.terminalId !== this.terminalId) { if (this.disposed || event.terminalId !== this.terminalId) {
return; return;
} }
if (event.type === "snapshot") { if (event.type === "replay") {
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
return; return;
} }
if (event.type === "restore") { if (event.type === "output" && event.data.length > 0) {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data }); 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 }); this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return; return;
} }
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
let subscription: ReturnType<DaemonClient["observeTerminal"]>; let subscription: ReturnType<DaemonClient["observeTerminal"]>;
try { try {
const restore = this.options.getRestoreOptions?.();
const preferredSize = restore?.size ?? this.options.getPreferredSize(); const preferredSize = restore?.size ?? this.options.getPreferredSize();
if (preferredSize) if (preferredSize)
this.options.client.sendTerminalInput(nextTerminalId, { 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 { import {
applyTerminalRendererReadyChange, applyTerminalRendererReadyChange,
resolveTerminalStreamTarget, resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay, shouldShowTerminalLoadingOverlay,
} from "./terminal-renderer-readiness"; } from "./terminal-renderer-readiness";
@@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => {
expect(current).toBeNull(); 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", () => { it("does not attach the stream before the matching renderer is ready", () => {
expect( expect(
resolveTerminalStreamTarget({ resolveTerminalStreamTarget({
@@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange(
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey; 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: { export function resolveTerminalStreamTarget(input: {
terminalId: string; terminalId: string;
terminalStreamKey: string; terminalStreamKey: string;
@@ -10,11 +10,10 @@
* `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away, * `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 * 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. * environment and CLI, while the first terminal keeps A's.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version * 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): protocols 0–4
* old drains (keeps its terminal, new ones open in a second worker, it exits after its last * are obsolete unreleased wires, so their programs/workers/copies are closed and a current
* terminal and its copy is deleted); one two versions old is shut down; one from a newer * worker serves new terminals. A truly newer protocol is left running, reported as
* Paseo is left running, reported as terminal_worker_incompatible, and closed by * terminal_worker_incompatible, and closed by `daemon stop`.
* `daemon stop`.
* *
* Every process it starts is stopped in `finally`. * Every process it starts is stopped in `finally`.
*/ */
@@ -35,7 +34,7 @@ import {
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getAvailablePort } from "./helpers/network.ts"; import { createServer } from "node:net";
if (process.platform === "win32") { if (process.platform === "win32") {
console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals)."); 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 cliHome = join(root, "os-home");
const workDir = join(root, "work"); const workDir = join(root, "work");
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); 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; const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation { interface Installation {
@@ -336,7 +335,18 @@ async function terminalIds(installation: Installation): Promise<string[]> {
} }
async function startDaemon(installation: Installation, extraEnv: Record<string, 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) { 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"); const b = createInstallation("B", "0.99.0");
let restoreA: (() => void) | null = null; let restoreA: (() => void) | null = null;
let passed = false; let passed = false;
let lastListen = "";
const competingListener = createServer();
try { try {
for (const [field, value] of [ 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"], ["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"], ["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"], ["features.voiceMode.enabled", "false"],
@@ -387,6 +401,7 @@ try {
const t1 = await openTerminal(a, "t1"); const t1 = await openTerminal(a, "t1");
const [firstWorker] = workerRecords(); const [firstWorker] = workerRecords();
assert(firstWorker, "one worker runs"); 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}$/); assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!); const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
assert( assert(
@@ -432,54 +447,62 @@ try {
restoreA = null; restoreA = null;
console.log("✓ closed and cleaned up\n"); console.log("✓ closed and cleaned up\n");
console.log("Test 4: a worker one protocol version old drains"); // A released endpoint belongs to nobody. Reproduce another process taking it
const old = await leaveWorkerWithProtocol(a, 0, "t3"); // between starts; worker adoption is keyed by the home, not by a stable TCP port.
await startDaemon(b); const releasedPort = Number(lastListen.split(":").at(-1));
const workers = workerRecords(); await new Promise<void>((resolve, reject) => {
assert.equal(workers.length, 2, "the old worker drains beside a new one"); competingListener.once("error", reject);
const current = workers.find((record) => record.pid !== old.workerPid)!; competingListener.listen(releasedPort, "127.0.0.1", resolve);
assert.equal(current.protocolVersion, 1); });
assert.match(current.runtime ?? "", /^0\.99\.0-/); console.log(`Competing listener owns released port ${releasedPort}`);
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");
console.log("Test 5: a worker two protocol versions old is shut down"); // There is no supported previous wire at protocol 5. Future previous-version classification
const obsolete = await leaveWorkerWithProtocol(a, -1, "t5"); // is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
await startDaemon(b); for (const protocolVersion of [0, 1, 2, 3, 4]) {
await waitFor( console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid), const obsolete = await leaveWorkerWithProtocol(
() => "the obsolete worker or its terminal survived", a,
); protocolVersion,
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone"); `obsolete-${protocolVersion}`,
assert.equal(workerRecords().length, 1, "one current worker runs"); );
await stopDaemon(b); await startDaemon(b);
console.log("✓ shut down\n"); 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"); console.log("Test 5: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, 2, "t6"); const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer");
await startDaemon(b); await startDaemon(b);
const listed = await run(b, ["terminal", "ls", "--all", "--json"]); const listed = await run(b, ["terminal", "ls", "--all", "--json"]);
assert.notEqual(listed.exitCode, 0, "terminal ls fails"); 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?.(); restoreA?.();
for (const installation of [b, a]) { for (const installation of [b, a]) {
await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]); 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 // Protocol support belongs to the installed client. Only browser hosting needs
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves. // a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
export const DEFAULT_CLIENT_CAPABILITIES = { export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.terminalChunkedReplay]: true,
[CLIENT_CAPS.binaryChunkingV1]: true, [CLIENT_CAPS.binaryChunkingV1]: true,
[CLIENT_CAPS.helloRejection]: true, [CLIENT_CAPS.helloRejection]: true,
[CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.ownedSubscriptions]: true,
+197 -55
View File
@@ -13,6 +13,7 @@ import {
type PluginClientBundleStore, type PluginClientBundleStore,
} from "./daemon-client"; } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { ChunkedTransport } from "@getpaseo/protocol/chunked-transport";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { import {
decodeFileTransferFrame, decodeFileTransferFrame,
@@ -1102,6 +1103,7 @@ test("advertises client capabilities in hello", async () => {
projected_subagent_timeline: true, projected_subagent_timeline: true,
reasoning_merge_enum: true, reasoning_merge_enum: true,
terminal_reflowable_snapshot: true, terminal_reflowable_snapshot: true,
terminal_chunked_replay: true,
timeline_notifications: true, timeline_notifications: true,
plugin_timeline_items: true, plugin_timeline_items: true,
workspace_setup_blocked: true, workspace_setup_blocked: true,
@@ -6388,66 +6390,80 @@ test("emits output events for the active terminal stream", async () => {
unsubscribe(); unsubscribe();
}); });
test("emits snapshot events for the subscribed terminal stream", async () => { test.each([5, 500, 10000])(
const logger = createMockLogger(); "preserves explicit %s history demand on an old ANSI host and normalizes restore",
const mock = createMockTransport(); async (history) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({ const client = new DaemonClient({
url: "ws://test", url: "ws://test",
clientId: "clsk_unit_test", clientId: "clsk_unit_test",
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
}); });
clients.push(client); clients.push(client);
const connectPromise = client.connect(); const connectPromise = client.connect();
mock.triggerOpen(); mock.triggerOpen();
await connectPromise; await connectPromise;
const snapshots: unknown[] = []; const events: unknown[] = [];
client.onTerminalStreamEvent((event) => { const replayEvents: unknown[] = [];
if (event.type !== "snapshot") { client.onTerminalStreamEvent((event) => {
return; if (event.type === "replay") replayEvents.push(event.replay);
} events.push(event);
snapshots.push(event.state); });
});
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" }); const subscribePromise = client.subscribeTerminal("term-1", {
await vi.waitFor(() => requestId: "sub-2",
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), restore: { mode: "visible-snapshot", scrollbackLines: history },
); });
mock.triggerMessage( await vi.waitFor(() =>
wrapSessionMessage({ expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
type: "subscribe_terminal_response", );
payload: { expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), mode: "visible-snapshot",
terminalId: "term-1", 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, slot: 12,
error: null, payload: encodeTerminalSnapshotPayload(state),
requestId: "sub-2", }),
}, );
}),
);
await subscribePromise;
const state = { expect(events).toEqual([]);
rows: 1, const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
cols: 5, mock.triggerMessage(
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
scrollback: [], );
cursor: { row: 0, col: 5 }, expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
}; },
mock.triggerMessage( );
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(snapshots).toEqual([state]);
});
test("sends explicit terminal input and resize commands", async () => { test("sends explicit terminal input and resize commands", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
@@ -6843,7 +6859,6 @@ test.each([false, true])(
}); });
const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd"; const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd";
const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport");
const sender = new ChunkedTransport({ const sender = new ChunkedTransport({
frameBytes: 24 * 1024 * 1024, frameBytes: 24 * 1024 * 1024,
send: (frame) => mock.triggerMessage(frame), send: (frame) => mock.triggerMessage(frame),
@@ -8484,6 +8499,133 @@ test("closes before admission when a protected-capable daemon is reached in lega
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
}); });
test.each([
{ chunked: false, transportChunked: false },
{ chunked: true, transportChunked: false },
{ chunked: false, transportChunked: true },
{ chunked: true, transportChunked: true },
])(
"default terminal observation requests ANSI replay (replay=$chunked, transport=$transportChunked)",
async ({ chunked, transportChunked }) => {
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({
preserveSent: true,
features: {
ownedSubscriptions: true,
"terminal-restore-modes": true,
binaryChunkingV1: transportChunked,
},
});
await connecting;
const hello = JSON.parse(transport.sent[0] as string);
expect(hello.capabilities[CLIENT_CAPS.terminalChunkedReplay]).toBe(true);
expect(hello.capabilities[CLIENT_CAPS.binaryChunkingV1]).toBe(true);
const wire: Uint8Array[] = [];
const sender = new ChunkedTransport({
frameBytes: 17,
send: (frame) => {
if (typeof frame === "string") throw new Error("Expected binary terminal frame");
wire.push(frame);
transport.triggerMessage(frame);
},
deliver: () => {},
});
sender.enabled = transportChunked;
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) {
await sender.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayBegin,
slot: 2,
payload: JSON.stringify(metadata),
}),
);
await sender.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 2,
payload: data,
}),
);
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }),
);
} else {
// An ANSI-era host selects its existing Restore path for this explicit request.
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }),
);
}
if (transportChunked) {
expect(wire.length).toBeGreaterThan(chunked ? 3 : 1);
expect(wire.some((frame) => frame[0] === 0x31)).toBe(true);
expect(wire.every((frame) => frame.length <= 17)).toBe(true);
} else {
expect(wire.map((frame) => frame[0])).toEqual(chunked ? [6, 7, 8] : [5]);
}
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" },
},
]);
const live = new TextEncoder().encode("live after replay 界🙂");
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload: live }),
);
expect(events.at(-1)).toEqual({
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "output",
data: live,
});
},
);
test("uses the frame budget selected by an asynchronously opened transport", async () => { test("uses the frame budget selected by an asynchronously opened transport", async () => {
const mock = createMockTransport(); const mock = createMockTransport();
mock.transport.maxFrameBytes = 1024; mock.transport.maxFrameBytes = 1024;
+3 -2
View File
@@ -6460,7 +6460,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. * for terminal_stream_exit: payload.error means observation failure, not PTY exit.
* Either outcome detaches this slot and releases the handle automatically. * Either outcome detaches this slot and releases the handle automatically.
*/ */
@@ -6479,7 +6480,7 @@ export class DaemonClient {
{ {
type: "subscribe_terminal_request", type: "subscribe_terminal_request",
terminalId, terminalId,
...(options?.restore ? { restore: options.restore } : {}), restore: options?.restore ?? { mode: "full-snapshot" },
}, },
options, 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 type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
export type { AgentAddress } from "@getpaseo/protocol/addresses"; 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"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => { 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 router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = []; const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen"); const payload = new TextEncoder().encode("restored screen");
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
payload, payload,
}); });
expect(events).toEqual([ expect(events).toEqual(
{ ["begin", "data", "end"].map((type) => ({
terminalId: "term-1", terminalId: "term-1",
subscriptionId: "subscription-1", subscriptionId: "subscription-1",
type: "restore", type: "replay",
data: payload, 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); router.handleFrame(frame);
expect(received).toEqual(["first", "second", "second"]); 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 { import {
decodeTerminalSnapshotPayload,
TerminalStreamOpcode, TerminalStreamOpcode,
TerminalReplayMetadataSchema,
type TerminalStreamFrame, type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index"; } 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 } & ( export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
| { type: "output"; data: Uint8Array } | { type: "output"; data: Uint8Array }
| { type: "snapshot"; state: TerminalState } | { type: "replay"; replay: TerminalReplayEvent }
| { type: "restore"; data: Uint8Array }
); );
export class TerminalStreamRouter { export class TerminalStreamRouter {
@@ -61,20 +64,42 @@ export class TerminalStreamRouter {
let event: TerminalStreamEvent; let event: TerminalStreamEvent;
if (frame.opcode === TerminalStreamOpcode.Output) if (frame.opcode === TerminalStreamOpcode.Output)
event = { ...identity, type: "output", data: frame.payload }; event = { ...identity, type: "output", data: frame.payload };
else if (frame.opcode === TerminalStreamOpcode.Restore) else if (frame.opcode === TerminalStreamOpcode.Restore) {
event = { ...identity, type: "restore", data: frame.payload }; // COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
else if (frame.opcode === TerminalStreamOpcode.Snapshot) { for (const replay of [
const state = decodeTerminalSnapshotPayload(frame.payload); { type: "begin" },
if (!state) return; { type: "data", data: frame.payload },
event = { ...identity, type: "snapshot", state }; { type: "end" },
} else return; ] as TerminalReplayEvent[])
registration.receive(event); this.deliver(registration.receive, { ...identity, type: "replay", replay });
for (const listener of this.listeners) { continue;
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
let raw: unknown;
try { try {
listener(event); raw = JSON.parse(new TextDecoder().decode(frame.payload));
} catch { } 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"); ).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.Input:
case TerminalStreamOpcode.Resize: case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot: case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.ReplayBegin:
case TerminalStreamOpcode.ReplayData:
case TerminalStreamOpcode.ReplayEnd:
case TerminalStreamOpcode.Restore: { case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes); const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null; return frame ? { kind: "terminal", frame } : null;
@@ -1,6 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { TerminalStateSchema } from "../messages.js"; 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({ export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(), rows: z.number().int().positive(),
cols: z.number().int().positive(), cols: z.number().int().positive(),
@@ -13,6 +19,9 @@ export const TerminalStreamOpcode = {
Resize: 0x03, Resize: 0x03,
Snapshot: 0x04, Snapshot: 0x04,
Restore: 0x05, Restore: 0x05,
ReplayBegin: 0x06,
ReplayData: 0x07,
ReplayEnd: 0x08,
} as const; } as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Input || value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot || value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.ReplayBegin ||
value === TerminalStreamOpcode.ReplayData ||
value === TerminalStreamOpcode.ReplayEnd ||
value === TerminalStreamOpcode.Restore value === TerminalStreamOpcode.Restore
); );
} }
@@ -1,4 +1,5 @@
export const CLIENT_CAPS = { export const CLIENT_CAPS = {
terminalChunkedReplay: "terminal_chunked_replay",
binaryChunkingV1: "binary_chunking_v1", binaryChunkingV1: "binary_chunking_v1",
helloRejection: "hello_rejection", helloRejection: "hello_rejection",
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
@@ -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", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10", "@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
"ajv": "^8.20.0", "ajv": "^8.20.0",
@@ -1493,7 +1493,6 @@ describe("terminal MCP tools", () => {
id: "term-1", id: "term-1",
name: "daemon", name: "daemon",
cwd: process.cwd(), cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}), }),
captureTerminal, captureTerminal,
}); });
@@ -2623,9 +2623,9 @@ describe("Codex app-server provider", () => {
OPENAI_BASE_URL: "https://custom-relay.example.com", OPENAI_BASE_URL: "https://custom-relay.example.com",
}); });
expect(capturedThreadStartConfig(capturedRequests)).toEqual({ expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-iisb", model_provider: "paseo:codex-iisb",
model_providers: { model_providers: {
"codex-iisb": { "paseo:codex-iisb": {
name: "Custom Codex", name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1", base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY", env_key: "OPENAI_API_KEY",
@@ -2643,15 +2643,30 @@ describe("Codex app-server provider", () => {
); );
expect(capturedThreadStartConfig(capturedRequests)).toEqual({ expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-custom", model_provider: "paseo:codex-custom",
model_providers: { model_providers: {
"codex-custom": expect.objectContaining({ "paseo:codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1", base_url: "https://custom-relay.example.com/v1",
}), }),
}, },
}); });
}); });
test("namespaces provider ids that collide with Codex built-ins", async () => {
// `ollama` is a reserved Codex provider id: without namespacing, Codex rejects
// the generated config outright and the agent never starts.
const capturedRequests = await runCustomCodexProviderTurn("ollama", "https://ollama.com");
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "paseo:ollama",
model_providers: {
"paseo:ollama": expect.objectContaining({
base_url: "https://ollama.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = []; const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({ const appServer = createFakeCodexAppServer({
@@ -7348,9 +7363,21 @@ describe("Codex importable sessions", () => {
const threadsByModelProvider = [ const threadsByModelProvider = [
{ id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 },
{ id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 },
{
id: "new-custom-thread",
cwd: "/workspace/project-a",
modelProvider: "paseo:my-codex",
updatedAt: 3,
},
{
id: "other-custom-thread",
cwd: "/workspace/project-a",
modelProvider: "paseo-my-codex",
updatedAt: 4,
},
]; ];
test("a custom Codex provider lists the threads it created", async () => { test("a custom Codex provider lists its old and namespaced threads without other providers", async () => {
const appServer = createFakeCodexAppServer({ const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider), "thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
}); });
@@ -7363,10 +7390,40 @@ describe("Codex importable sessions", () => {
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); expect(sessions.map((session) => session.providerHandleId)).toEqual([
"custom-thread",
"new-custom-thread",
]);
appServer.assertNoErrors(); appServer.assertNoErrors();
}); });
test.each(["openai", "ollama", "lmstudio", "amazon-bedrock", "amazon-bedrock-runtime", "gcp"])(
"a custom provider named %s excludes native built-in sessions",
async (providerId) => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler([
{ id: "native-thread", cwd: "/workspace/project-a", modelProvider: providerId },
{
id: "custom-thread",
cwd: "/workspace/project-a",
modelProvider: `paseo:${providerId}`,
},
]),
});
const provider = createProviderWithFakeAppServer(appServer, {
runtimeSettings: {
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
},
customProvider: { id: providerId, label: "Custom Codex", extends: "codex" },
});
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]);
appServer.assertNoErrors();
},
);
test("stock Codex keeps listing only its own threads", async () => { test("stock Codex keeps listing only its own threads", async () => {
const appServer = createFakeCodexAppServer({ const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider), "thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
@@ -151,6 +151,16 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000; const INTERRUPT_TIMEOUT_MS = 2_000;
const CODEX_PROVIDER = "codex" as const; const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
const CODEX_BUILTIN_MODEL_PROVIDER_IDS = new Set([
"openai",
"ollama",
"lmstudio",
"amazon-bedrock",
"amazon-bedrock-runtime",
"gcp",
]);
// Codex treats most app-server client names as the model-request originator. // Codex treats most app-server client names as the model-request originator.
// This reserved Codex name is non-originating, so requests keep Codex's default // This reserved Codex name is non-originating, so requests keep Codex's default
// CLI identity instead of showing up as Paseo in provider usage logs. // CLI identity instead of showing up as Paseo in provider usage logs.
@@ -3368,10 +3378,14 @@ function buildCodexCustomProviderConfig(
providerConfig.env_key = "OPENAI_API_KEY"; providerConfig.env_key = "OPENAI_API_KEY";
providerConfig.requires_openai_auth = false; providerConfig.requires_openai_auth = false;
} }
// Namespace Paseo ids so they cannot override Codex's built-ins. A colon is
// forbidden in Paseo ids, keeping these keys distinct from every legacy id
// when both generations are listed together.
const providerKey = `paseo:${customProvider.id}`;
return { return {
model_provider: customProvider.id, model_provider: providerKey,
model_providers: { model_providers: {
[customProvider.id]: providerConfig, [providerKey]: providerConfig,
}, },
}; };
} }
@@ -4059,6 +4073,11 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.client || !this.currentThreadId) return; if (!this.client || !this.currentThreadId) return;
// Paseo reads history only when it replays it; the resume response needs no turns. // Paseo reads history only when it replays it; the resume response needs no turns.
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true }; const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
if (this.deps.customCodexConfig) {
// Resume otherwise prefers the provider stored on the thread, including
// the unnamespaced key emitted by earlier Paseo versions.
params.modelProvider = this.deps.customCodexConfig.model_provider;
}
const developerInstructions = composeSystemPromptParts( const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt, this.config.systemPrompt,
this.config.daemonAppendSystemPrompt, this.config.daemonAppendSystemPrompt,
@@ -7373,12 +7392,19 @@ export class CodexAppServerAgentClient implements AgentClient {
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
// Codex records each thread under the model provider that ran it and, // Codex records each thread under the model provider that ran it and,
// unless told otherwise, lists only the provider its own config selects. // unless told otherwise, lists only the provider its own config selects.
// A custom provider runs under its own id, so ask for exactly that one. // Include legacy custom ids, but keep Codex built-in sessions separate.
const customProviderConfig = this.customProviderConfig(); const customProviderConfig = this.customProviderConfig();
let modelProviders: string[] | undefined;
if (customProviderConfig && this.deps.customProvider) {
modelProviders = [customProviderConfig.model_provider];
if (!CODEX_BUILTIN_MODEL_PROVIDER_IDS.has(this.deps.customProvider.id)) {
modelProviders.push(this.deps.customProvider.id);
}
}
const allThreads = await readCodexThreadWindow(client, this.logger, { const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit, limit: listLimit,
cwd: options?.cwd, cwd: options?.cwd,
...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), ...(modelProviders ? { modelProviders } : {}),
}); });
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => { return threads.slice(0, limit).map((thread) => {
@@ -7,7 +7,8 @@ import path from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
// Runs the real `codex` binary against a local Responses endpoint standing in // Runs the real `codex` binary against a local Responses endpoint standing in
// for a custom provider's OPENAI_BASE_URL, so no login is needed. // for a custom provider's OPENAI_BASE_URL, so no login is needed.
@@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
}); });
test("lists a custom provider's session under that provider and imports it there", async () => { test.each([
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); { providerId: "my-codex", seedLegacy: false, seedBuiltin: false },
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); { providerId: "ollama", seedLegacy: false, seedBuiltin: false },
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); { providerId: "my-codex", seedLegacy: true, seedBuiltin: false },
cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); { providerId: "ollama", seedLegacy: false, seedBuiltin: true },
const { baseUrl, server, requestCount } = await startResponsesEndpoint(); ])(
cleanups.push(() => closeServer(server)); "lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)",
async ({ providerId, seedLegacy, seedBuiltin }) => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
const logger = createTestLogger(); const logger = createTestLogger();
const customCodex = new CodexAppServerAgentClient( if (seedLegacy || seedBuiltin) {
logger, // Seed an old custom session or a native built-in session with real Codex.
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, const environment = {
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, ...process.env,
); CODEX_HOME: codexHome,
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); CODEX_OSS_BASE_URL: baseUrl,
OPENAI_API_KEY: "test-key",
};
const legacy = new CodexAppServerAgentSession(
{ provider: "codex", cwd, model: "mock-model" },
null,
logger,
async () =>
spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }),
{
environment,
customCodexConfig: {
model_provider: providerId,
model_providers: seedBuiltin
? {}
: {
[providerId]: {
name: "My Codex",
base_url: baseUrl,
wire_api: "responses",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
},
},
},
},
);
try {
await legacy.run("hello from the legacy provider");
} finally {
await legacy.close();
}
}
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: providerId, label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const session = await customCodex.createSession({ const session = await customCodex.createSession({
provider: "codex", provider: "codex",
cwd, cwd,
model: "mock-model", model: "mock-model",
}); });
try { try {
await session.run("hello from the custom provider"); await session.run("hello from the custom provider");
} finally { } finally {
await session.close(); await session.close();
} }
const customSessions = await customCodex.listImportableSessions({ cwd }); const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd }); const stockSessions = await stockCodex.listImportableSessions({ cwd });
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual(
"hello from the custom provider", seedLegacy
]); ? ["hello from the custom provider", "hello from the legacy provider"]
expect(stockSessions).toEqual([]); : ["hello from the custom provider"],
);
expect(stockSessions).toEqual([]);
const config = { provider: "codex", cwd, model: "mock-model" }; const config = { provider: "codex", cwd, model: "mock-model" };
const imported = await customCodex.importSession( const sessionToImport = seedLegacy
{ providerHandleId: customSessions[0].providerHandleId, cwd }, ? customSessions.find(
{ config, storedConfig: config }, (entry) => entry.firstPromptPreview === "hello from the legacy provider",
); )!
const requestsBeforeImportedTurn = requestCount(); : customSessions[0];
try { const imported = await customCodex.importSession(
await imported.session.run("hello again after import"); { providerHandleId: sessionToImport.providerHandleId, cwd },
} finally { { config, storedConfig: config },
await imported.session.close(); );
} const requestsBeforeImportedTurn = requestCount();
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); try {
}, 120_000); await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
},
120_000,
);
}); });
@@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; 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 type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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, terminalId: terminal.id,
headless: liveHeadless, headless: liveHeadless,
}); });
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); const initialSnapshot = waitForReplayEnd(client, terminal.id);
const liveSubscription = await subscribeTerminal(client, terminal.id); const liveSubscription = await subscribeTerminal(client, terminal.id);
await initialSnapshot; await initialSnapshot;
@@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac
text: BYTE_DONE_MARKER, text: BYTE_DONE_MARKER,
rows: BYTE_TEST_SIZE.rows, rows: BYTE_TEST_SIZE.rows,
}); });
const daemonSnapshot = await readTerminalSnapshot({ const daemonSnapshot = await readReplayViewport({
port: daemon.port, port: daemon.port,
terminalId: terminal.id, terminalId: terminal.id,
}); });
await liveHeadless.flush(); await liveHeadless.flush();
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot)); expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines);
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); 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, terminalId: terminal.id,
headless: restoreHeadless, headless: restoreHeadless,
}); });
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore"); const restoreFrame = waitForReplayEnd(client, terminal.id);
await subscribeTerminal(client, terminal.id, { await subscribeTerminal(client, terminal.id, {
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE }, restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
@@ -116,12 +115,10 @@ class ClientHeadlessTerminal {
} }
applyEvent(event: TerminalStreamEvent): void { applyEvent(event: TerminalStreamEvent): void {
if (event.type === "snapshot") { if (event.type === "replay") {
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true }); if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true });
return; if (event.replay.type === "data")
} this.enqueueText(this.decoder.decode(event.replay.data, { stream: true }));
if (event.type === "restore") {
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
return; return;
} }
this.enqueueText(this.decoder.decode(event.data, { stream: true })); this.enqueueText(this.decoder.decode(event.data, { stream: true }));
@@ -187,35 +184,32 @@ async function subscribeTerminal(
return response.subscription; return response.subscription;
} }
async function readTerminalSnapshot(input: { async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{
port: number; lines: string[];
terminalId: string; cursor: TerminalState["cursor"];
}): Promise<TerminalState> { }> {
const client = new DaemonClient({ const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" });
url: `ws://127.0.0.1:${input.port}/ws`, const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
appVersion: "0.1.96", const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless });
clientType: "mobile",
});
try { try {
await client.connect(); await client.connect();
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot"); const completed = waitForReplayEnd(client, input.terminalId);
await subscribeTerminal(client, input.terminalId); await subscribeTerminal(client, input.terminalId);
const event = await snapshot; await completed;
return event.state; await headless.flush();
return { lines: headless.visibleLines(), cursor: headless.cursor() };
} finally { } finally {
stop();
await client.close(); await client.close();
} }
} }
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>( async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise<void> {
client: DaemonClient, await client.waitForTerminalStreamEvent(
terminalId: string, (event) =>
type: TType, event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end",
): Promise<Extract<TerminalStreamEvent, { type: TType }>> { 10000,
return client.waitForTerminalStreamEvent( );
(event) => event.terminalId === terminalId && event.type === type,
10_000,
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
} }
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> { 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`); 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 { function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
const cells: TerminalCell[] = []; const cells: TerminalCell[] = [];
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) { for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
const cell = line?.getCell(col, reusableCell); const cell = line?.getCell(col, reusableCell);
cells.push({ char: cell?.getChars() || " " }); cells.push({ char: cell?.getChars() || " " });
} }
return terminalRowText(cells); return cells
.map((cell) => cell.char)
.join("")
.trimEnd();
} }
function packageLockStreamScript(): string { function packageLockStreamScript(): string {
@@ -1,3 +1,4 @@
import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "fs"; import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
@@ -10,7 +11,6 @@ import {
type WSOutboundMessage, type WSOutboundMessage,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { import {
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame, decodeTerminalStreamFrame,
encodeTerminalStreamFrame, encodeTerminalStreamFrame,
TerminalStreamOpcode, TerminalStreamOpcode,
@@ -80,6 +80,24 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); 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( async function waitForTerminalSnapshot(
client: DaemonClient, client: DaemonClient,
terminalId: string, terminalId: string,
@@ -92,16 +110,34 @@ async function waitForTerminalSnapshot(
reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`)); reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`));
}, timeout); }, timeout);
let terminal: HeadlessTerminal | null = null;
let writes = Promise.resolve();
const unsubscribe = client.onTerminalStreamEvent((event) => { const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminalId || event.type !== "snapshot") { if (event.terminalId !== terminalId || event.type !== "replay") return;
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);
}); });
}); });
} }
@@ -160,7 +196,7 @@ function toWsBuffer(raw: WebSocket.RawData): Buffer | null {
return null; return null;
} }
async function connectRawWebSocket(port: number): Promise<WebSocket> { async function connectRawWebSocket(port: number, chunkedReplay = false): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve()); ws.once("open", () => resolve());
@@ -180,6 +216,7 @@ async function connectRawWebSocket(port: number): Promise<WebSocket> {
clientId: `terminal-raw-${Date.now()}-${Math.random().toString(36).slice(2)}`, clientId: `terminal-raw-${Date.now()}-${Math.random().toString(36).slice(2)}`,
clientType: "cli", clientType: "cli",
protocolVersion: 1, protocolVersion: 1,
capabilities: { terminal_chunked_replay: chunkedReplay },
}), }),
); );
@@ -386,17 +423,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 { function getFrameText(frame: TerminalStreamFrame): string {
if (frame.opcode === TerminalStreamOpcode.Output) { if (frame.opcode === TerminalStreamOpcode.Output) {
return new TextDecoder().decode(frame.payload); 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) { if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload); return new TextDecoder().decode(frame.payload);
} }
@@ -446,7 +490,10 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput
if (!frame || frame.slot !== slot) { if (!frame || frame.slot !== slot) {
return; return;
} }
if (frame.opcode === TerminalStreamOpcode.Snapshot) { if (
frame.opcode === TerminalStreamOpcode.Restore ||
frame.opcode === TerminalStreamOpcode.ReplayEnd
) {
snapshotCount += 1; snapshotCount += 1;
return; return;
} }
@@ -634,7 +681,7 @@ async function measureRepeatCadenceForTerminal(input: {
const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`); const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`);
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -817,13 +864,7 @@ test("live terminal restore skips the initial snapshot", async () => {
expect(outputText).toContain("after-live"); expect(outputText).toContain("after-live");
expect(outputText).not.toContain("before-live"); expect(outputText).not.toContain("before-live");
expect( expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
outputFrames.some(
(frame) =>
frame.opcode === TerminalStreamOpcode.Snapshot ||
frame.opcode === TerminalStreamOpcode.Restore,
),
).toBe(false);
} finally { } finally {
await closeWebSocket(ws); await closeWebSocket(ws);
} }
@@ -901,15 +942,12 @@ test("propagates debounced terminal titles through list responses and snapshots"
} }
expect(listedTitle).toBe("Build Output"); expect(listedTitle).toBe("Build Output");
const snapshotPromise = waitForTerminalSnapshot( const attached = await ctx.client.subscribeTerminal(terminalId);
ctx.client, expect(attached.error).toBeNull();
terminalId, const afterAttach = await ctx.client.listTerminals(cwd);
(state) => state.title === "Build Output", 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 }); rmSync(cwd, { recursive: true, force: true });
}, 30000); }, 30000);
@@ -973,13 +1011,10 @@ test("subscribe response is sent before the initial snapshot frame", async () =>
return; return;
} }
const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); const frame = decodeTerminalStreamFrame(new Uint8Array(buffer));
if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { if (frame?.opcode !== TerminalStreamOpcode.Restore) {
return;
}
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state || !extractStateText(state).includes("hello-ordering")) {
return; return;
} }
if (!getFrameText(frame).includes("hello-ordering")) return;
events.push("snapshot"); events.push("snapshot");
maybeResolve(); maybeResolve();
}; };
@@ -1113,7 +1148,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first"); const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
firstWs, firstWs,
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore,
); );
sendRawTerminalInput(firstWs, firstSlot, "e"); sendRawTerminalInput(firstWs, firstSlot, "e");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
@@ -1140,7 +1175,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second"); const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
secondWs, secondWs,
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore,
); );
const replay = await waitForRawBinaryFrame( const replay = await waitForRawBinaryFrame(
secondWs, secondWs,
@@ -1172,7 +1207,7 @@ test("reconnected terminal streams do not replay input modes before they are ena
const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive"); const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
); );
await waitForNoRawBinaryFrame(ws, 300); await waitForNoRawBinaryFrame(ws, 300);
} finally { } finally {
@@ -1188,7 +1223,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
const ws = await connectRawWebSocket(ctx.daemon.port); const ws = await connectRawWebSocket(ctx.daemon.port);
await subscribeRawTerminal(ws, terminalId, "sub-raw"); 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 } }) const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } })
._socket; ._socket;
@@ -1203,12 +1238,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
await new Promise((resolve) => setTimeout(resolve, 750)); await new Promise((resolve) => setTimeout(resolve, 750));
const catchUpFramePromise = waitForRawBinaryFrame( const catchUpFramePromise = waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
15000, 15000,
); );
rawSocket!.resume(); rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise; const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore);
await closeWebSocket(ws); await closeWebSocket(ws);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
@@ -1260,7 +1295,7 @@ test("resize updates server dimensions without sending a live snapshot", async (
await new Promise((resolve) => setTimeout(resolve, 300)); 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?.rows).toBe(10);
expect(state?.cols).toBe(40); expect(state?.cols).toBe(40);
@@ -1277,7 +1312,7 @@ test("the latest resize from any attached client owns the PTY size", async () =>
); );
const readSize = () => { 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; return state ? { rows: state.rows, cols: state.cols } : null;
}; };
const expectSize = async (size: { rows: number; cols: number }) => { const expectSize = async (size: { rows: number; cols: number }) => {
@@ -1403,10 +1438,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a");
const initialSnapshot = await waitForRawBinaryFrame( const initialSnapshot = await waitForRawBinaryFrame(
firstSocket, firstSocket,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore);
ctx.client.sendTerminalInput(terminalId, { ctx.client.sendTerminalInput(terminalId, {
type: "input", type: "input",
@@ -1442,9 +1477,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
try { try {
await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b");
const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); 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(state).not.toBeNull();
expect(extractStateText(state!)).toContain("before-drop"); expect(extractStateText(state!)).toContain("before-drop");
expect(extractStateText(state!)).toContain("while-dead"); expect(extractStateText(state!)).toContain("while-dead");
@@ -1513,11 +1548,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl
await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); await subscribeRawTerminal(ws, terminalId, "sub-fidelity");
const snapshotFrame = await waitForRawBinaryFrame( const snapshotFrame = await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); const state = await decodeRestoreState(snapshotFrame, terminalId);
expect(state).not.toBeNull(); expect(state).not.toBeNull();
expect(state).toMatchObject({ expect(state).toMatchObject({
rows: 24, rows: 24,
@@ -1545,7 +1580,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
await subscribeRawTerminal(ws, terminalId, "sub-exit"); await subscribeRawTerminal(ws, terminalId, "sub-exit");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
@@ -1561,8 +1596,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
const exitMessage = await exitMessagePromise; const exitMessage = await exitMessagePromise;
expect(exitMessage.message.type).toBe("terminal_stream_exit"); expect(exitMessage.message.type).toBe("terminal_stream_exit");
const subscribeResult = await ctx.client.subscribeTerminal(terminalId); await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found");
expect(subscribeResult.error).toBe("Terminal not found");
await waitForNoRawBinaryFrame(ws, 750); await waitForNoRawBinaryFrame(ws, 750);
} finally { } finally {
await closeWebSocket(ws); await closeWebSocket(ws);
@@ -1580,7 +1614,7 @@ test("empty input frame does not crash the server", async () => {
const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
@@ -1619,7 +1653,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
await subscribeRawTerminal(ws, terminalId, "sub-large-output"); await subscribeRawTerminal(ws, terminalId, "sub-large-output");
await waitForRawBinaryFrame( await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000, 10000,
); );
@@ -1636,7 +1670,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
.reduce((sum, frame) => sum + frame.payload.byteLength, 0); .reduce((sum, frame) => sum + frame.payload.byteLength, 0);
return ( return (
outputBytes >= 1024 * 1024 || outputBytes >= 1024 * 1024 ||
collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)
); );
}, },
20000, 20000,
@@ -1646,7 +1680,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
for (const frame of frames) { for (const frame of frames) {
expect( expect(
frame.opcode === TerminalStreamOpcode.Output || frame.opcode === TerminalStreamOpcode.Output ||
frame.opcode === TerminalStreamOpcode.Snapshot, frame.opcode === TerminalStreamOpcode.Restore,
).toBe(true); ).toBe(true);
} }
@@ -1837,18 +1871,149 @@ describe("list terminals across directories", () => {
}); });
}); });
test("a paused terminal consumer catches up through a snapshot instead of retaining every output frame", async () => { test.each([3000, 12050])(
const { existsSync } = await import("node:fs"); "chunked SDK replay retains requested history for %s printed lines",
const cwd = tmpCwd(); async (count) => {
tempDirs.push(cwd); const cwd = tmpCwd();
const marker = path.join(cwd, "output-complete"); tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, { const created = await createTerminalInWorkspace(ctx.client, {
cwd, cwd,
options: { options: {
command: process.execPath, command: "/bin/sh",
args: [ args: [
"-e", "-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,
);
test.each([false, true])(
"a paused terminal consumer catches up through ANSI replay instead of retaining every output frame (chunked=%s)",
async (chunked) => {
const { existsSync } = await import("node:fs");
const cwd = tmpCwd();
tempDirs.push(cwd);
const marker = path.join(cwd, "output-complete");
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: process.execPath,
args: [
"-e",
`
const fs = require('node:fs'); const fs = require('node:fs');
process.stdin.setRawMode?.(true); process.stdin.setRawMode?.(true);
process.stdin.resume(); process.stdin.resume();
@@ -1862,32 +2027,37 @@ test("a paused terminal consumer catches up through a snapshot instead of retain
write(); write();
}); });
`, `,
], ],
}, },
}); });
const ws = await connectRawWebSocket(ctx.daemon.port); const ws = await connectRawWebSocket(ctx.daemon.port, chunked);
try {
const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal");
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
);
const capture = startTerminalOutputCapture(ws, slot);
try { try {
ws.pause(); const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal");
sendRawTerminalInput(ws, slot, "go\n"); await waitForRawBinaryFrame(
await waitForCondition(() => existsSync(marker), 20000); ws,
ws.resume(); (frame) =>
await waitForCondition(() => capture.snapshotCount > 0, 10000); frame.slot === slot &&
expect(ws.readyState).toBe(WebSocket.OPEN); frame.opcode ===
expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan( (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore),
32 * 1024 * 1024,
); );
const capture = startTerminalOutputCapture(ws, slot);
try {
ws.pause();
sendRawTerminalInput(ws, slot, "go\n");
await waitForCondition(() => existsSync(marker), 20000);
ws.resume();
await waitForCondition(() => capture.snapshotCount > 0, 10000);
expect(ws.readyState).toBe(WebSocket.OPEN);
expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan(
32 * 1024 * 1024,
);
} finally {
ws.resume();
capture.stop();
}
} finally { } finally {
ws.resume(); await closeWebSocket(ws);
capture.stop();
} }
} finally { },
await closeWebSocket(ws); 40000,
} );
}, 40000);
@@ -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 () => { test.each([false, true])(
const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); "terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)",
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); async (chunked) => {
const peers: SubscriptionPeer[] = []; const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
try { const admin = new DaemonClient({
await admin.connect(); url: `ws://127.0.0.1:${daemon.port}/ws`,
const workspace = ( appVersion: "0.8.0",
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))"],
}); });
expect(result.error).toBe(null); const peers: SubscriptionPeer[] = [];
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); try {
peers.push(idle); await admin.connect();
const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); const workspace = (
peers.push(peer); await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
for (const requestId of ["output-a", "output-b"]) { ).workspace!;
await peer.request({ const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
type: "subscribe_terminal_request", workspaceId: workspace.id,
requestId, command: process.execPath,
terminalId: result.terminal!.id, args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
}); });
} expect(result.error).toBe(null);
const replies = peer.frames.flatMap((frame) => const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
frame.type === "session" && terminal_chunked_replay: chunked,
frame.message.type === "subscribe_terminal_response" && });
frame.message.payload.error === null peers.push(idle);
? [frame.message.payload] const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
: [], terminal_chunked_replay: chunked,
); });
expect(replies).toHaveLength(2); peers.push(peer);
const ids = replies.map( for (const requestId of ["output-a", "output-b"]) {
(reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, await peer.request({
); type: "subscribe_terminal_request",
expect(ids.every((id) => typeof id === "string")).toBe(true); requestId,
expect(ids[0]).not.toBe(ids[1]); terminalId: result.terminal!.id,
expect(replies[0].slot).not.toBe(replies[1].slot); });
await expect }
.poll(() => const replies = peer.frames.flatMap((frame) =>
peer.binaryFrames frame.type === "session" &&
.map(decodeTerminalStreamFrame) frame.message.type === "subscribe_terminal_response" &&
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) frame.message.payload.error === null
.map((frame) => frame!.slot), ? [frame.message.payload]
) : [],
.toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); );
expect(idle.binaryFrames).toEqual([]); expect(replies).toHaveLength(2);
await peer.request({ const ids = replies.map(
type: "subscription.release.request", (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId,
requestId: "release-output-a", );
subscriptionId: ids[0], expect(ids.every((id) => typeof id === "string")).toBe(true);
}); expect(ids[0]).not.toBe(ids[1]);
peer.binaryFrames.length = 0; expect(replies[0].slot).not.toBe(replies[1].slot);
admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); await expect
await expect .poll(() =>
.poll(
() =>
peer.binaryFrames peer.binaryFrames
.map(decodeTerminalStreamFrame) .map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) .filter(
.map((frame) => Buffer.from(frame!.payload).toString()) (frame) =>
.join(""), frame?.opcode ===
{ timeout: 5000 }, (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore),
) )
.toContain("remaining-observer"); .map((frame) => frame!.slot),
expect( )
peer.binaryFrames .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot)));
.map(decodeTerminalStreamFrame) expect(idle.binaryFrames).toEqual([]);
.every((frame) => frame?.slot === replies[1].slot), await peer.request({
).toBe(true); type: "subscription.release.request",
expect(idle.binaryFrames).toEqual([]); requestId: "release-output-a",
await admin.killTerminal(result.terminal!.id); subscriptionId: ids[0],
} finally { });
for (const peer of peers) peer.close(); peer.binaryFrames.length = 0;
await admin.close(); admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" });
await daemon.close(); 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 () => { test("a modern connection and pure reads retain no client observation producers", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
@@ -68,13 +68,7 @@ function createStubTerminalManager(
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {}, onCommandFinished: () => () => {},
getState: () => ({ hasOutput: () => true,
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {}, kill: () => {},
onTitleChange: () => () => {}, onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }), getSize: () => ({ rows: 1, cols: 1 }),
+2 -2
View File
@@ -1130,11 +1130,11 @@ export class Session {
terminalManager, terminalManager,
emit: (msg) => this.emit(msg), emit: (msg) => this.emit(msg),
hasBinaryChannel: () => this.onBinaryMessage !== null, hasBinaryChannel: () => this.onBinaryMessage !== null,
supportsChunkedReplay: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
clientSupportsWrapReflow: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source),
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
}); });
this.agentDirectory = new AgentDirectory({ this.agentDirectory = new AgentDirectory({
@@ -1,3 +1,6 @@
import assert from "node:assert/strict";
import { createHook, executionAsyncResource } from "node:async_hooks";
import { MessagePort } from "node:worker_threads";
import path from "node:path"; import path from "node:path";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { setImmediate as nextTurn } from "node:timers/promises"; import { setImmediate as nextTurn } from "node:timers/promises";
@@ -57,19 +60,33 @@ const service = new WorkspaceReconciliationService({
// Initialize async filesystem support using the bundle, leaving every project // Initialize async filesystem support using the bundle, leaving every project
// and workspace path untouched until the first reconciliation. // and workspace path untouched until the first reconciliation.
await stat(process.argv[1]); await stat(process.argv[1]);
// Collect fixture allocations before the measured run; allocations made by
// reconciliation itself remain part of its budget.
assert.ok(global.gc, "Launch the probe with --expose-gc");
global.gc();
await nextTurn(); await nextTurn();
let workerMessages = 0;
const activity = createHook({
before() {
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
},
}).enable();
try { try {
const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () => const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () =>
service.runOnce(), service.runOnce(),
); );
activity.disable();
process.stdout.write( process.stdout.write(
JSON.stringify({ JSON.stringify({
blockingMs: measured.blockingMs, blockingMs: measured.blockingMs,
filesystemRequests: measured.filesystemRequests, filesystemRequests: measured.filesystemRequests,
yields: measured.yields,
workerMessages,
changesApplied: measured.result.changesApplied.length, changesApplied: measured.result.changesApplied.length,
allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null), allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null),
}), }),
); );
} finally { } finally {
activity.disable();
service.dispose(); service.dispose();
} }
@@ -1,15 +1,15 @@
import { execFile, execFileSync } from "node:child_process"; import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util"; 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 { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { build } from "esbuild"; import { build } from "esbuild";
import { import {
createTestRegistries, createTestRegistries,
createTestLogger, createTestLogger,
deferred, deferred,
} from "./test-fixtures/reconciliation-registries.js"; } from "./test-fixtures/reconciliation-registries.js";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type pino from "pino"; import type pino from "pino";
import { afterAll, beforeAll, afterEach, describe, expect, test } from "vitest"; import { afterAll, beforeAll, afterEach, describe, expect, test } from "vitest";
@@ -535,30 +535,54 @@ describe("WorkspaceReconciliationService", () => {
test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-")));
tempDirs.push(rootPath); tempDirs.push(rootPath);
// Unrelated runner work must not consume the reconciliation budget. // This CPU belongs to the runner, not to reconciliation. Keep it present
const unrelatedWork = new Promise<void>((resolve) => { // throughout the read so a later loop turn cannot contaminate the budget.
setImmediate(() => { let running = true;
const cpu = Reflect.get(process, "threadCpuUsage") as typeof process.cpuUsage; const unrelatedWork = () => {
const start = cpu(); const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
while (cpu(start).user + cpu(start).system < 80_000) {} process,
resolve(); "threadCpuUsage",
}); );
}); const start = threadCpuUsage();
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, rootPath], { let elapsed: NodeJS.CpuUsage;
timeout: 25_000, do {
}); elapsed = threadCpuUsage(start);
process.stderr.write(stderr); } while (elapsed.user + elapsed.system < 60_000);
const { changesApplied, blockingMs, filesystemRequests, allArchived } = JSON.parse(stdout) as { if (running) unrelated = setImmediate(unrelatedWork);
changesApplied: number;
blockingMs: number;
filesystemRequests: number;
allArchived: boolean;
}; };
await unrelatedWork; let unrelated = setImmediate(unrelatedWork);
expect(blockingMs).toBeLessThan(20); try {
expect(filesystemRequests).toBe(20001); const { stdout, stderr } = await promisify(execFile)(
expect(changesApplied).toBe(20000); process.execPath,
expect(allArchived).toBe(true); ["--expose-gc", probePath, rootPath],
{ timeout: 25_000 },
);
process.stderr.write(stderr);
const {
changesApplied,
blockingMs,
filesystemRequests,
yields,
allArchived,
workerMessages,
} = JSON.parse(stdout) as {
changesApplied: number;
blockingMs: number;
filesystemRequests: number;
yields: number;
allArchived: boolean;
workerMessages: number;
};
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBe(20001);
expect(changesApplied).toBe(20000);
expect(allArchived).toBe(true);
expect(yields).toBeGreaterThan(0);
expect(workerMessages).toBe(0);
} finally {
running = false;
clearImmediate(unrelated);
}
}, 30000); }, 30000);
test("preserves workspace archival that lands during boot reconciliation", async () => { test("preserves workspace archival that lands during boot reconciliation", async () => {
@@ -209,13 +209,8 @@ describe("runWorktreeAutoTerminals", () => {
getSize: () => ({ rows: 0, cols: 0 }), getSize: () => ({ rows: 0, cols: 0 }),
getTitle: () => undefined, getTitle: () => undefined,
getExitInfo: () => null, getExitInfo: () => null,
getState: () => ({ hasOutput: () => false,
rows: 0,
cols: 0,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {}, kill: () => {},
killAndWait: async () => {}, killAndWait: async () => {},
}; };
@@ -312,13 +307,8 @@ describe("runWorktreeAutoTerminals", () => {
} }
}; };
}, },
getState: () => ({ hasOutput: () => true,
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {}, kill: () => {},
onTitleChange: () => () => {}, onTitleChange: () => () => {},
onActivityChange: () => () => {}, onActivityChange: () => () => {},
@@ -269,9 +269,9 @@ export function buildWorktreeSetupDetail(input: {
} }
async function waitForTerminalBootstrapReadiness( async function waitForTerminalBootstrapReadiness(
terminal: Pick<TerminalSession, "getState" | "subscribe">, terminal: Pick<TerminalSession, "hasOutput" | "subscribe">,
): Promise<void> { ): Promise<void> {
if (terminalHasOutput(terminal.getState())) { if (terminal.hasOutput()) {
return; return;
} }
@@ -304,7 +304,7 @@ async function waitForTerminalBootstrapReadiness(
finish(); finish();
}); });
if (terminalHasOutput(terminal.getState())) { if (terminal.hasOutput()) {
finish(); finish();
return; 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: { export async function runWorktreeAutoTerminals(options: {
workspaceId: string; workspaceId: string;
worktree: WorktreeConfig; worktree: WorktreeConfig;
@@ -212,13 +212,7 @@ function createTerminalManagerStub(options?: {
id: `terminal-${terminals.length + 1}`, id: `terminal-${terminals.length + 1}`,
name: input.name ?? "Terminal", name: input.name ?? "Terminal",
cwd: input.cwd, cwd: input.cwd,
getState: () => ({ hasOutput: () => true,
rows: 1,
cols: 1,
scrollback: [[{ char: "$" }]],
grid: [],
cursor: { row: 0, col: 0 },
}),
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {}, onCommandFinished: () => () => {},
@@ -1,6 +1,5 @@
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import type { TerminalCell } from "@getpaseo/protocol/messages"; import type { IBuffer } from "@xterm/headless";
import type { TerminalSession } from "./terminal.js";
export interface CaptureTerminalLinesOptions { export interface CaptureTerminalLinesOptions {
start?: number; start?: number;
@@ -13,14 +12,6 @@ export interface CaptureTerminalLinesResult {
totalLines: number; 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( function resolveCaptureLineIndex(
lineNumber: number | undefined, lineNumber: number | undefined,
totalLines: number, totalLines: number,
@@ -46,14 +37,10 @@ function resolveCaptureLineIndex(
} }
export function captureTerminalLines( export function captureTerminalLines(
terminal: TerminalSession, buffer: IBuffer,
options: CaptureTerminalLinesOptions = {}, options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult { ): CaptureTerminalLinesResult {
const state = terminal.getState(); const totalLines = buffer.length;
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start"); const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end"); const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
@@ -64,8 +51,10 @@ export function captureTerminalLines(
}; };
} }
return { const lines: string[] = [];
lines: allLines.slice(startIndex, endIndex + 1), for (let row = startIndex; row <= endIndex; row++) {
totalLines, 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(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
expect(session.getTitle()).toBe("Existing title"); expect(session.getTitle()).toBe("Existing title");
expect(session.getState().title).toBe("Existing title");
expect(snapshots).toEqual([]); expect(snapshots).toEqual([]);
unsubscribe(); unsubscribe();
@@ -1,12 +1,16 @@
import {
replayTerminal,
type TerminalReplaySink,
type TerminalReplayMetadata,
type TerminalReplayOptions,
} from "./terminal-replay.js";
import { import {
createTerminal, createTerminal,
type LocalTerminalSession, type LocalTerminalSession,
type TerminalActivityTransition, type TerminalActivityTransition,
type TerminalSession, type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} from "./terminal.js"; } 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 { randomBytes, randomUUID } from "node:crypto";
import { resolve, sep } from "node:path"; import { resolve, sep } from "node:path";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
@@ -76,10 +80,11 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void; registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid";
getTerminal(id: string): TerminalSession | undefined; getTerminal(id: string): TerminalSession | undefined;
getTerminalState( replayTerminal(
id: string, id: string,
options?: TerminalStateSnapshotOptions, options: TerminalReplayOptions | undefined,
): Promise<TerminalStateSnapshot | null>; sink: TerminalReplaySink,
): Promise<TerminalReplayMetadata | null>;
setTerminalTitle(id: string, title: string): boolean; setTerminalTitle(id: string, title: string): boolean;
setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>; setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>;
clearTerminalAttention(id: string): 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. */ /** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */
export interface LocalTerminalManager extends TerminalManager { export interface LocalTerminalManager extends TerminalManager {
createTerminal(
options: Parameters<TerminalManager["createTerminal"]>[0],
): Promise<LocalTerminalSession>;
getTerminal(id: string): LocalTerminalSession | undefined;
getTerminalProcessId(id: string): number | null; getTerminalProcessId(id: string): number | null;
getTerminalActivityToken(id: string): string | null; getTerminalActivityToken(id: string): string | null;
} }
@@ -337,7 +346,7 @@ export function createTerminalManager(
activityUrl?: string | null; activityUrl?: string | null;
baseEnv?: Record<string, string>; baseEnv?: Record<string, string>;
paseoCliPath?: string | null; paseoCliPath?: string | null;
}): Promise<TerminalSession> { }): Promise<LocalTerminalSession> {
assertAbsolutePath(options.cwd); assertAbsolutePath(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? []; const terminals = terminalsByCwd.get(options.cwd) ?? [];
@@ -357,7 +366,7 @@ export function createTerminalManager(
...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}), ...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}),
}; };
terminalActivityTokenById.set(terminalId, activityToken); terminalActivityTokenById.set(terminalId, activityToken);
let session: TerminalSession; let session: LocalTerminalSession;
try { try {
session = registerSession( session = registerSession(
await createTerminal({ await createTerminal({
@@ -404,7 +413,7 @@ export function createTerminalManager(
return expected === token ? "valid" : "invalid"; return expected === token ? "valid" : "invalid";
}, },
getTerminal(id: string): TerminalSession | undefined { getTerminal(id: string): LocalTerminalSession | undefined {
return terminalsById.get(id); return terminalsById.get(id);
}, },
@@ -416,11 +425,9 @@ export function createTerminalManager(
return terminalActivityTokenById.get(id) ?? null; return terminalActivityTokenById.get(id) ?? null;
}, },
async getTerminalState( async replayTerminal(id, options, sink) {
id: string, const session = terminalsById.get(id);
options?: TerminalStateSnapshotOptions, return session ? replayTerminal(session, options, sink) : null;
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
}, },
setTerminalTitle(id: string, title: string): boolean { setTerminalTitle(id: string, title: string): boolean {
@@ -482,7 +489,7 @@ export function createTerminalManager(
totalLines: 0, totalLines: 0,
}; };
} }
return captureTerminalLines(session, options); return session.captureLines(options);
}, },
listDirectories(): string[] { 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, TerminalStreamOpcode,
decodeTerminalStreamFrame, decodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { import {
createTerminalRestoreDelivery,
encodeTerminalRestoreFrame, encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow, resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions, resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js"; } 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", () => { 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", () => { test("resolves bounded restore snapshot options", () => {
expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull(); expect(
expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined(); resolveTerminalRestoreSnapshotOptions({ restore: { mode: "live" }, chunked: false }),
expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({ ).toBeNull();
expect(
resolveTerminalRestoreSnapshotOptions({ restore: { mode: "full-snapshot" }, chunked: false }),
).toBeUndefined();
expect(
resolveTerminalRestoreSnapshotOptions({
restore: { mode: "visible-snapshot" },
chunked: false,
}),
).toEqual({
scrollbackLines: 200, scrollbackLines: 200,
}); });
expect( expect(
resolveTerminalRestoreSnapshotOptions({ resolveTerminalRestoreSnapshotOptions({
mode: "visible-snapshot", restore: { mode: "visible-snapshot", scrollbackLines: 999 },
scrollbackLines: 999, chunked: false,
}), }),
).toEqual({ scrollbackLines: 500 }); ).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({ expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({
mode: "visible-snapshot", mode: "visible-snapshot",
scrollbackLines: 500,
}); });
expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({ 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( const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({ encodeTerminalRestoreFrame({
slot: 4, slot: 4,
snapshot: { data: new TextEncoder().encode("restored"),
state: terminalState("restored"),
revision: 1,
},
}), }),
); );
@@ -73,3 +59,40 @@ describe("terminal restore policy", () => {
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored"); 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 type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import { import {
TerminalStreamOpcode, TerminalStreamOpcode,
encodeTerminalSnapshotPayload, type TerminalReplayMetadataSchema,
encodeTerminalStreamFrame, encodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024; 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 TerminalRestoreOptions = NonNullable<SubscribeTerminalRequest["restore"]>;
export type TerminalSubscriptionSnapshotMode = "state" | "ready";
export function resolveTerminalSubscriptionSnapshotMode(
restore: TerminalRestoreOptions | undefined,
): TerminalSubscriptionSnapshotMode {
return restore ? "ready" : "state";
}
export function resolveRestoreAfterOutputOverflow( export function resolveRestoreAfterOutputOverflow(
restore: TerminalRestoreOptions | undefined, restore: TerminalRestoreOptions | undefined,
): TerminalRestoreOptions | undefined { ): TerminalRestoreOptions {
if (restore?.mode === "live") { return {
return { mode: "visible-snapshot" }; mode: "visible-snapshot",
} scrollbackLines: Math.min(
return restore; restore?.scrollbackLines ?? MAX_VISIBLE_RESTORE_SCROLLBACK_LINES,
MAX_VISIBLE_RESTORE_SCROLLBACK_LINES,
),
};
} }
export function resolveTerminalRestoreSnapshotOptions( export function resolveTerminalRestoreSnapshotOptions({
restore: TerminalRestoreOptions, restore,
): TerminalStateSnapshotOptions | null | undefined { chunked,
}: {
restore: TerminalRestoreOptions;
chunked: boolean;
}): TerminalReplayOptions | null | undefined {
if (restore.mode === "live") { if (restore.mode === "live") {
return null; return null;
} }
if (restore.mode === "visible-snapshot") { if (restore.mode === "full-snapshot") {
return { return chunked ? { scrollbackLines: restore.scrollbackLines } : undefined;
scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines),
};
} }
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: { export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array {
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 {
return encodeTerminalStreamFrame({ return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore, opcode: TerminalStreamOpcode.Restore,
slot: input.slot, slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state), payload: input.data,
}); });
} }
function resolveVisibleRestoreScrollbackLines(value: number | undefined): number { /** The daemon's only old-app adapter. Capable delivery never assembles the replay. */
if (typeof value !== "number") { export function createTerminalRestoreDelivery(input: {
return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; slot: number;
} chunked: boolean;
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES); 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 { describe, expect, test, vi } from "vitest";
import pino from "pino"; import pino from "pino";
@@ -7,8 +10,7 @@ import {
decodeTerminalStreamFrame, decodeTerminalStreamFrame,
type TerminalStreamFrame, type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { ServerMessage, TerminalSession } from "./terminal.js";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { import {
TerminalSessionController, TerminalSessionController,
type TerminalSessionControllerOptions, type TerminalSessionControllerOptions,
@@ -19,8 +21,33 @@ import { isSameOrDescendantPath } from "../server/path-utils.js";
import { PluginSessionSocket } from "../server/plugins/session-socket.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js";
import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.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( function createController(
options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, options: TerminalSessionControllerOptions & {
emitBinary(frame: Uint8Array): void | Promise<void>;
},
) { ) {
const source = {}; const source = {};
const ownership = new SessionDelivery( const ownership = new SessionDelivery(
@@ -59,22 +86,6 @@ function deferred<T>(): {
return { promise, resolve, reject }; 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 { function createLogger(): pino.Logger {
return { return {
warn: vi.fn(), warn: vi.fn(),
@@ -85,7 +96,7 @@ function createLogger(): pino.Logger {
describe("terminal-session-controller restore", () => { describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => { test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null; let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>(); const snapshot = deferred<ReplayFixture | null>();
const binaryFrames: TerminalStreamFrame[] = []; const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = []; const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = { const terminal: TerminalSession = {
@@ -104,8 +115,7 @@ describe("terminal-session-controller restore", () => {
onTitleChange: () => vi.fn(), onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(), onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }), getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"), hasOutput: () => true,
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "\x1b[?1h\x1b[?2004h", getReplayPreamble: () => "\x1b[?1h\x1b[?2004h",
getTitle: () => undefined, getTitle: () => undefined,
getActivity: () => null, getActivity: () => null,
@@ -121,7 +131,7 @@ describe("terminal-session-controller restore", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(() => terminal), getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise), replayTerminal: replayFixture(() => snapshot.promise),
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
killTerminal: vi.fn(), killTerminal: vi.fn(),
@@ -157,10 +167,10 @@ describe("terminal-session-controller restore", () => {
}, },
}); });
await Promise.resolve(); await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1); expect(binaryFrames).toEqual([]);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); 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 snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
@@ -202,8 +212,7 @@ function listSession(input: {
onTitleChange: () => vi.fn(), onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(), onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }), getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""), hasOutput: () => true,
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
getReplayPreamble: () => "", getReplayPreamble: () => "",
getTitle: () => undefined, getTitle: () => undefined,
getActivity: () => null, getActivity: () => null,
@@ -236,7 +245,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(), clearTerminalAttention: vi.fn(),
@@ -311,7 +320,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(), clearTerminalAttention: vi.fn(),
@@ -362,7 +371,7 @@ describe("terminal-session-controller with terminals held by a newer Paseo", ()
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(), clearTerminalAttention: vi.fn(),
@@ -428,99 +437,6 @@ async function waitForCoalescerFlush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 30)); 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", () => { describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => { test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo"; const rootCwd = "/work/repo";
@@ -541,7 +457,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
killTerminal: vi.fn(), killTerminal: vi.fn(),
@@ -609,7 +525,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
killTerminal: vi.fn(), killTerminal: vi.fn(),
@@ -701,7 +617,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"), validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(), getTerminal: vi.fn(),
getTerminalState: vi.fn(), replayTerminal: async () => null,
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(), setTerminalActivity: vi.fn(),
killTerminal: vi.fn(), killTerminal: vi.fn(),
@@ -764,9 +680,17 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
}); });
describe("terminal-session-controller backpressure snapshot fallback", () => { 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; pushOutput: (data: string) => void;
frames: TerminalStreamFrame[]; frames: TerminalStreamFrame[];
replayOptions: Array<TerminalReplayOptions | undefined>;
ready: () => void;
}> { }> {
let terminalListener: ((message: ServerMessage) => void) | null = null; let terminalListener: ((message: ServerMessage) => void) | null = null;
const terminal: TerminalSession = { const terminal: TerminalSession = {
@@ -777,19 +701,15 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
send: vi.fn(), send: vi.fn(),
subscribe: (listener) => { subscribe: (listener) => {
terminalListener = listener; terminalListener = listener;
// Legacy stream: a snapshot arrives on subscribe (one Snapshot frame), // Readiness starts ANSI replay; later bytes use Output frames.
// after which output streams through the coalescer as Output frames. queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
queueMicrotask(() =>
listener({ type: "snapshot", state: terminalState("live"), revision: 1 }),
);
return vi.fn(); return vi.fn();
}, },
onExit: () => vi.fn(), onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(), onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(), onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }), getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("live"), hasOutput: () => true,
getStateSnapshot: () => ({ state: terminalState("live"), revision: 1 }),
getReplayPreamble: () => "", getReplayPreamble: () => "",
getTitle: () => undefined, getTitle: () => undefined,
setTitle: vi.fn(), setTitle: vi.fn(),
@@ -797,14 +717,16 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
kill: vi.fn(), kill: vi.fn(),
killAndWait: vi.fn(), killAndWait: vi.fn(),
}; };
const terminalManager = { const replayOptions: Array<TerminalReplayOptions | undefined> = [];
const terminalManager: TerminalManager = {
getTerminals: vi.fn(), getTerminals: vi.fn(),
createTerminal: vi.fn(), createTerminal: vi.fn(),
registerCwdEnv: vi.fn(), registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal), getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => replayTerminal: (id, options, sink) => {
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("live"), revision: 1 }), replayOptions.push(options);
), return replayFixture(async () => terminalReplay("live", 1))(id, options, sink);
},
setTerminalTitle: vi.fn(), setTerminalTitle: vi.fn(),
killTerminal: vi.fn(), killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(), killTerminalAndWait: vi.fn(),
@@ -812,16 +734,26 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
listDirectories: vi.fn(() => []), listDirectories: vi.fn(() => []),
killAll: vi.fn(), killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => 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 frames: TerminalStreamFrame[] = [];
const messages: SessionOutboundMessage[] = [];
const controller = createController({ const controller = createController({
terminalManager, terminalManager,
emit: vi.fn(), emit: (message) => {
messages.push(message);
},
supportsChunkedReplay: () => chunked,
emitBinary: (bytes) => { emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes); const frame = decodeTerminalStreamFrame(bytes);
if (frame) { if (frame) {
frames.push(frame); frames.push(frame);
if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.();
} }
}, },
hasBinaryChannel: () => true, hasBinaryChannel: () => true,
@@ -834,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
type: "subscribe_terminal_request", type: "subscribe_terminal_request",
terminalId: "term-1", terminalId: "term-1",
requestId: "req-1", requestId: "req-1",
restore,
}); });
await waitForCoalescerFlush(); await waitForCoalescerFlush();
// Drop the initial subscribe snapshot frame so each test only sees frames // 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; frames.length = 0;
return { return {
replayOptions,
ready: () => terminalListener?.({ type: "snapshotReady" }),
pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }), pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }),
frames, 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 () => { test("streams all output without a snapshot when the client keeps up", async () => {
const { pushOutput, frames } = await setup(() => 0); const { pushOutput, frames } = await setup(() => 0);
@@ -853,7 +858,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput(chunk); pushOutput(chunk);
await waitForCoalescerFlush(); 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); const outputFrames = frames.filter((frame) => frame.opcode === TerminalStreamOpcode.Output);
expect(outputFrames.length).toBeGreaterThan(0); expect(outputFrames.length).toBeGreaterThan(0);
const receivedBytes = outputFrames.reduce( const receivedBytes = outputFrames.reduce(
@@ -869,7 +874,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("y".repeat(300 * 1024)); pushOutput("y".repeat(300 * 1024));
await waitForCoalescerFlush(); 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 // After the snapshot the byte counter is reset, so a small follow-up chunk
// streams as Output rather than tripping the fallback again. // streams as Output rather than tripping the fallback again.
@@ -877,7 +882,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("z".repeat(1024)); pushOutput("z".repeat(1024));
await waitForCoalescerFlush(); 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); 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)); pushOutput("p".repeat(300 * 1024));
await waitForCoalescerFlush(); 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 () => { 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)); pushOutput("r".repeat(300 * 1024));
await waitForCoalescerFlush(); 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 outputs = new Set<(message: ServerMessage) => void>();
const exits = new Set<Parameters<TerminalSession["onExit"]>[0]>(); const exits = new Set<Parameters<TerminalSession["onExit"]>[0]>();
const terminal: TerminalSession = { const terminal: TerminalSession = {
@@ -934,8 +969,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
onTitleChange: () => () => {}, onTitleChange: () => () => {},
onActivityChange: () => () => {}, onActivityChange: () => () => {},
getSize: () => ({ rows: 1, cols: 80 }), getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("snapshot"), hasOutput: () => true,
getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }),
getReplayPreamble: () => "", getReplayPreamble: () => "",
getTitle: () => undefined, getTitle: () => undefined,
getActivity: () => null, getActivity: () => null,
@@ -948,7 +982,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
}; };
const manager: TerminalManager = { const manager: TerminalManager = {
getTerminal: () => terminal, getTerminal: () => terminal,
getTerminalState: readSnapshot, replayTerminal: replayFixture(readReplay),
getTerminals: async () => [terminal], getTerminals: async () => [terminal],
createTerminal: async () => terminal, createTerminal: async () => terminal,
registerCwdEnv() {}, registerCwdEnv() {},
@@ -1026,7 +1060,7 @@ describe("terminal output before natural exit", () => {
"flushes each observer's final bytes before exit (modern=%s)", "flushes each observer's final bytes before exit (modern=%s)",
async (modern) => { async (modern) => {
vi.useFakeTimers(); vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); const f = exitFixture(async () => terminalReplay("snapshot", 0));
try { try {
const source = {}; const source = {};
const owners = [ const owners = [
@@ -1048,7 +1082,7 @@ describe("terminal output before natural exit", () => {
row.message.payload.subscriptionId === owner.id)), row.message.payload.subscriptionId === owner.id)),
); );
expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot, TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output, TerminalStreamOpcode.Output,
TerminalStreamOpcode.Output, TerminalStreamOpcode.Output,
"terminal_stream_exit", "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 () => { test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>(); const snapshot = deferred<ReplayFixture | null>();
let reads = 0; let reads = 0;
const f = exitFixture(() => { const f = exitFixture(() => {
reads++; reads++;
@@ -1081,10 +1115,10 @@ describe("terminal output before natural exit", () => {
f.exit(); f.exit();
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); 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); await vi.advanceTimersByTimeAsync(0);
expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot, TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output, TerminalStreamOpcode.Output,
"terminal_stream_exit", "terminal_stream_exit",
]); ]);
@@ -1104,11 +1138,10 @@ describe("terminal output before natural exit", () => {
"finishes an in-flight backpressure snapshot (available=%s)", "finishes an in-flight backpressure snapshot (available=%s)",
async (available) => { async (available) => {
vi.useFakeTimers(); vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>(); const snapshot = deferred<ReplayFixture | null>();
let reads = 0; let reads = 0;
const f = exitFixture( const f = exitFixture(
async () => async () => (++reads === 1 ? terminalReplay("initial", 0) : snapshot.promise),
++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise,
true, true,
); );
try { try {
@@ -1122,10 +1155,7 @@ describe("terminal output before natural exit", () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false); expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false);
if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit")); if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit"));
else else snapshot.resolve(available ? terminalReplay("pressure-snapshot", 1) : null);
snapshot.resolve(
available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null,
);
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
const output = f.frames const output = f.frames
.filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) .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 () => { test("release during exit's pending snapshot suppresses late delivery", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>(); const snapshot = deferred<ReplayFixture | null>();
const f = exitFixture(() => snapshot.promise); const f = exitFixture(() => snapshot.promise);
const source = {}; const source = {};
try { try {
@@ -1158,7 +1188,7 @@ describe("terminal output before natural exit", () => {
subscriptionId: owner.id!, subscriptionId: owner.id!,
}; };
const release = f.delivery.request(source, request, () => f.delivery.release(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 release;
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); 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 () => { test("explicit release discards only that observer's trailing output", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); const f = exitFixture(async () => terminalReplay("snapshot", 0));
const source = {}; const source = {};
try { try {
const a = await f.subscribe(source, true, "a"); const a = await f.subscribe(source, true, "a");
@@ -1221,8 +1251,8 @@ describe("terminal snapshot failure", () => {
let reads = 0; let reads = 0;
const f = exitFixture(async () => { const f = exitFixture(async () => {
if (++reads === (phase === "backpressure" ? 2 : 1)) if (++reads === (phase === "backpressure" ? 2 : 1))
throw new Error("Terminal worker request timed out: getTerminalState"); throw new Error("Terminal worker request timed out: replayTerminal");
return { state: terminalState("healthy"), revision: 0 }; return terminalReplay("healthy", 0);
}, phase === "backpressure"); }, phase === "backpressure");
try { try {
const source = {}; const source = {};
@@ -1250,7 +1280,7 @@ describe("terminal snapshot failure", () => {
payload: { payload: {
terminalId: "exit-terminal", terminalId: "exit-terminal",
subscriptionId: failed.id, 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 () => { 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 f = exitFixture(() => snapshot.promise);
const source = {}; const source = {};
try { try {
@@ -1293,7 +1323,7 @@ describe("terminal snapshot failure", () => {
subscriptionId: owner.id!, subscriptionId: owner.id!,
}; };
const release = f.delivery.request(source, request, () => f.delivery.release(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; await release;
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
expect(f.delivery.registrationCount).toBe(0); expect(f.delivery.registrationCount).toBe(0);
@@ -1313,7 +1343,7 @@ test.each([false, true])(
let reads = 0; let reads = 0;
const f = exitFixture(async () => { const f = exitFixture(async () => {
if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable");
return { state: terminalState("recovered"), revision: 0 }; return terminalReplay("recovered", 0);
}, backpressure); }, backpressure);
try { try {
await f.subscribe({}, false, "legacy-recovery"); 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 }); for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 });
await vi.advanceTimersByTimeAsync(5); await vi.advanceTimersByTimeAsync(5);
expect(reads).toBe(backpressure ? 3 : 2); 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, true,
); );
f.exit(); 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 { import {
MAX_CLIENT_BUFFERED_BYTES, MAX_CLIENT_BUFFERED_BYTES,
MAX_TERMINAL_OUTPUT_FRAME_BYTES, MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame, createTerminalRestoreDelivery,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow, resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions, resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions, type TerminalRestoreOptions,
} from "./terminal-restore.js"; } from "./terminal-restore.js";
import type { TerminalReplayMetadata } from "./terminal-replay.js";
import type { TerminalSession } from "./terminal.js"; import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { applyTerminalSize } from "./terminal-size-ownership.js"; import { applyTerminalSize } from "./terminal-size-ownership.js";
@@ -68,31 +67,28 @@ interface ActiveTerminalStream {
needsSnapshot: boolean; needsSnapshot: boolean;
snapshotInFlight: boolean; snapshotInFlight: boolean;
retrySnapshotErrors: boolean; retrySnapshotErrors: boolean;
readyRevision?: number; // Readiness is independent of revision: reconnect adoption has no revision.
snapshotReady: boolean;
restore?: TerminalRestoreOptions; restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[]; bufferedOutputs: BufferedTerminalOutput[];
bufferedOutputBytes: number;
outputBytesSinceSnapshot: number; outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer; outputCoalescer: TerminalOutputCoalescer;
} }
interface SnapshotSendResult { type SnapshotSendResult =
shouldContinue: boolean; | { shouldContinue: false }
replayRevision?: number; | { shouldContinue: true; replay: TerminalReplayMetadata | null };
}
export interface TerminalSessionControllerOptions { export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void; emit: (msg: SessionOutboundMessage) => void;
hasBinaryChannel: () => boolean; hasBinaryChannel: () => boolean;
supportsChunkedReplay?: (source: object) => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger; sessionLogger: pino.Logger;
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>; listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>; 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 // 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 // keeps streaming; a backed-up client trips the snapshot path. Defaults to a
// constant 0 (no backpressure signal) so callers without a transport always // 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 sessionLogger: pino.Logger;
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>; private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>; private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
private readonly clientSupportsWrapReflow: (source: object) => boolean;
private readonly getClientBufferedAmount: (source: object) => number | null; private readonly getClientBufferedAmount: (source: object) => number | null;
private readonly subscribedDirectories = new Map<string, TerminalDirectorySubscription>(); private readonly subscribedDirectories = new Map<string, TerminalDirectorySubscription>();
@@ -153,7 +148,7 @@ export class TerminalSessionController {
private readonly activeStreams = new Map<number, ActiveTerminalStream>(); private readonly activeStreams = new Map<number, ActiveTerminalStream>();
private nextSlot = 0; private nextSlot = 0;
constructor(options: TerminalSessionControllerOptions) { constructor(private readonly options: TerminalSessionControllerOptions) {
this.terminalManager = options.terminalManager; this.terminalManager = options.terminalManager;
this.emit = options.emit; this.emit = options.emit;
this.hasBinaryChannel = options.hasBinaryChannel; this.hasBinaryChannel = options.hasBinaryChannel;
@@ -163,7 +158,6 @@ export class TerminalSessionController {
this.listTerminalWorkspaceRoots = this.listTerminalWorkspaceRoots =
options.listTerminalWorkspaceRoots ?? options.listTerminalWorkspaceRoots ??
(async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd)); (async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd));
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0); this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
} }
@@ -904,9 +898,10 @@ export class TerminalSessionController {
snapshotInFlight: false, snapshotInFlight: false,
retrySnapshotErrors: options.retrySnapshotErrors, retrySnapshotErrors: options.retrySnapshotErrors,
exiting: false, exiting: false,
readyRevision: undefined, snapshotReady: false,
restore: options?.restore, restore: options?.restore,
bufferedOutputs: [], bufferedOutputs: [],
bufferedOutputBytes: 0,
outputBytesSinceSnapshot: 0, outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({ outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout }, timers: { setTimeout, clearTimeout },
@@ -934,7 +929,6 @@ export class TerminalSessionController {
// The snapshot replaces this batch only after it succeeds. If the // The snapshot replaces this batch only after it succeeds. If the
// terminal disappears during the read, completion still owns these bytes. // terminal disappears during the read, completion still owns these bytes.
activeStream.snapshotOutput = payload; activeStream.snapshotOutput = payload;
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
activeStream.needsSnapshot = true; activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream); void this.trySendSnapshot(activeStream);
return; return;
@@ -952,35 +946,45 @@ export class TerminalSessionController {
this.activeStreams.set(slot, activeStream); this.activeStreams.set(slot, activeStream);
const unsubscribeOutput = terminal.subscribe( const unsubscribeOutput = terminal.subscribe((message) => {
(message) => { if (this.activeStreams.get(slot) !== activeStream) {
if (this.activeStreams.get(slot) !== activeStream) { return;
return; }
} if (message.type === "snapshotReady") {
if (message.type === "snapshot" || message.type === "snapshotReady") { activeStream.snapshotReady = true;
activeStream.readyRevision = message.revision; activeStream.outputCoalescer.flush();
activeStream.outputCoalescer.flush(); activeStream.needsSnapshot = true;
activeStream.needsSnapshot = true; void this.trySendSnapshot(activeStream);
void this.trySendSnapshot(activeStream); return;
return; }
} if (message.type === "titleChange") {
if (message.type === "titleChange") { return;
return; }
} if (message.data.length === 0) {
if (message.data.length === 0) { return;
return; }
} if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { activeStream.bufferedOutputBytes += Buffer.byteLength(message.data);
activeStream.bufferedOutputs.push({ if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) {
data: message.data, activeStream.owner.emit({
revision: message.revision, 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; return;
} }
activeStream.outputCoalescer.handle(message.data); activeStream.bufferedOutputs.push({
}, data: message.data,
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, revision: message.revision,
); });
return;
}
activeStream.outputCoalescer.handle(message.data);
});
const unsubscribeExit = terminal.onExit(() => const unsubscribeExit = terminal.onExit(() =>
this.detachStream(terminal.id, { emitExit: true }), this.detachStream(terminal.id, { emitExit: true }),
); );
@@ -1017,22 +1021,27 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true }); this.detachStream(activeStream.terminalId, { emitExit: true });
return; return;
} }
if (activeStream.restore && activeStream.readyRevision === undefined) { if (activeStream.restore && !activeStream.snapshotReady) {
return; return;
} }
activeStream.outputCoalescer.flush(); activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true; activeStream.snapshotInFlight = true;
try { 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 const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore) ? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager); : await this.emitRestoreSnapshot(activeStream, terminalManager, { mode: "full-snapshot" });
if (!snapshotResult.shouldContinue) { if (!snapshotResult.shouldContinue) {
return; return;
} }
activeStream.snapshotOutput = undefined; activeStream.snapshotOutput = undefined;
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replay);
activeStream.needsSnapshot = false; activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0; activeStream.outputBytesSinceSnapshot = 0;
} catch (error) { } 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( private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream, activeStream: ActiveTerminalStream,
terminalManager: TerminalManager, terminalManager: TerminalManager,
restore: TerminalRestoreOptions, restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> { ): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore); const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true;
const snapshotOptions = resolveTerminalRestoreSnapshotOptions({ restore, chunked });
if (snapshotOptions === null) { if (snapshotOptions === null) {
return { shouldContinue: true }; return { shouldContinue: true, replay: null };
} }
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { const deliver = createTerminalRestoreDelivery({
...snapshotOptions, slot: activeStream.slot,
includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), 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) { if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false }; return { shouldContinue: false };
} }
@@ -1119,38 +1112,31 @@ export class TerminalSessionController {
return { shouldContinue: false }; 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 // 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. // coalescer's trailing path so it doesn't flush back-to-back with it.
activeStream.outputCoalescer.markFlushed(); activeStream.outputCoalescer.markFlushed();
return { shouldContinue: true, replayRevision: snapshot.revision }; return { shouldContinue: true, replay: snapshot };
} }
private replayTerminalOutputAfterSnapshot( private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream, activeStream: ActiveTerminalStream,
terminal: TerminalSession, terminal: TerminalSession,
replayRevision: number | undefined, replay: TerminalReplayMetadata | null,
): void { ): 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) { if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble); activeStream.outputCoalescer.handle(replayPreamble);
} }
activeStream.bufferedOutputBytes = 0;
const bufferedOutputs = activeStream.bufferedOutputs.splice( const bufferedOutputs = activeStream.bufferedOutputs.splice(
0, 0,
activeStream.bufferedOutputs.length, activeStream.bufferedOutputs.length,
); );
for (const output of bufferedOutputs) { for (const output of bufferedOutputs) {
if ( if (replay !== null && output.revision !== undefined && output.revision <= replay.revision) {
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
continue; continue;
} }
activeStream.outputCoalescer.handle(output.data); activeStream.outputCoalescer.handle(output.data);
@@ -72,3 +72,46 @@ describe("terminal worker authentication", () => {
await waitUntil(() => !existsSync(recordPath), 10_000); 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 { export interface FramedSocket {
readonly socket: Socket; readonly socket: Socket;
send(message: unknown, callback?: (error: Error | null) => void): boolean; send(message: unknown, callback?: (error: Error | null) => void): boolean;
sendAndWait(message: unknown): Promise<void>;
close(): void; close(): void;
} }
@@ -91,6 +92,17 @@ export function attachFramedSocket(
} }
return socket.write(frame, (error) => callback?.(error ?? null)); 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() { close() {
socket.end(() => socket.destroy()); socket.end(() => socket.destroy());
}, },
@@ -5,7 +5,7 @@ import type {
TerminalsChangedListener, TerminalsChangedListener,
TerminalWorkspaceContributionChangedListener, TerminalWorkspaceContributionChangedListener,
} from "./terminal-manager.js"; } 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 { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js";
import type { DaemonTerminalManager } from "./worker-terminal-manager.js"; import type { DaemonTerminalManager } from "./worker-terminal-manager.js";
@@ -217,8 +217,8 @@ export function createTerminalWorkerPool(
return undefined; return undefined;
}, },
async getTerminalState(id, stateOptions): Promise<TerminalStateSnapshot | null> { async replayTerminal(id, replayOptions, sink) {
return routed(id).getTerminalState(id, stateOptions); return routed(id).replayTerminal(id, replayOptions, sink);
}, },
setTerminalTitle(id, title) { setTerminalTitle(id, title) {
@@ -1,3 +1,4 @@
import { createTerminalReplayController } from "./terminal-replay.js";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto"; import { randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs"; 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 { currentBootId } from "../server/pid-lock.js";
import { PRIVATE_FILE_MODE } from "../server/private-files.js"; import { PRIVATE_FILE_MODE } from "../server/private-files.js";
import { createTerminalManager } from "./terminal-manager.js"; import { createTerminalManager } from "./terminal-manager.js";
import { captureTerminalLines } from "./terminal-capture.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import type { import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js";
TerminalExitInfo,
TerminalSession,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js";
import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js";
import { import {
@@ -30,7 +26,6 @@ import {
type TerminalWorkerHelloAck, type TerminalWorkerHelloAck,
type TerminalWorkerListResult, type TerminalWorkerListResult,
type TerminalWorkerRequest, type TerminalWorkerRequest,
type TerminalWorkerStateResult,
type TerminalWorkerToParentMessage, type TerminalWorkerToParentMessage,
type WorkerTerminalInfo, type WorkerTerminalInfo,
} from "./terminal-worker-protocol.js"; } from "./terminal-worker-protocol.js";
@@ -177,7 +172,7 @@ function runWorker(argv: string[]): void {
const protocolVersion = reportedProtocolVersion(); const protocolVersion = reportedProtocolVersion();
const manager = createTerminalManager(); const manager = createTerminalManager();
const inspector = createTerminalWorkerProcessInspector(); const inspector = createTerminalWorkerProcessInspector();
const sessionsById = new Map<string, TerminalSession>(); const sessionsById = new Map<string, LocalTerminalSession>();
const unsubscribeByTerminalId = new Map<string, Array<() => void>>(); const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
const outputCoalescerByTerminalId = new Map<string, TerminalOutputCoalescer>(); const outputCoalescerByTerminalId = new Map<string, TerminalOutputCoalescer>();
// A terminal is published (terminalCreated) only once its program is recorded. An exit that // 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); reportInFlightTerminalCreateFailure(error);
}); });
const replayController = createTerminalReplayController({
getSession: (id) => sessionsById.get(id),
flushOutput: (id) => outputCoalescerByTerminalId.get(id)?.flush(),
});
function sendToController(message: TerminalWorkerToParentMessage): void { function sendToController(message: TerminalWorkerToParentMessage): void {
// With no controller the event is dropped; the headless terminal keeps the screen, and the replayController.send(message);
// next daemon reads it through listTerminals.
controller?.send(message);
} }
function persistRecord(): void { function persistRecord(): void {
@@ -259,17 +256,11 @@ function runWorker(argv: string[]): void {
} }
} }
function buildTerminalStateResult( function terminalMetadata(session: LocalTerminalSession) {
session: TerminalSession | undefined, return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() };
options?: TerminalStateSnapshotOptions,
): TerminalWorkerStateResult {
if (!session) {
return null;
}
return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() };
} }
function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo {
return { return {
id: session.id, id: session.id,
name: session.name, 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); clearTerminalSubscriptions(session.id);
// Coalesce pty output chunks into a single frame per ~5ms window so a burst of small // 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); outputCoalescerByTerminalId.set(session.id, outputCoalescer);
const unsubscribeMessage = session.subscribe( const unsubscribeMessage = session.subscribe((message) => {
(message) => { if (message.type === "output") {
if (message.type === "output") { pendingOutputRevision = message.revision;
pendingOutputRevision = message.revision; outputCoalescer.handle(message.data);
outputCoalescer.handle(message.data); return;
return; }
} // Non-output messages (snapshotReady/titleChange) must not jump
// Non-output messages (snapshot/snapshotReady/titleChange) must not jump // ahead of buffered output: flush the coalescer first, then forward.
// ahead of buffered output: flush the coalescer first, then forward. outputCoalescer.flush();
outputCoalescer.flush(); sendToController({
sendToController({ type: "terminalMessage",
type: "terminalMessage", terminalId: session.id,
terminalId: session.id, message,
message, replayPreamble: session.getReplayPreamble(),
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 unsubscribeExit = session.onExit((info) => { const unsubscribeExit = session.onExit((info) => {
outputCoalescer.flush(); outputCoalescer.flush();
clearTerminalSubscriptions(session.id); 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 // 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 // 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. // 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; if (session.getExitInfo()) return;
// The manager drops a session as soon as its program exits, so only a live one has a pid. // 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); const pid = manager.getTerminalProcessId(session.id);
@@ -476,11 +461,19 @@ function runWorker(argv: string[]): void {
await session.killAndWait().catch(() => undefined); await session.killAndWait().catch(() => undefined);
throw error; throw error;
} }
const initialSnapshot = session.getStateSnapshot(); const state = terminalMetadata(session);
sendToController({ sendToController({
type: "terminalCreated", type: "terminalCreated",
terminal: toTerminalInfo(session), 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({ sendToController({
type: "response", type: "response",
@@ -488,7 +481,7 @@ function runWorker(argv: string[]): void {
ok: true, ok: true,
result: { result: {
terminal: toTerminalInfo(session), terminal: toTerminalInfo(session),
state: initialSnapshot.state, state,
}, },
}); });
const heldExit = exitsHeldDuringCreation.get(session.id); const heldExit = exitsHeldDuringCreation.get(session.id);
@@ -513,7 +506,7 @@ function runWorker(argv: string[]): void {
outputCoalescerByTerminalId.get(session.id)?.flush(); outputCoalescerByTerminalId.get(session.id)?.flush();
return { return {
terminal: toTerminalInfo(session), terminal: toTerminalInfo(session),
state: session.getStateSnapshot().state, state: terminalMetadata(session),
activityToken: manager.getTerminalActivityToken(session.id), activityToken: manager.getTerminalActivityToken(session.id),
replayPreamble: session.getReplayPreamble(), replayPreamble: session.getReplayPreamble(),
}; };
@@ -559,28 +552,15 @@ function runWorker(argv: string[]): void {
return; return;
} }
case "getTerminalState": { case "replayTerminal": {
// Flush buffered output before snapshotting: the headless state already includes it, await replayController.replay(message);
// 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,
),
});
return; return;
} }
case "captureTerminal": { case "captureTerminal": {
const session = manager.getTerminal(message.terminalId); const session = manager.getTerminal(message.terminalId);
const result = session const result = session
? captureTerminalLines(session, { ? session.captureLines({
start: message.start, start: message.start,
end: message.end, end: message.end,
stripAnsi: message.stripAnsi, stripAnsi: message.stripAnsi,
@@ -659,10 +639,9 @@ function runWorker(argv: string[]): void {
} }
authenticated = true; authenticated = true;
// One controller at a time: a new authenticated connection replaces the old one. // One controller at a time: a new authenticated connection replaces the old one.
const previous = controller;
controller = framed; controller = framed;
replayController.setConnection(framed);
everControlled = true; everControlled = true;
previous?.socket.destroy();
const ack: TerminalWorkerHelloAck = { const ack: TerminalWorkerHelloAck = {
type: "helloAck", type: "helloAck",
pid: process.pid, pid: process.pid,
@@ -688,6 +667,7 @@ function runWorker(argv: string[]): void {
clearTimeout(helloTimer); clearTimeout(helloTimer);
if (controller === framed) { if (controller === framed) {
controller = null; controller = null;
replayController.setConnection(null);
exitIfIdle(); exitIfIdle();
} }
}); });
@@ -2,18 +2,24 @@ import { describe, expect, it } from "vitest";
import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
describe("classifyTerminalWorkerProtocol", () => { 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([ it.each([
[5, "current"], [6, "current"],
[4, "previous"], [5, "previous"],
[3, "obsolete"], [4, "obsolete"],
[-1, "obsolete"], [-1, "obsolete"],
[6, "newer"], [7, "newer"],
// A version this daemon cannot read comes from a Paseo it does not know. // A version this daemon cannot read comes from a Paseo it does not know.
["6", "newer"], ["6", "newer"],
[4.5, "newer"], [4.5, "newer"],
[Number.NaN, "newer"], [Number.NaN, "newer"],
[undefined, "newer"], [undefined, "newer"],
])("a worker reporting %s to a daemon at 5 is %s", (reported, fit) => { ])("a worker reporting %s to a daemon at 6 is %s", (reported, fit) => {
expect(classifyTerminalWorkerProtocol(reported, 5)).toBe(fit); expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit);
}); });
}); });
@@ -1,11 +1,11 @@
import type { import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
TerminalExitInfo, import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
ServerMessage, export interface WorkerTerminalState {
ClientMessage, rows: number;
TerminalStateSnapshot, cols: number;
TerminalStateSnapshotOptions, hasOutput: boolean;
} from "./terminal.js"; title?: string;
import type { TerminalState } from "@getpaseo/protocol/messages"; }
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; 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 // `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 // terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
// worker running untouched. See docs/terminal-worker.md. // 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 // Test-only: the protocol version a worker reports, so tests can stand up older and newer
// workers from one build. // 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. // 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 (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer";
if (reported === current) return "current"; 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"; return reported < current ? "obsolete" : "newer";
} }
@@ -77,7 +78,7 @@ export interface TerminalWorkerHelloAck {
export interface WorkerListedTerminal { export interface WorkerListedTerminal {
terminal: WorkerTerminalInfo; terminal: WorkerTerminalInfo;
state: TerminalState; state: WorkerTerminalState;
activityToken: string | null; activityToken: string | null;
replayPreamble: string; replayPreamble: string;
} }
@@ -122,10 +123,10 @@ export type TerminalWorkerRequest =
options?: WorkerKillAndWaitOptions; options?: WorkerKillAndWaitOptions;
} }
| { | {
type: "getTerminalState"; type: "replayTerminal";
requestId: string; requestId: string;
terminalId: string; terminalId: string;
options?: TerminalStateSnapshotOptions; options?: TerminalReplayOptions;
} }
| { | {
type: "captureTerminal"; type: "captureTerminal";
@@ -166,10 +167,11 @@ export type TerminalWorkerResponse =
}; };
export type TerminalWorkerEvent = export type TerminalWorkerEvent =
| { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent }
| { | {
type: "terminalCreated"; type: "terminalCreated";
terminal: WorkerTerminalInfo; terminal: WorkerTerminalInfo;
state: TerminalState; state: WorkerTerminalState;
} }
| { | {
type: "terminalMessage"; type: "terminalMessage";
@@ -206,6 +208,3 @@ export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWor
export type TerminalWorkerListResult = WorkerListedTerminal[]; export type TerminalWorkerListResult = WorkerListedTerminal[];
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult; 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 // POSIX-only: node-pty + POSIX shell assertions
/* eslint-disable max-nested-callbacks */ /* eslint-disable max-nested-callbacks */
import { describe, it, expect, afterEach } from "vitest"; import { describe, it, expect, afterEach } from "vitest";
@@ -6,7 +8,7 @@ import {
buildTerminalEnvironment, buildTerminalEnvironment,
createTerminal, createTerminal,
resolveZshShellIntegrationDir, resolveZshShellIntegrationDir,
type TerminalSession, type LocalTerminalSession as TerminalSession,
} from "./terminal.js"; } from "./terminal.js";
import { import {
chmodSync, chmodSync,
@@ -28,7 +30,7 @@ if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; 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 { function rowToText(row: TerminalRow): string {
return row return row
@@ -38,12 +40,12 @@ function rowToText(row: TerminalRow): string {
} }
// Extract text from a single row // 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]); return rowToText(state.grid[rowIndex]);
} }
// Extract all visible lines as array (trimmed, empty lines included) // 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); return state.grid.map(rowToText);
} }
@@ -55,7 +57,7 @@ async function waitForLines(
): Promise<void> { ): Promise<void> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
const lines = getLines(session.getState()); const lines = getLines(await readTerminalState(session));
let matches = true; let matches = true;
for (let i = 0; i < expectedLines.length; i++) { for (let i = 0; i < expectedLines.length; i++) {
if (lines[i] !== expectedLines[i]) { if (lines[i] !== expectedLines[i]) {
@@ -68,7 +70,7 @@ async function waitForLines(
} }
await new Promise((resolve) => setTimeout(resolve, 50)); 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( throw new Error(
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, `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( async function waitForState(
session: TerminalSession, session: TerminalSession,
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean, predicate: (state: TerminalState) => boolean,
timeoutMs = 5000, timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> { ): Promise<TerminalState> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
const state = session.getState(); const state = await readTerminalState(session);
if (predicate(state)) { if (predicate(state)) {
return state; return state;
} }
@@ -232,19 +234,19 @@ function isOsc11OkLine(line: string): boolean {
return line.startsWith("OSC11_OK:"); return line.startsWith("OSC11_OK:");
} }
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean { function hasDaOkLine(state: TerminalState): boolean {
return getLines(state).some(isDaOkLine); return getLines(state).some(isDaOkLine);
} }
function hasDsrOkLine(state: ReturnType<TerminalSession["getState"]>): boolean { function hasDsrOkLine(state: TerminalState): boolean {
return getLines(state).some(isDsrOkLine); return getLines(state).some(isDsrOkLine);
} }
function hasOsc11OkLine(state: ReturnType<TerminalSession["getState"]>): boolean { function hasOsc11OkLine(state: TerminalState): boolean {
return getLines(state).some(isOsc11OkLine); return getLines(state).some(isOsc11OkLine);
} }
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean { function lastNonEmptyLineIsPrompt(state: TerminalState): boolean {
const last = const last =
getLines(state) getLines(state)
.toReversed() .toReversed()
@@ -322,7 +324,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
// Line 2: "$" // Line 2: "$"
await waitForLines(session, ["$ echo hello", "hello", "$"]); 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, 0)).toBe("$ echo hello");
expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 1)).toBe("hello");
expect(getRowText(state, 2)).toBe("$"); expect(getRowText(state, 2)).toBe("$");
@@ -346,7 +348,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
session.send({ type: "input", data: "echo second\r" }); session.send({ type: "input", data: "echo second\r" });
await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); 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, 0)).toBe("$ echo first");
expect(getRowText(state, 1)).toBe("first"); expect(getRowText(state, 1)).toBe("first");
expect(getRowText(state, 2)).toBe("$ echo second"); expect(getRowText(state, 2)).toBe("$ echo second");
@@ -370,7 +372,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$ pwd", "/tmp", "$"]); await waitForLines(session, ["$ pwd", "/tmp", "$"]);
const state = session.getState(); const state = await readTerminalState(session);
expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 0)).toBe("$ pwd");
expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 1)).toBe("/tmp");
expect(getRowText(state, 2)).toBe("$"); expect(getRowText(state, 2)).toBe("$");
@@ -431,7 +433,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
expect(seenTitles).toContain("npm run dev"); expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("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(); unsubscribeTitle();
}); });
@@ -457,7 +459,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
expect(seenTitles).toContain("Build Log"); expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log"); expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log"); expect(session.getTitle()).toBe("Build Log");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -478,7 +480,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
expect(session.getTitle()).toBe("typecheck"); 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 () => { 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); await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([7]); 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(); unsubscribeCommandFinished();
}); });
@@ -541,7 +543,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForState(session, () => commandCompletions.length === 1); await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([3]); 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(); unsubscribeCommandFinished();
}); });
@@ -696,7 +698,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForState(session, () => commandCompletions.includes(1)); await waitForState(session, () => commandCompletions.includes(1));
expect(commandCompletions).toEqual([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(); unsubscribeCommandFinished();
}); });
@@ -720,7 +722,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
const state = session.getState(); const state = await readTerminalState(session);
const outputRow = state.grid[1]; const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("R"); 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$"]); 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]; const outputRow = state.grid[1];
// Check O cell // 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$"]); 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]; const outputRow = state.grid[1];
// Check R cell // Check R cell
@@ -805,7 +807,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
const state = session.getState(); const state = await readTerminalState(session);
const outputRow = state.grid[1]; const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("B"); expect(outputRow[0].char).toBe("B");
@@ -815,7 +817,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
}); });
describe("subscribe", () => { describe("subscribe", () => {
it("receives a snapshot on initial subscription", async () => { it("receives readiness on initial subscription", async () => {
const session = trackSession( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", workspaceId: "ws-test",
@@ -831,7 +833,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
expect(messages.length).toBeGreaterThan(0); expect(messages.length).toBeGreaterThan(0);
expect(messages[0].type).toBe("snapshot"); expect(messages[0].type).toBe("snapshotReady");
unsubscribe(); unsubscribe();
}); });
@@ -861,12 +863,14 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$ echo test", "test", "$"]); await waitForLines(session, ["$ echo test", "test", "$"]);
expect(messages.some((message) => message.type === "output")).toBe(true); 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(); 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( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", workspaceId: "ws-test",
@@ -889,13 +893,15 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
session.send({ type: "resize", rows: 30, cols: 100 }); session.send({ type: "resize", rows: 30, cols: 100 });
await new Promise((resolve) => setTimeout(resolve, 200)); 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 }); expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
unsubscribe(); 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( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", workspaceId: "ws-test",
@@ -912,7 +918,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
return; return;
} }
unsubscribe(); unsubscribe();
const stateText = getLines(session.getState()).join("\n"); const stateText = session.captureLines().lines.join("\n");
resolve(stateText.includes("state-after-output")); 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` }); session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` });
await waitForState(session, hasDaOkLine); 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$/); 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` }); session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` });
await waitForState(session, hasDsrOkLine); 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+$/); 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` }); session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` });
await waitForState(session, hasDsrOkLine); 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+$/); 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` }); session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` });
await waitForState(session, hasDsrOkLine); 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"); 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` }); session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` });
await waitForState(session, hasOsc11OkLine); 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\\"); expect(ack).toBe("OSC11_OK:ESC]11;rgb:0b0b/0b0b/0b0bESC\\");
}); });
}); });
@@ -1097,7 +1103,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
unsubscribe(); 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( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", workspaceId: "ws-test",
@@ -1121,26 +1127,24 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
"$", "$",
]); ]);
let snapshotText = ""; let ready!: () => void;
const unsubscribe = session.subscribe((message) => { const readiness = new Promise<void>((resolve) => {
if (message.type !== "snapshot") { ready = resolve;
return;
}
snapshotText = [...message.state.scrollback, ...message.state.grid]
.map(rowToText)
.join("\n");
}); });
const unsubscribe = session.subscribe((message) => {
await new Promise((resolve) => setTimeout(resolve, 100)); if (message.type === "snapshotReady") ready();
});
expect(snapshotText).toContain("before-detach"); await readiness;
expect(snapshotText).toContain("after-detach"); 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(); unsubscribe();
}); });
}); });
describe("getState", () => { describe("ANSI replay observation", () => {
it("returns current terminal state with grid", async () => { it("reconstructs current terminal state from ANSI", async () => {
const session = trackSession( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", 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.rows).toBe(24);
expect(state.cols).toBe(80); expect(state.cols).toBe(80);
@@ -1179,19 +1183,16 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$"]); await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" });
const state = await waitForState( const deadline = Date.now() + 5000;
session, while (
(current) => !new TextDecoder().decode(session.serializeReplay().data).includes("\x1b[?25l\x1b[2 q")
current.cursor.style === "block" && ) {
current.cursor.blink === false && if (Date.now() > deadline) throw new Error("cursor presentation did not reach replay");
current.cursor.hidden === true, 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 () => { it("grid cells have char and color attributes", async () => {
@@ -1206,7 +1207,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
await waitForLines(session, ["$"]); await waitForLines(session, ["$"]);
const state = session.getState(); const state = await readTerminalState(session);
// First cell should be "$" // First cell should be "$"
expect(state.grid[0][0].char).toBe("$"); expect(state.grid[0][0].char).toBe("$");
expect(state.grid[0][0]).toHaveProperty("fg"); 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, $ // The visible area will show something like: 17, 18, 19, 20, $
await waitForLines(session, ["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 // Scrollback should contain the earlier output
expect(state.scrollback.length).toBeGreaterThan(0); expect(state.scrollback.length).toBeGreaterThan(0);
@@ -1269,7 +1270,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
session.kill(); session.kill();
// Should not throw when trying to get state after kill // Should not throw when trying to get state after kill
const state = session.getState(); const state = await readTerminalState(session);
expect(state).toBeDefined(); 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 { describe, it, expect, afterEach, vi } from "vitest";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import { import {
@@ -10,7 +14,7 @@ import {
humanizeProcessTitle, humanizeProcessTitle,
normalizeProcessTitle, normalizeProcessTitle,
resolveZshShellIntegrationDir, resolveZshShellIntegrationDir,
type TerminalSession, type LocalTerminalSession as TerminalSession,
} from "./terminal.js"; } from "./terminal.js";
import { import {
chmodSync, chmodSync,
@@ -26,7 +30,6 @@ import {
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import * as pty from "node-pty"; import * as pty from "node-pty";
import xterm from "@xterm/headless"; import xterm from "@xterm/headless";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { delimiter, join } from "node:path"; import { delimiter, join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; 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"); const hasZsh = existsSync("/bin/zsh");
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number]; type TerminalRow = TerminalState["grid"][number];
function rowToText(row: TerminalRow): string { function rowToText(row: TerminalRow): string {
return row return row
@@ -44,12 +47,12 @@ function rowToText(row: TerminalRow): string {
} }
// Extract text from a single row // 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]); return rowToText(state.grid[rowIndex]);
} }
// Extract all visible lines as array (trimmed, empty lines included) // 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); return state.grid.map(rowToText);
} }
@@ -65,7 +68,7 @@ async function waitForLines(
): Promise<void> { ): Promise<void> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
const lines = getLines(session.getState()); const lines = getLines(await readTerminalState(session));
let matches = true; let matches = true;
for (let i = 0; i < expectedLines.length; i++) { for (let i = 0; i < expectedLines.length; i++) {
if (lines[i] !== expectedLines[i]) { if (lines[i] !== expectedLines[i]) {
@@ -78,7 +81,7 @@ async function waitForLines(
} }
await new Promise((resolve) => setTimeout(resolve, 50)); 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( throw new Error(
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, `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( async function waitForState(
session: TerminalSession, session: TerminalSession,
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean, predicate: (state: TerminalState) => boolean,
timeoutMs = 5000, timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> { ): Promise<TerminalState> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
const state = session.getState(); const state = await readTerminalState(session);
if (predicate(state)) { if (predicate(state)) {
return 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.rows).toBe(24);
expect(state.cols).toBe(80); 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.rows).toBe(40);
expect(state.cols).toBe(120); 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( const session = trackSession(
await createTerminal({ await createTerminal({
workspaceId: "ws-test", workspaceId: "ws-test",
@@ -513,21 +516,15 @@ describe("createTerminal", () => {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < 5000) { while (Date.now() - start < 5000) {
if (rowToText(session.getState().grid[2]).startsWith("A")) { if (rowToText((await readTerminalState(session)).grid[2]).startsWith("A")) {
break; break;
} }
await new Promise((resolve) => setTimeout(resolve, 50)); 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. // 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]); 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 () => { 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. // row back the way a selection copy would.
const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true });
try { try {
await new Promise<void>((resolve) => await new Promise<void>((resolve) => restored.write(session.serializeReplay().data, resolve));
restored.write(renderTerminalSnapshotToAnsi(state), resolve),
);
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text);
} finally { } finally {
restored.dispose(); restored.dispose();
@@ -615,11 +610,8 @@ describe("createTerminal", () => {
// Poll the parsed buffer until the final line lands, so the exit summary // 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. // (which reads the headless buffer) can't race the async xterm parse.
const finalLineParsed = (): boolean => { const finalLineParsed = (): boolean =>
const state = session.getState(); session.captureLines({ start: -12 }).lines.some((line) => line.includes("line-2999"));
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 deadline = Date.now() + 10000; const deadline = Date.now() + 10000;
while (!finalLineParsed()) { while (!finalLineParsed()) {
if (Date.now() > deadline) { if (Date.now() > deadline) {
@@ -678,7 +670,7 @@ describe("createTerminal", () => {
); );
await waitForLines(session, ["paseo-launch-ok"]); 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"]); 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"]); 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 () => { it("leaves the command check to the terminal itself on Windows", async () => {
@@ -807,7 +799,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
// Line 2: "$" // Line 2: "$"
await waitForLines(session, ["$ echo hello", "hello", "$"]); 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, 0)).toBe("$ echo hello");
expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 1)).toBe("hello");
expect(getRowText(state, 2)).toBe("$"); expect(getRowText(state, 2)).toBe("$");
@@ -829,7 +821,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
await waitForLines(session, ["$ pwd", "/tmp", "$"]); await waitForLines(session, ["$ pwd", "/tmp", "$"]);
const state = session.getState(); const state = await readTerminalState(session);
expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 0)).toBe("$ pwd");
expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 1)).toBe("/tmp");
expect(getRowText(state, 2)).toBe("$"); expect(getRowText(state, 2)).toBe("$");
@@ -890,7 +882,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
expect(seenTitles).toContain("npm run dev"); expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("npm run dev"); expect(session.getTitle()).toBe("npm run dev");
expect(session.getState().title).toBe("npm run dev");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -916,7 +907,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
expect(seenTitles).toContain("Build Log"); expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log"); expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -937,7 +927,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
expect(session.getTitle()).toBe("typecheck"); expect(session.getTitle()).toBe("typecheck");
expect(session.getState().title).toBe("typecheck");
}); });
it("emits command completion from VS Code OSC 633 without visible output", async () => { 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); await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([7]); 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(); unsubscribeCommandFinished();
}); });
@@ -1000,7 +989,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
await waitForState(session, () => commandCompletions.length === 1); await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([3]); 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(); unsubscribeCommandFinished();
}); });
@@ -1155,7 +1144,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
await waitForState(session, () => commandCompletions.includes(1)); await waitForState(session, () => commandCompletions.includes(1));
expect(commandCompletions).toEqual([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(); unsubscribeCommandFinished();
}); });
@@ -1189,7 +1178,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal"); expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -1219,7 +1207,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal"); expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -1248,7 +1235,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal"); expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle(); unsubscribeTitle();
}); });
@@ -1272,7 +1258,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
const state = session.getState(); const state = await readTerminalState(session);
const outputRow = state.grid[1]; const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("R"); 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$"]); 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]; const outputRow = state.grid[1];
// Check R cell // Check R cell
@@ -1331,7 +1317,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
const state = session.getState(); const state = await readTerminalState(session);
const outputRow = state.grid[1]; const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("B"); expect(outputRow[0].char).toBe("B");
@@ -1355,7 +1341,7 @@ describe("resize", () => {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const state = session.getState(); const state = await readTerminalState(session);
expect(state.rows).toBe(40); expect(state.rows).toBe(40);
expect(state.cols).toBe(120); expect(state.cols).toBe(120);
}); });
@@ -1373,7 +1359,7 @@ describe("resize", () => {
session.send({ type: "resize", rows: 10, cols: 40 }); session.send({ type: "resize", rows: 10, cols: 40 });
await new Promise((resolve) => setTimeout(resolve, 100)); 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.length).toBe(10);
expect(state.grid[0].length).toBe(40); expect(state.grid[0].length).toBe(40);
}); });
@@ -1552,7 +1538,7 @@ it.runIf(isPlatform("linux", "darwin"))(
env: { PATH: "bin" }, env: { PATH: "bin" },
}); });
await waitForLines(session, ["paseo-launch-ok"]); 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.cwd).toBe(realpathSync(projectDir));
expect(session.getExitInfo()).toBeNull(); expect(session.getExitInfo()).toBeNull();
} finally { } finally {
@@ -1574,3 +1560,309 @@ it.runIf(isPlatform("linux", "darwin"))(
).rejects.toThrow(" node : command not found"); ).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, findExecutable,
findExecutableWithoutProbing, findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js"; } 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 { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
@@ -66,26 +72,6 @@ export interface TerminalCommandFinishedInfo {
exitCode: number | null; 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 = export type ClientMessage =
| { type: "input"; data: string } | { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number } | { type: "resize"; rows: number; cols: number }
@@ -93,7 +79,6 @@ export type ClientMessage =
export type ServerMessage = export type ServerMessage =
| { type: "output"; data: string; revision?: number } | { type: "output"; data: string; revision?: number }
| { type: "snapshot"; state: TerminalState; revision?: number }
| { type: "snapshotReady"; revision?: number; replayPreamble?: string } | { type: "snapshotReady"; revision?: number; replayPreamble?: string }
| { type: "titleChange"; title?: string }; | { type: "titleChange"; title?: string };
@@ -108,14 +93,13 @@ export interface TerminalSession {
cwd: string; cwd: string;
workspaceId: string; workspaceId: string;
send(msg: ClientMessage): void; send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void; subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void;
onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void; onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void;
getSize(): { rows: number; cols: number }; getSize(): { rows: number; cols: number };
getState(): TerminalState; hasOutput(): boolean;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string; getReplayPreamble(): string;
getTitle(): string | undefined; getTitle(): string | undefined;
getActivity(): TerminalActivity | null; 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. */ /** A session that owns its PTY in this process, so it knows the program's pid. */
export interface LocalTerminalSession extends TerminalSession { export interface LocalTerminalSession extends TerminalSession {
readonly pid: number; readonly pid: number;
serializeReplay(options?: TerminalReplayOptions): {
metadata: TerminalReplayMetadata;
data: Uint8Array;
};
captureLines(options?: CaptureTerminalLinesOptions): CaptureTerminalLinesResult;
} }
function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null { function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null {
@@ -545,179 +534,6 @@ function prependPathEntry(currentPath: string, entry: string): string {
return [entry, ...entries].join(delimiter); 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 { function normalizeProcessToken(token: string): string {
if (token.length === 0) { if (token.length === 0) {
return token; return token;
@@ -984,10 +800,12 @@ export async function createTerminal(
const terminal = new Terminal({ const terminal = new Terminal({
rows, rows,
cols, cols,
scrollback: 1000, scrollback: 10000,
allowProposedApi: true, allowProposedApi: true,
}); });
const serialize = createTerminalSerializer(terminal);
ensureNodePtySpawnHelperExecutableForCurrentPlatform(); ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY // Create PTY
@@ -1224,6 +1042,10 @@ export async function createTerminal(
if (disposed || killed) { if (disposed || killed) {
return; 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; stateRevision += 1;
for (const listener of listeners) { for (const listener of listeners) {
listener({ type: "output", data, revision: stateRevision }); listener({ type: "output", data, revision: stateRevision });
@@ -1234,10 +1056,6 @@ export async function createTerminal(
// Pipe PTY output to terminal emulator // Pipe PTY output to terminal emulator
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
if (killed) return; if (killed) return;
const inputModeUpdate = inputModeTracker.feed(data);
for (const response of inputModeUpdate.responses) {
ptyProcess.write(response);
}
recentOutputChunks.push(data); recentOutputChunks.push(data);
recentOutputLength += data.length; recentOutputLength += data.length;
// Drop whole leading chunks while the rest still covers the char limit, so // 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 { function serializeReplay(replayOptions?: TerminalReplayOptions) {
return { const metadata = {
rows: terminal.rows, rows: terminal.rows,
cols: terminal.cols, cols: terminal.cols,
grid: extractGrid(terminal), retainedHistoryLines: terminal.buffer.normal.baseY,
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),
revision: stateRevision, revision: stateRevision,
replayPreamble: getReplayPreamble(),
}; };
return { metadata, data: serialize(replayOptions) };
} }
function getSize(): { rows: number; cols: number } { function getSize(): { rows: number; cols: number } {
@@ -1386,14 +1187,10 @@ export async function createTerminal(
} }
} }
function subscribe( function subscribe(listener: (msg: ServerMessage) => void): () => void {
listener: (msg: ServerMessage) => void,
subscribeOptions?: TerminalSubscribeOptions,
): () => void {
let active = true; let active = true;
let snapshotDelivered = false; let snapshotDelivered = false;
const queuedMessages: ServerMessage[] = []; const queuedMessages: ServerMessage[] = [];
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
const subscriptionListener = (msg: ServerMessage): void => { const subscriptionListener = (msg: ServerMessage): void => {
if (!active) { if (!active) {
return; return;
@@ -1410,17 +1207,11 @@ export async function createTerminal(
terminal.write("", () => { terminal.write("", () => {
if (!disposed && active && listeners.has(subscriptionListener)) { if (!disposed && active && listeners.has(subscriptionListener)) {
snapshotDelivered = true; snapshotDelivered = true;
if (initialSnapshot === "ready") { listener({
// Carry the input-mode preamble so the snapshot-less "ready" path type: "snapshotReady",
// (live restore) can replay it without a separate state fetch. revision: stateRevision,
listener({ replayPreamble: getReplayPreamble(),
type: "snapshotReady", });
revision: stateRevision,
replayPreamble: getReplayPreamble(),
});
} else {
listener({ type: "snapshot", ...getStateSnapshot() });
}
for (const message of queuedMessages.splice(0)) { for (const message of queuedMessages.splice(0)) {
listener(message); listener(message);
} }
@@ -1604,8 +1395,15 @@ export async function createTerminal(
onTitleChange, onTitleChange,
onActivityChange, onActivityChange,
getSize, getSize,
getState, serializeReplay,
getStateSnapshot, 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, getReplayPreamble,
getTitle, getTitle,
getActivity, 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 { randomBytes, randomUUID } from "node:crypto";
import { access } from "node:fs/promises"; import { access } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/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 { createExternalProcessEnv } from "../server/paseo-env.js";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
import { writtenDuringThisBoot } from "../server/pid-lock.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 type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
import type { import type {
@@ -15,7 +16,6 @@ import type {
TerminalCommandFinishedInfo, TerminalCommandFinishedInfo,
TerminalExitInfo, TerminalExitInfo,
TerminalSession, TerminalSession,
TerminalStateSnapshot,
} from "./terminal.js"; } from "./terminal.js";
import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js"; import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -59,7 +59,6 @@ import type {
TerminalWorkerResponse, TerminalWorkerResponse,
TerminalWorkerToParentMessage, TerminalWorkerToParentMessage,
WorkerCreateTerminalOptions, WorkerCreateTerminalOptions,
TerminalWorkerStateResult,
WorkerTerminalInfo, WorkerTerminalInfo,
} from "./terminal-worker-protocol.js"; } from "./terminal-worker-protocol.js";
@@ -93,7 +92,7 @@ interface PendingRequest {
interface WorkerTerminalRecord { interface WorkerTerminalRecord {
info: RequiredWorkerTerminalInfo; info: RequiredWorkerTerminalInfo;
state: TerminalState; state: WorkerTerminalState;
activity: TerminalActivity | null; activity: TerminalActivity | null;
// Cached input-mode preamble from the worker (the authoritative tracker lives // Cached input-mode preamble from the worker (the authoritative tracker lives
// in the worker process). Updated with streamed output and snapshot responses. // 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 requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
const resolveCreateEnvironment = const resolveCreateEnvironment =
managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment; managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment;
const replaySinks = new Map<string, TerminalReplaySink>();
const pendingRequests = new Map<string, PendingRequest>(); const pendingRequests = new Map<string, PendingRequest>();
const inFlight = new Set<Promise<void>>(); const inFlight = new Set<Promise<void>>();
const recordsById = new Map<string, WorkerTerminalRecord>(); const recordsById = new Map<string, WorkerTerminalRecord>();
@@ -244,7 +244,7 @@ export function createWorkerTerminalManager(
function registerRecord(input: { function registerRecord(input: {
info: RequiredWorkerTerminalInfo; info: RequiredWorkerTerminalInfo;
state: TerminalState; state: WorkerTerminalState;
}): TerminalSession { }): TerminalSession {
const existing = recordsById.get(input.info.id); const existing = recordsById.get(input.info.id);
if (existing) { if (existing) {
@@ -290,18 +290,11 @@ export function createWorkerTerminalManager(
} }
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message }); sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
}, },
subscribe( subscribe(listener: (msg: ServerMessage) => void): () => void {
listener: (msg: ServerMessage) => void,
options?: { initialSnapshot?: "state" | "ready" },
): () => void {
record.messageListeners.add(listener); record.messageListeners.add(listener);
if (options?.initialSnapshot === "ready") { queueMicrotask(() => {
queueMicrotask(() => { if (record.messageListeners.has(listener)) listener({ type: "snapshotReady" });
if (record.messageListeners.has(listener)) { });
listener({ type: "snapshotReady", revision: 0 });
}
});
}
return () => { return () => {
record.messageListeners.delete(listener); record.messageListeners.delete(listener);
}; };
@@ -362,19 +355,8 @@ export function createWorkerTerminalManager(
cols: record.state.cols, cols: record.state.cols,
}; };
}, },
getState(): TerminalState { hasOutput(): boolean {
return record.state; return record.state.hasOutput;
},
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,
};
}, },
getReplayPreamble(): string { getReplayPreamble(): string {
return record.replayPreamble; return record.replayPreamble;
@@ -442,9 +424,7 @@ export function createWorkerTerminalManager(
if (!record) { if (!record) {
return; return;
} }
if (message.message.type === "snapshot") { if (message.message.type === "output") record.state.hasOutput = true;
record.state = message.message.state;
}
record.replayPreamble = message.replayPreamble; record.replayPreamble = message.replayPreamble;
for (const listener of Array.from(record.messageListeners)) { for (const listener of Array.from(record.messageListeners)) {
listener(message.message); listener(message.message);
@@ -610,7 +590,23 @@ export function createWorkerTerminalManager(
} }
function attachWorker(): void { 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)) { if (isResponse(message)) {
const pending = pendingRequests.get(message.requestId); const pending = pendingRequests.get(message.requestId);
if (!pending) { if (!pending) {
@@ -628,14 +624,15 @@ export function createWorkerTerminalManager(
handleWorkerEvent(message); handleWorkerEvent(message);
}); });
worker.on("close", () => { connection.on("close", () => {
if (worker !== connection) return;
workerDisconnected = true; workerDisconnected = true;
rejectPendingRequests(new Error("Terminal worker connection closed")); rejectPendingRequests(new Error("Terminal worker connection closed"));
const reconnect = managerOptions.reconnect; const reconnect = managerOptions.reconnect;
if (!detached && reconnect) { if (!detached && reconnect) {
reconnecting = (async () => { reconnecting = (async () => {
const connection = await reconnect(); const recovered = await reconnect();
if (!connection) { if (!recovered) {
for (const terminalId of Array.from(recordsById.keys())) { for (const terminalId of Array.from(recordsById.keys())) {
handleTerminalExitEvent({ handleTerminalExitEvent({
type: "terminalExit", type: "terminalExit",
@@ -646,10 +643,10 @@ export function createWorkerTerminalManager(
return; return;
} }
if (detached) { if (detached) {
connection.disconnect(); recovered.disconnect();
return; return;
} }
worker = connection; worker = recovered;
workerDisconnected = false; workerDisconnected = false;
attachWorker(); attachWorker();
await adoptTerminals(); await adoptTerminals();
@@ -672,12 +669,18 @@ export function createWorkerTerminalManager(
} }
attachWorker(); attachWorker();
function sendRequest(input: TerminalWorkerRequestInput): Promise<unknown> { function sendRequest(
if (reconnecting && workerDisconnected) return reconnecting.then(() => sendRequest(input)); input: TerminalWorkerRequestInput,
sink?: TerminalReplaySink,
): Promise<unknown> {
if (reconnecting && workerDisconnected)
return reconnecting.then(() => sendRequest(input, sink));
if (workerDisconnected || !worker.connected) { if (workerDisconnected || !worker.connected) {
return Promise.reject(new Error("Terminal worker is not running")); return Promise.reject(new Error("Terminal worker is not running"));
} }
const connection = worker;
const requestId = randomUUID(); const requestId = randomUUID();
if (sink) replaySinks.set(requestId, sink);
const message = { ...input, requestId } as TerminalWorkerRequest; const message = { ...input, requestId } as TerminalWorkerRequest;
const request = new Promise((resolve, reject) => { const request = new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -685,7 +688,7 @@ export function createWorkerTerminalManager(
reject(new Error(`Terminal worker request timed out: ${input.type}`)); reject(new Error(`Terminal worker request timed out: ${input.type}`));
}, requestTimeoutMs); }, requestTimeoutMs);
pendingRequests.set(requestId, { resolve, reject, timeout }); pendingRequests.set(requestId, { resolve, reject, timeout });
worker.send(message, (error) => { connection.send(message, (error) => {
if (!error) { if (!error) {
return; return;
} }
@@ -700,7 +703,10 @@ export function createWorkerTerminalManager(
() => undefined, () => undefined,
); );
inFlight.add(settled); inFlight.add(settled);
void settled.finally(() => inFlight.delete(settled)); void settled.finally(() => {
inFlight.delete(settled);
replaySinks.delete(requestId);
});
return request; return request;
} }
@@ -867,22 +873,12 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session; return recordsById.get(id)?.session;
}, },
async getTerminalState( async replayTerminal(id, options, sink): Promise<TerminalReplayMetadata | null> {
id: string, const metadata = (await sendRequest(
options?: { scrollbackLines?: number }, { type: "replayTerminal", terminalId: id, options },
): Promise<TerminalStateSnapshot | null> { sink,
const snapshot = (await sendRequest({ )) as TerminalReplayMetadata | null;
type: "getTerminalState", return metadata;
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
if (snapshot && snapshot.replayPreamble !== undefined) {
const record = recordsById.get(id);
if (record) {
record.replayPreamble = snapshot.replayPreamble;
}
}
return snapshot;
}, },
setTerminalTitle(id: string, title: string): boolean { setTerminalTitle(id: string, title: string): boolean {

Some files were not shown because too many files have changed in this diff Show More