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` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
version1Snapshot format version
colsnumberTerminal column count when serialized
rowsnumberTerminal row count when serialized
payloadstringANSI payload containing screen + scrollback content
cursor{ row, col, visible }Cursor position and visibility
modes{ altScreen, cursorKeysApp, bracketedPaste }Terminal mode flags restored after payload
+ +### Functions + + + + + + + + + + + + + + + + + + +
FunctionDescription
serialize(term: WTerm): TerminalSnapshotCaptures terminal content, cursor state, and mode flags.
restore(term: WTerm, snapshot: TerminalSnapshot): voidReplays 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