From 746433d280ce7f65af496025f42182009049a441 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Sat, 18 Apr 2026 21:05:44 -0700
Subject: [PATCH] feat: add @wterm/serialize for session persistence
Adds a new package that serializes wterm terminal state (grid, scrollback,
cursor, modes) into a portable snapshot and restores it into a fresh terminal,
matching the capability xterm.js users get from @xterm/addon-serialize.
Closes #35.
---
README.md | 1 +
apps/docs/src/app/api-reference/page.mdx | 32 ++++
apps/docs/src/app/serialize/layout.tsx | 7 +
apps/docs/src/app/serialize/page.mdx | 118 ++++++++++++++
apps/docs/src/lib/docs-navigation.ts | 1 +
apps/docs/src/lib/page-titles.ts | 1 +
packages/@wterm/core/README.md | 1 +
packages/@wterm/serialize/README.md | 65 ++++++++
packages/@wterm/serialize/package.json | 46 ++++++
.../serialize/src/__tests__/cursor.test.ts | 23 +++
.../serialize/src/__tests__/encode.test.ts | 92 +++++++++++
.../@wterm/serialize/src/__tests__/helpers.ts | 21 +++
.../serialize/src/__tests__/roundtrip.test.ts | 87 +++++++++++
.../src/__tests__/scrollback.test.ts | 39 +++++
packages/@wterm/serialize/src/encode.ts | 145 ++++++++++++++++++
packages/@wterm/serialize/src/index.ts | 61 ++++++++
packages/@wterm/serialize/tsconfig.json | 8 +
packages/@wterm/serialize/vitest.config.ts | 14 ++
pnpm-lock.yaml | 22 ++-
19 files changed, 781 insertions(+), 3 deletions(-)
create mode 100644 apps/docs/src/app/serialize/layout.tsx
create mode 100644 apps/docs/src/app/serialize/page.mdx
create mode 100644 packages/@wterm/serialize/README.md
create mode 100644 packages/@wterm/serialize/package.json
create mode 100644 packages/@wterm/serialize/src/__tests__/cursor.test.ts
create mode 100644 packages/@wterm/serialize/src/__tests__/encode.test.ts
create mode 100644 packages/@wterm/serialize/src/__tests__/helpers.ts
create mode 100644 packages/@wterm/serialize/src/__tests__/roundtrip.test.ts
create mode 100644 packages/@wterm/serialize/src/__tests__/scrollback.test.ts
create mode 100644 packages/@wterm/serialize/src/encode.ts
create mode 100644 packages/@wterm/serialize/src/index.ts
create mode 100644 packages/@wterm/serialize/tsconfig.json
create mode 100644 packages/@wterm/serialize/vitest.config.ts
diff --git a/README.md b/README.md
index b149fbd..76971e4 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin
| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) |
| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash |
| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal |
+| [`@wterm/serialize`](packages/@wterm/serialize) | Serialize and restore terminal state |
## Features
diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx
index 0058866..cb71479 100644
--- a/apps/docs/src/app/api-reference/page.mdx
+++ b/apps/docs/src/app/api-reference/page.mdx
@@ -405,3 +405,35 @@ interface CursorState {
visible: boolean;
}
```
+
+## @wterm/serialize
+
+Serialize and restore terminal sessions for persistence across reloads.
+
+### API
+
+```ts
+import { serialize, restore } from "@wterm/serialize";
+
+interface TerminalSnapshot {
+ version: 1;
+ cols: number;
+ rows: number;
+ payload: string;
+ cursor: { row: number; col: number; visible: boolean };
+ modes: {
+ altScreen: boolean;
+ cursorKeysApp: boolean;
+ bracketedPaste: boolean;
+ };
+}
+
+function serialize(term: WTerm): TerminalSnapshot;
+function restore(term: WTerm, snapshot: TerminalSnapshot): void;
+```
+
+### Notes
+
+- `serialize()` throws if called before `term.init()`.
+- `restore()` throws for unsupported snapshot versions.
+- v1 snapshots do not capture partial parser state or terminal title state.
diff --git a/apps/docs/src/app/serialize/layout.tsx b/apps/docs/src/app/serialize/layout.tsx
new file mode 100644
index 0000000..84fd983
--- /dev/null
+++ b/apps/docs/src/app/serialize/layout.tsx
@@ -0,0 +1,7 @@
+import { pageMetadata } from "@/lib/page-metadata";
+
+export const metadata = pageMetadata("serialize");
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return children;
+}
diff --git a/apps/docs/src/app/serialize/page.mdx b/apps/docs/src/app/serialize/page.mdx
new file mode 100644
index 0000000..4d4dd33
--- /dev/null
+++ b/apps/docs/src/app/serialize/page.mdx
@@ -0,0 +1,118 @@
+# Serialize
+
+Persist and restore terminal sessions with `@wterm/serialize`. Capture a snapshot of terminal content and replay it on the next page load.
+
+## Install
+
+```bash
+npm install @wterm/serialize
+```
+
+## Why Persistence
+
+Session persistence is useful when:
+
+- You want terminal output to survive page refreshes.
+- You need to resume a session after navigation.
+- You want fast restoration of UI state without replaying backend logs.
+
+## Quick Start
+
+### Vanilla JS
+
+```js
+import { WTerm } from "@wterm/dom";
+import { serialize, restore } from "@wterm/serialize";
+import "@wterm/dom/css";
+
+const STORAGE_KEY = "wterm:snapshot";
+
+const term = new WTerm(document.getElementById("terminal"));
+await term.init();
+
+const saved = localStorage.getItem(STORAGE_KEY);
+if (saved) {
+ try {
+ restore(term, JSON.parse(saved));
+ } catch {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+}
+
+window.addEventListener("beforeunload", () => {
+ const snapshot = serialize(term);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
+});
+```
+
+## API
+
+### `TerminalSnapshot`
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+ version |
+ 1 |
+ Snapshot format version |
+
+
+ cols |
+ number |
+ Terminal column count when serialized |
+
+
+ rows |
+ number |
+ Terminal row count when serialized |
+
+
+ payload |
+ string |
+ ANSI payload containing screen + scrollback content |
+
+
+ cursor |
+ { row, col, visible } |
+ Cursor position and visibility |
+
+
+ modes |
+ { altScreen, cursorKeysApp, bracketedPaste } |
+ Terminal mode flags restored after payload |
+
+
+
+
+### Functions
+
+
+
+
+ | Function |
+ Description |
+
+
+
+
+ serialize(term: WTerm): TerminalSnapshot |
+ Captures terminal content, cursor state, and mode flags. |
+
+
+ restore(term: WTerm, snapshot: TerminalSnapshot): void |
+ Replays payload and restores mode/cursor state. |
+
+
+
+
+## Limitations
+
+- Partial parser state in the middle of an escape sequence is not captured.
+- Terminal title state is not captured in v1.
diff --git a/apps/docs/src/lib/docs-navigation.ts b/apps/docs/src/lib/docs-navigation.ts
index 854fa00..59de067 100644
--- a/apps/docs/src/lib/docs-navigation.ts
+++ b/apps/docs/src/lib/docs-navigation.ts
@@ -34,6 +34,7 @@ export const navGroups: NavGroup[] = [
items: [
{ name: "Just Bash", href: "/just-bash" },
{ name: "Markdown", href: "/markdown" },
+ { name: "Serialize", href: "/serialize" },
{ name: "Core / Advanced", href: "/core" },
],
},
diff --git a/apps/docs/src/lib/page-titles.ts b/apps/docs/src/lib/page-titles.ts
index ef9bedb..84fe98d 100644
--- a/apps/docs/src/lib/page-titles.ts
+++ b/apps/docs/src/lib/page-titles.ts
@@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = {
vanilla: "Vanilla JS",
"just-bash": "Just Bash",
markdown: "Markdown",
+ serialize: "Serialize",
core: "Core / Advanced",
"api-reference": "API Reference",
};
diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md
index 34c04e3..8070a0e 100644
--- a/packages/@wterm/core/README.md
+++ b/packages/@wterm/core/README.md
@@ -10,6 +10,7 @@ Headless terminal emulator core for [wterm](https://github.com/vercel-labs/wterm
| [`@wterm/react`](https://www.npmjs.com/package/@wterm/react) | React component + `useTerminal` hook |
| [`@wterm/just-bash`](https://www.npmjs.com/package/@wterm/just-bash) | In-browser Bash shell powered by just-bash |
| [`@wterm/markdown`](https://www.npmjs.com/package/@wterm/markdown) | Streaming Markdown-to-ANSI renderer for terminals |
+| [`@wterm/serialize`](https://www.npmjs.com/package/@wterm/serialize) | Serialize and restore terminal state |
## Install
diff --git a/packages/@wterm/serialize/README.md b/packages/@wterm/serialize/README.md
new file mode 100644
index 0000000..e092819
--- /dev/null
+++ b/packages/@wterm/serialize/README.md
@@ -0,0 +1,65 @@
+# @wterm/serialize
+
+Serialize and restore terminal state for [wterm](https://github.com/vercel-labs/wterm). Capture a snapshot of the current grid + scrollback and restore it later for session persistence.
+
+## Install
+
+```bash
+npm install @wterm/serialize
+```
+
+## Quick Start
+
+```ts
+import { WTerm } from "@wterm/dom";
+import { serialize, restore } from "@wterm/serialize";
+import "@wterm/dom/css";
+
+const term = new WTerm(document.getElementById("terminal")!);
+await term.init();
+
+const saved = localStorage.getItem("terminal:snapshot");
+if (saved) {
+ restore(term, JSON.parse(saved));
+}
+
+window.addEventListener("beforeunload", () => {
+ const snapshot = serialize(term);
+ localStorage.setItem("terminal:snapshot", JSON.stringify(snapshot));
+});
+```
+
+## API
+
+### `TerminalSnapshot`
+
+```ts
+interface TerminalSnapshot {
+ version: 1;
+ cols: number;
+ rows: number;
+ payload: string;
+ cursor: { row: number; col: number; visible: boolean };
+ modes: {
+ altScreen: boolean;
+ cursorKeysApp: boolean;
+ bracketedPaste: boolean;
+ };
+}
+```
+
+### Functions
+
+```ts
+serialize(term: WTerm): TerminalSnapshot
+restore(term: WTerm, snapshot: TerminalSnapshot): void
+```
+
+## Limitations
+
+- Partial parser state in the middle of an escape sequence is not captured.
+- Terminal title state is not captured in v1.
+
+## License
+
+Apache-2.0
diff --git a/packages/@wterm/serialize/package.json b/packages/@wterm/serialize/package.json
new file mode 100644
index 0000000..e1e95af
--- /dev/null
+++ b/packages/@wterm/serialize/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@wterm/serialize",
+ "version": "0.1.8",
+ "description": "Serialize and restore wterm terminal state",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "prepublishOnly": "pnpm build",
+ "test": "vitest run",
+ "type-check": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@internal/ts": "workspace:*",
+ "typescript": "^6.0.2"
+ },
+ "peerDependencies": {
+ "@wterm/core": "workspace:*",
+ "@wterm/dom": "workspace:*"
+ },
+ "keywords": [
+ "terminal",
+ "wterm",
+ "serialize",
+ "persistence",
+ "ansi"
+ ],
+ "license": "Apache-2.0",
+ "homepage": "https://wterm.dev",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vercel-labs/wterm",
+ "directory": "packages/@wterm/serialize"
+ }
+}
diff --git a/packages/@wterm/serialize/src/__tests__/cursor.test.ts b/packages/@wterm/serialize/src/__tests__/cursor.test.ts
new file mode 100644
index 0000000..a06061f
--- /dev/null
+++ b/packages/@wterm/serialize/src/__tests__/cursor.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest";
+import { restore, serialize } from "../index.js";
+import { makeTerm } from "./helpers.js";
+
+describe("cursor", () => {
+ it("preserves cursor position and visibility through roundtrip", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(20, 5);
+ term.write("abc\r\nxy");
+ term.write("\x1b[?25l");
+
+ const snapshot = serialize(term as any);
+ const restored = await makeTerm(20, 5);
+ restore(restored as any, snapshot);
+
+ const before = term.bridge!.getCursor();
+ const after = restored.bridge!.getCursor();
+
+ expect(after.row).toBe(before.row);
+ expect(after.col).toBe(before.col);
+ expect(after.visible).toBe(before.visible);
+ });
+});
diff --git a/packages/@wterm/serialize/src/__tests__/encode.test.ts b/packages/@wterm/serialize/src/__tests__/encode.test.ts
new file mode 100644
index 0000000..e1b0ec7
--- /dev/null
+++ b/packages/@wterm/serialize/src/__tests__/encode.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import { colorBg, colorFg, encodeRow, encodeStream } from "../encode.js";
+
+type Cell = { char: number; fg: number; bg: number; flags: number };
+
+type FakeBridge = {
+ getCursor: () => { row: number; col: number; visible: boolean };
+ getRows: () => number;
+ getCols: () => number;
+ getScrollbackCount: () => number;
+ getScrollbackLineLen: (offset: number) => number;
+ getScrollbackCell: (offset: number, col: number) => Cell;
+ getCell: (row: number, col: number) => Cell;
+};
+
+function makeCell(ch: string, overrides: Partial = {}): Cell {
+ return {
+ char: ch.codePointAt(0) ?? 0,
+ fg: 256,
+ bg: 256,
+ flags: 0,
+ ...overrides,
+ };
+}
+
+function bridgeForRow(row: Cell[]): FakeBridge {
+ return {
+ getCursor: () => ({ row: 0, col: 0, visible: true }),
+ getRows: () => 1,
+ getCols: () => row.length,
+ getScrollbackCount: () => 0,
+ getScrollbackLineLen: () => 0,
+ getScrollbackCell: () => makeCell(" "),
+ getCell: (_r: number, c: number) => row[c] ?? makeCell(" "),
+ };
+}
+
+describe("encodeStream", () => {
+ it("emits clear-home-reset prologue", () => {
+ const bridge = bridgeForRow([makeCell("A")]);
+ const out = encodeStream(bridge as any);
+ expect(out.startsWith("\x1b[2J\x1b[H\x1b[0m")).toBe(true);
+ });
+
+ it("plain ASCII row encodes text and trims trailing default spaces", () => {
+ const row = [makeCell("H"), makeCell("i"), makeCell(" "), makeCell(" ")];
+ const bridge = bridgeForRow(row);
+
+ const out = encodeStream(bridge as any);
+ expect(out).toContain("Hi");
+ expect(out).not.toContain("Hi ");
+ });
+
+ it("BOLD flag emits ESC[1m", () => {
+ const out = encodeRow({} as any, 1, () => makeCell("B", { flags: 0x01 }));
+ expect(out).toContain("\x1b[1m");
+ });
+
+ it("ITALIC emits ESC[3m", () => {
+ const out = encodeRow({} as any, 1, () => makeCell("I", { flags: 0x04 }));
+ expect(out).toContain("\x1b[3m");
+ });
+
+ it("REVERSE emits ESC[7m", () => {
+ const out = encodeRow({} as any, 1, () => makeCell("R", { flags: 0x20 }));
+ expect(out).toContain("\x1b[7m");
+ });
+
+ it("STRIKETHROUGH emits ESC[9m", () => {
+ const out = encodeRow({} as any, 1, () => makeCell("S", { flags: 0x80 }));
+ expect(out).toContain("\x1b[9m");
+ });
+
+ it("fg 0..7 uses 30+i, fg 8..15 uses 90+(i-8), fg 16..255 uses 38;5;N, fg 256 uses 39", () => {
+ expect(colorFg(1)).toBe("\x1b[31m");
+ expect(colorFg(10)).toBe("\x1b[92m");
+ expect(colorFg(196)).toBe("\x1b[38;5;196m");
+ expect(colorFg(256)).toBe("\x1b[39m");
+ });
+
+ it("bg 0..7 uses 40+i, bg 8..15 uses 100+(i-8), bg 16..255 uses 48;5;N, bg 256 uses 49", () => {
+ expect(colorBg(2)).toBe("\x1b[42m");
+ expect(colorBg(11)).toBe("\x1b[103m");
+ expect(colorBg(201)).toBe("\x1b[48;5;201m");
+ expect(colorBg(256)).toBe("\x1b[49m");
+ });
+
+ it("emits ESC[0m at end of each row", () => {
+ const out = encodeRow({} as any, 1, () => makeCell("X"));
+ expect(out.endsWith("\x1b[0m")).toBe(true);
+ });
+});
diff --git a/packages/@wterm/serialize/src/__tests__/helpers.ts b/packages/@wterm/serialize/src/__tests__/helpers.ts
new file mode 100644
index 0000000..b812426
--- /dev/null
+++ b/packages/@wterm/serialize/src/__tests__/helpers.ts
@@ -0,0 +1,21 @@
+import type { WTerm } from "@wterm/dom";
+import { WasmBridge } from "@wterm/core";
+
+export async function makeTerm(
+ cols: number,
+ rows: number,
+): Promise> {
+ const bridge = await WasmBridge.load();
+ bridge.init(cols, rows);
+
+ return {
+ bridge,
+ write(data: string | Uint8Array) {
+ if (typeof data === "string") {
+ bridge.writeString(data);
+ } else {
+ bridge.writeRaw(data);
+ }
+ },
+ };
+}
diff --git a/packages/@wterm/serialize/src/__tests__/roundtrip.test.ts b/packages/@wterm/serialize/src/__tests__/roundtrip.test.ts
new file mode 100644
index 0000000..44d39dc
--- /dev/null
+++ b/packages/@wterm/serialize/src/__tests__/roundtrip.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { WasmBridge } from "@wterm/core";
+import { restore, serialize } from "../index.js";
+import { makeTerm } from "./helpers.js";
+
+describe("roundtrip", () => {
+ let bridge: WasmBridge;
+
+ beforeEach(async () => {
+ bridge = await WasmBridge.load();
+ bridge.init(40, 10);
+ });
+
+ it("write Hello, world! -> serialize -> restore preserves chars", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(40, 10);
+ term.write("Hello, world!");
+
+ const snapshot = serialize(term as any);
+
+ const restored = await makeTerm(40, 10);
+ restore(restored as any, snapshot);
+
+ for (let i = 0; i < "Hello, world!".length; i++) {
+ expect(restored.bridge!.getCell(0, i).char).toBe(
+ "Hello, world!".charCodeAt(i),
+ );
+ }
+ });
+
+ it("write ANSI with BOLD + color and preserves chars and flags", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(40, 10);
+ term.write("\x1b[1;31mERR\x1b[0m OK");
+
+ const snapshot = serialize(term as any);
+ const restored = await makeTerm(40, 10);
+ restore(restored as any, snapshot);
+
+ expect(restored.bridge!.getCell(0, 0).char).toBe("E".charCodeAt(0));
+ expect(restored.bridge!.getCell(0, 1).char).toBe("R".charCodeAt(0));
+ expect(restored.bridge!.getCell(0, 2).char).toBe("R".charCodeAt(0));
+ expect(restored.bridge!.getCell(0, 0).flags & 0x01).toBe(0x01);
+ });
+
+ it("cursor position preserved", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(40, 10);
+ term.write("abc\r\nxy");
+
+ const snapshot = serialize(term as any);
+ const restored = await makeTerm(40, 10);
+ restore(restored as any, snapshot);
+
+ const cursor = restored.bridge!.getCursor();
+ expect(cursor.row).toBe(snapshot.cursor.row);
+ expect(cursor.col).toBe(snapshot.cursor.col);
+ expect(cursor.visible).toBe(snapshot.cursor.visible);
+ });
+
+ it("throws for unsupported snapshot version", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(40, 10);
+ const snapshot = serialize(term as any);
+ const bad = { ...snapshot, version: 2 as 2 };
+
+ expect(() => restore(term as any, bad as any)).toThrow(
+ "wterm: unsupported snapshot version 2",
+ );
+ });
+
+ it("throws when serialize called before init", () => {
+ expect(() => serialize({ bridge: null } as any)).toThrow(
+ "wterm: cannot serialize before init",
+ );
+ });
+
+ it("throws when restore called before init", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(40, 10);
+ const snapshot = serialize(term as any);
+
+ expect(() =>
+ restore({ bridge: null, write() {} } as any, snapshot),
+ ).toThrow("wterm: cannot restore before init");
+ });
+});
diff --git a/packages/@wterm/serialize/src/__tests__/scrollback.test.ts b/packages/@wterm/serialize/src/__tests__/scrollback.test.ts
new file mode 100644
index 0000000..2fbc8e0
--- /dev/null
+++ b/packages/@wterm/serialize/src/__tests__/scrollback.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from "vitest";
+import { restore, serialize } from "../index.js";
+import { makeTerm } from "./helpers.js";
+
+describe("scrollback", () => {
+ it("captures and restores scrollback", async () => {
+ // Uses a minimal shim, not a real DOM WTerm, because serialize() only touches bridge + write().
+ const term = await makeTerm(20, 10);
+
+ for (let i = 0; i < 30; i++) {
+ term.write(`line ${i}\r\n`);
+ }
+
+ const beforeCount = term.bridge!.getScrollbackCount();
+ expect(beforeCount).toBeGreaterThan(0);
+
+ const snapshot = serialize(term as any);
+
+ const restored = await makeTerm(20, 10);
+ restore(restored as any, snapshot);
+
+ const afterCount = restored.bridge!.getScrollbackCount();
+ expect(afterCount).toBe(beforeCount);
+
+ const compare = Math.min(beforeCount, 5);
+ for (let offset = 0; offset < compare; offset++) {
+ const lenA = term.bridge!.getScrollbackLineLen(offset);
+ const lenB = restored.bridge!.getScrollbackLineLen(offset);
+ expect(lenB).toBe(lenA);
+
+ const width = Math.min(lenA, 20);
+ for (let col = 0; col < width; col++) {
+ const a = term.bridge!.getScrollbackCell(offset, col);
+ const b = restored.bridge!.getScrollbackCell(offset, col);
+ expect(b.char).toBe(a.char);
+ }
+ }
+ });
+});
diff --git a/packages/@wterm/serialize/src/encode.ts b/packages/@wterm/serialize/src/encode.ts
new file mode 100644
index 0000000..432b17a
--- /dev/null
+++ b/packages/@wterm/serialize/src/encode.ts
@@ -0,0 +1,145 @@
+import type { CellData, WasmBridge } from "@wterm/core";
+
+const ESC = "\x1b[";
+const RESET = `${ESC}0m`;
+const DEFAULT_COLOR = 256;
+
+const FLAG_TO_SGR: Array<[number, string]> = [
+ [0x01, `${ESC}1m`],
+ [0x02, `${ESC}2m`],
+ [0x04, `${ESC}3m`],
+ [0x08, `${ESC}4m`],
+ [0x10, `${ESC}5m`],
+ [0x20, `${ESC}7m`],
+ [0x40, `${ESC}8m`],
+ [0x80, `${ESC}9m`],
+];
+
+type RunStyle = {
+ fg: number;
+ bg: number;
+ flags: number;
+};
+
+function isDefaultSpace(cell: CellData): boolean {
+ return (
+ cell.char === 0x20 &&
+ cell.flags === 0 &&
+ cell.fg === DEFAULT_COLOR &&
+ cell.bg === DEFAULT_COLOR
+ );
+}
+
+function normalizeChar(codepoint: number): string {
+ if (codepoint === 0) return " ";
+ return String.fromCodePoint(codepoint);
+}
+
+function sameStyle(a: RunStyle, b: RunStyle): boolean {
+ return a.fg === b.fg && a.bg === b.bg && a.flags === b.flags;
+}
+
+export function colorFg(idx: number): string {
+ if (idx === DEFAULT_COLOR) return `${ESC}39m`;
+ if (idx >= 0 && idx <= 7) return `${ESC}${30 + idx}m`;
+ if (idx >= 8 && idx <= 15) return `${ESC}${90 + (idx - 8)}m`;
+ return `${ESC}38;5;${idx}m`;
+}
+
+export function colorBg(idx: number): string {
+ if (idx === DEFAULT_COLOR) return `${ESC}49m`;
+ if (idx >= 0 && idx <= 7) return `${ESC}${40 + idx}m`;
+ if (idx >= 8 && idx <= 15) return `${ESC}${100 + (idx - 8)}m`;
+ return `${ESC}48;5;${idx}m`;
+}
+
+export function sgrForRun(_prev: RunStyle | null, cur: RunStyle): string {
+ let out = RESET;
+
+ for (const [flag, sgr] of FLAG_TO_SGR) {
+ if ((cur.flags & flag) !== 0) out += sgr;
+ }
+
+ out += colorFg(cur.fg);
+ out += colorBg(cur.bg);
+
+ return out;
+}
+
+export function encodeRow(
+ _bridge: WasmBridge,
+ len: number,
+ readCell: (col: number) => CellData,
+): string {
+ const cells: CellData[] = [];
+ for (let col = 0; col < len; col++) {
+ cells.push(readCell(col));
+ }
+
+ let trimmedLen = cells.length;
+ while (trimmedLen > 0 && isDefaultSpace(cells[trimmedLen - 1]!)) {
+ trimmedLen--;
+ }
+
+ if (trimmedLen === 0) return RESET;
+
+ let out = "";
+ let prev: RunStyle | null = null;
+ let runStyle: RunStyle | null = null;
+ let runText = "";
+
+ for (let col = 0; col < trimmedLen; col++) {
+ const cell = cells[col]!;
+ const style: RunStyle = { fg: cell.fg, bg: cell.bg, flags: cell.flags };
+ const char = normalizeChar(cell.char);
+
+ if (!runStyle) {
+ runStyle = style;
+ runText = char;
+ continue;
+ }
+
+ if (sameStyle(runStyle, style)) {
+ runText += char;
+ continue;
+ }
+
+ out += sgrForRun(prev, runStyle) + runText;
+ prev = runStyle;
+ runStyle = style;
+ runText = char;
+ }
+
+ if (runStyle) {
+ out += sgrForRun(prev, runStyle) + runText;
+ }
+
+ out += RESET;
+ return out;
+}
+
+export function encodeStream(bridge: WasmBridge): string {
+ const cursor = bridge.getCursor();
+ const rows = bridge.getRows();
+ const cols = bridge.getCols();
+
+ let out = "\x1b[2J\x1b[H\x1b[0m";
+
+ const scrollbackCount = bridge.getScrollbackCount();
+ for (let i = scrollbackCount - 1; i >= 0; i--) {
+ const len = bridge.getScrollbackLineLen(i);
+ out += encodeRow(bridge, len, (col) => bridge.getScrollbackCell(i, col));
+ out += "\r\n";
+ }
+
+ for (let row = 0; row < rows; row++) {
+ out += encodeRow(bridge, cols, (col) => bridge.getCell(row, col));
+ if (row < rows - 1) out += "\r\n";
+ }
+
+ out += "\x1b[0m";
+ out += `\x1b[${cursor.row + 1};${cursor.col + 1}H`;
+ out += cursor.visible ? "\x1b[?25h" : "\x1b[?25l";
+
+ return out;
+}
diff --git a/packages/@wterm/serialize/src/index.ts b/packages/@wterm/serialize/src/index.ts
new file mode 100644
index 0000000..f8e9419
--- /dev/null
+++ b/packages/@wterm/serialize/src/index.ts
@@ -0,0 +1,61 @@
+import type { WTerm } from "@wterm/dom";
+import { encodeStream } from "./encode.js";
+
+export interface TerminalSnapshot {
+ version: 1;
+ cols: number;
+ rows: number;
+ payload: string;
+ cursor: { row: number; col: number; visible: boolean };
+ modes: {
+ altScreen: boolean;
+ cursorKeysApp: boolean;
+ bracketedPaste: boolean;
+ };
+}
+
+export function serialize(term: WTerm): TerminalSnapshot {
+ if (!term.bridge) {
+ throw new Error("wterm: cannot serialize before init");
+ }
+
+ const cursor = term.bridge.getCursor();
+
+ return {
+ version: 1,
+ cols: term.bridge.getCols(),
+ rows: term.bridge.getRows(),
+ payload: encodeStream(term.bridge),
+ cursor: {
+ row: cursor.row,
+ col: cursor.col,
+ visible: cursor.visible,
+ },
+ modes: {
+ altScreen: term.bridge.usingAltScreen(),
+ cursorKeysApp: term.bridge.cursorKeysApp(),
+ bracketedPaste: term.bridge.bracketedPaste(),
+ },
+ };
+}
+
+export function restore(term: WTerm, snapshot: TerminalSnapshot): void {
+ if (!term.bridge) {
+ throw new Error("wterm: cannot restore before init");
+ }
+
+ if (snapshot.version !== 1) {
+ throw new Error(`wterm: unsupported snapshot version ${snapshot.version}`);
+ }
+
+ const parts = [snapshot.payload];
+
+ if (snapshot.modes.altScreen) parts.push("\x1b[?1049h");
+ if (snapshot.modes.cursorKeysApp) parts.push("\x1b[?1h");
+ if (snapshot.modes.bracketedPaste) parts.push("\x1b[?2004h");
+
+ parts.push(`\x1b[${snapshot.cursor.row + 1};${snapshot.cursor.col + 1}H`);
+ parts.push(snapshot.cursor.visible ? "\x1b[?25h" : "\x1b[?25l");
+
+ term.write(parts.join(""));
+}
diff --git a/packages/@wterm/serialize/tsconfig.json b/packages/@wterm/serialize/tsconfig.json
new file mode 100644
index 0000000..67ad8c2
--- /dev/null
+++ b/packages/@wterm/serialize/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@internal/ts/tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/packages/@wterm/serialize/vitest.config.ts b/packages/@wterm/serialize/vitest.config.ts
new file mode 100644
index 0000000..4762117
--- /dev/null
+++ b/packages/@wterm/serialize/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts", "src/**/__tests__/**"],
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0522f7c..961e204 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -503,6 +503,22 @@ importers:
specifier: ^6.0.2
version: 6.0.2
+ packages/@wterm/serialize:
+ dependencies:
+ '@wterm/core':
+ specifier: workspace:*
+ version: link:../core
+ '@wterm/dom':
+ specifier: workspace:*
+ version: link:../dom
+ devDependencies:
+ '@internal/ts':
+ specifier: workspace:*
+ version: link:../../@internal/ts
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.2
+
packages:
'@adobe/css-tools@4.4.4':
@@ -10402,7 +10418,7 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
@@ -10455,7 +10471,7 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -10495,7 +10511,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
|