Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@solidjs/router": "^0.16.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"monaco-editor": "^0.55.1",
Expand All @@ -32,6 +33,8 @@
"@solidjs/testing-library": "^0.8.10",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/user-event": "^14.6.4",
"@xterm/headless": "^6.0.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
"typescript": "^6.0.3",
"vite": "^8.2.2",
Expand Down
25 changes: 25 additions & 0 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 88 additions & 28 deletions web/src/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { SearchAddon, type ISearchResultChangeEvent } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import "@xterm/xterm/css/xterm.css";
import { openAttach } from "./api";
import type { RuntimeSocket } from "./runtimeTransport";
Expand All @@ -20,6 +21,7 @@ import {
loadTerminalCache,
MAX_TERMINAL_CACHE_BYTES,
saveTerminalCache,
type TerminalCacheEntry,
} from "./terminalCache";
import {
createReplayQueue,
Expand All @@ -30,6 +32,12 @@ import {
type ReplayHandle,
type ReplayTail,
} from "./terminalReplay";

// Cap on the scrollback the serialized cache persists. A 5000-line scrollback
// full of wide chars and colour serializes large; 2000 lines keeps the cache
// entry and the one-write restore bounded while still filling the viewport and
// a deep scroll on reload (WI-129).
const SERIALIZE_MAX_SCROLLBACK = 2000;
import { beginForegroundReplay } from "./terminalPrewarm";
import {
clampTerminalFontSize,
Expand Down Expand Up @@ -138,16 +146,17 @@ const TerminalView: Component<Props> = (props) => {
let ws: RuntimeSocket | null = null;
let fit: FitAddon | null = null;
let search: SearchAddon | null = null;
let serializeAddon: SerializeAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let inSnapshot = true;
let outputPosition: number | undefined;
let snapshotEndPosition: number | undefined;
let cacheChunks: Uint8Array[] = [];
let cacheBytes = 0;
// A parked pane's cached tail, prepared but not yet replayed. Kept in memory
// on reload and replayed lazily on first activation (F3), so retained tabs do
// not time-slice the shared replay parser with the active pane.
let deferredCacheReplay: ReplayTail | null = null;
// A parked pane's cache entry, loaded but not yet restored. Kept in memory on
// reload and restored lazily on first activation (F3), so retained tabs do not
// time-slice the shared replay parser with the active pane.
let deferredCache: TerminalCacheEntry | null = null;
let cacheTimer: ReturnType<typeof setTimeout> | null = null;
// The outputPosition at the last successful persist, so an unchanged ring is
// not re-copied and re-written to IndexedDB.
Expand Down Expand Up @@ -322,14 +331,32 @@ const TerminalView: Component<Props> = (props) => {
return combined;
};

// Serialize the live terminal's screen + a capped scrollback, so a reload
// restores it in one write instead of re-parsing raw bytes (F5). Returns null
// when serialization is unavailable or throws, so persistCache can fall back
// to the raw ring.
const trySerialize = (): string | null => {
if (!serializeAddon) return null;
try {
return serializeAddon.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK });
} catch {
return null;
}
};

const persistCache = () => {
if (outputPosition === undefined) return;
// Skip the write when nothing new has arrived since the last persist
// OutputPosition is the monotonic byte cursor, so an unchanged
// value means the ring is identical and copying+writing it is wasted work.
if (outputPosition === lastPersistedPosition) return;
lastPersistedPosition = outputPosition;
void saveTerminalCache(props.sessionId, outputPosition, cachedBytes());
const serialized = trySerialize();
void saveTerminalCache(
props.sessionId,
outputPosition,
serialized !== null ? { serialized } : { data: cachedBytes() },
);
};

const scheduleCachePersist = () => {
Expand Down Expand Up @@ -547,6 +574,8 @@ const TerminalView: Component<Props> = (props) => {
fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
serializeAddon = new SerializeAddon();
term.loadAddon(serializeAddon);
search = new SearchAddon();
term.loadAddon(search);
search.onDidChangeResults((info) => props.onSearchResults?.(info));
Expand Down Expand Up @@ -814,31 +843,23 @@ const TerminalView: Component<Props> = (props) => {
if (destroyed) return;
// A live pane's initial load holds the pre-warm gate until snapshot-done.
enterForegroundReplay();
if (!cached || cached.data.byteLength === 0) {
if (!cached) {
setReadyToConnect(true);
if (!isParked()) connect();
return;
}
const bytes = new Uint8Array(cached.data);
const prepared = prepareReplayTail(bytes, cached.outputPosition);
cacheChunks = [bytes];
cacheBytes = bytes.byteLength;
// Adopt the cache's position now so a warm reattach resumes from it even
// if the visible replay is deferred (or later cancelled by a re-park).
outputPosition = prepared.outputPosition;
// if the visible restore is deferred (or later cancelled by a re-park).
outputPosition = cached.outputPosition;
if (shouldDeferCacheReplay(isParked(), true)) {
// Parked on reload: keep the tail in memory and replay it on the first
// Parked on reload: keep the entry in memory and restore it on the first
// activation, not into the shared FIFO with the active pane (F3).
deferredCacheReplay = prepared;
deferredCache = cached;
setReadyToConnect(true);
return;
}
setStatusText("Restoring terminal...");
replay = replayCacheTail(prepared);
void replay.done.then(() => {
restoreCache(cached, () => {
if (destroyed) return;
outputPosition = prepared.outputPosition;
term?.scrollToBottom();
setReadyToConnect(true);
if (!isParked()) connect();
});
Expand Down Expand Up @@ -917,6 +938,49 @@ const TerminalView: Component<Props> = (props) => {
);
}

/**
* Restore a cached terminal, then run `onDone`. A serialized entry (F5) is a
* single `term.write` of the screen + capped scrollback — no raw re-parse. A
* raw entry (pre-warm, fallback, or a pre-F5 cache) re-parses a ground-state
* tail through the shared replay queue, exactly as before F5.
*/
function restoreCache(cached: TerminalCacheEntry, onDone: () => void): void {
setStatusText("Restoring terminal...");
if (typeof cached.serialized === "string") {
term?.reset();
cacheChunks = [];
cacheBytes = 0;
outputPosition = cached.outputPosition;
const restored = cached.serialized;
if (!term) {
onDone();
return;
}
term.write(restored, () => {
if (destroyed) return;
term?.scrollToBottom();
onDone();
});
return;
}
if (cached.data) {
const bytes = new Uint8Array(cached.data);
const prepared = prepareReplayTail(bytes, cached.outputPosition);
cacheChunks = [bytes];
cacheBytes = bytes.byteLength;
outputPosition = prepared.outputPosition;
replay = replayCacheTail(prepared);
void replay.done.then(() => {
if (destroyed) return;
outputPosition = prepared.outputPosition;
term?.scrollToBottom();
onDone();
});
return;
}
onDone();
}

function connect() {
if (isParked()) return;
if (isSessionGone()) { markSessionGone(); return; }
Expand Down Expand Up @@ -1111,18 +1175,14 @@ const TerminalView: Component<Props> = (props) => {
if (destroyed || !readyToConnect() || isParked()) return;
// Resuming to the foreground: hold the pre-warm gate through this attach.
enterForegroundReplay();
// A tab parked on reload deferred its cache replay (F3); run it now, before
// attaching, so the restored scrollback is on screen when the delta arrives.
const pending = deferredCacheReplay;
// A tab parked on reload deferred its cache restore (F3); run it now, before
// attaching, so the restored screen is up when the delta arrives.
const pending = deferredCache;
if (pending) {
deferredCacheReplay = null;
setStatusText("Restoring terminal...");
deferredCache = null;
replay?.cancel();
replay = replayCacheTail(pending);
void replay.done.then(() => {
restoreCache(pending, () => {
if (destroyed || isParked()) return;
outputPosition = pending.outputPosition;
term?.scrollToBottom();
connect();
});
return;
Expand Down
71 changes: 71 additions & 0 deletions web/src/__tests__/serializeRestore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Terminal } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import { describe, expect, it } from "vitest";

import claudeCodeTui from "../../tests/fixtures/transcripts/claude-code-tui.bin.gz?gzbytes";
import shellPlain from "../../tests/fixtures/transcripts/shell-plain.bin.gz?gzbytes";
import cargoBuild from "../../tests/fixtures/transcripts/cargo-build.bin.gz?gzbytes";

// F5 (WI-129) persists a live pane's xterm state via @xterm/addon-serialize and
// restores it in a single `term.write` on reload. The fidelity contract is: a
// serialized screen, written into a fresh terminal, reproduces the same screen
// and scrollback — so restore-then-reserialize is a fixed point. Proven here
// against the real transcript corpus (H2), through the same core VT parser the
// browser build uses (@xterm/headless shares it).

const SERIALIZE_MAX_SCROLLBACK = 2000;

const CORPORA = [
{ name: "claude-code-tui", bytes: claudeCodeTui },
{ name: "shell-plain", bytes: shellPlain },
{ name: "cargo-build", bytes: cargoBuild },
] as const;

function makeTerminal() {
const term = new Terminal({
cols: 120,
rows: 40,
scrollback: 5000,
allowProposedApi: true,
});
const serialize = new SerializeAddon();
term.loadAddon(serialize);
return { term, serialize };
}

function write(term: Terminal, data: Uint8Array | string): Promise<void> {
return new Promise((r) => term.write(data, r));
}

describe.each(CORPORA)("serialized restore fidelity: $name", ({ bytes }) => {
it("restore-then-reserialize is a fixed point (screen + scrollback preserved)", async () => {
const a = makeTerminal();
await write(a.term, bytes);
const serialized = a.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK });
// Note: an alt-screen program that has since exited leaves an empty normal
// buffer, so `serialized` can legitimately be "" — the fixed-point below
// still holds, and that is the property F5 relies on.

// Restore into a fresh terminal in ONE write, then re-serialize.
const b = makeTerminal();
await write(b.term, serialized);
const reserialized = b.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK });

expect(reserialized).toBe(serialized);
// The live screen (the viewport) matches too, independent of scrollback.
expect(b.serialize.serialize({ scrollback: 0 })).toBe(
a.serialize.serialize({ scrollback: 0 }),
);
});

it("caps the serialized scrollback to the documented bound", async () => {
const { term, serialize } = makeTerminal();
await write(term, bytes);
const capped = serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK });
// Never more scrollback lines than the cap (rows of viewport aside): the
// serialized string's newline count stays bounded regardless of a
// 5000-line buffer.
const lines = capped.split("\n").length;
expect(lines).toBeLessThanOrEqual(SERIALIZE_MAX_SCROLLBACK + term.rows + 2);
});
});
Loading
Loading