diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml
index 8e1a91b..f85241a 100644
--- a/.github/workflows/js.yml
+++ b/.github/workflows/js.yml
@@ -137,6 +137,12 @@ jobs:
with:
bun-version: latest
+ - name: Setup Node.js PTY host
+ if: matrix.runtime == 'bun'
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
diff --git a/js/.changeset/fuzzy-pianos-record.md b/js/.changeset/fuzzy-pianos-record.md
new file mode 100644
index 0000000..7b2e200
--- /dev/null
+++ b/js/.changeset/fuzzy-pianos-record.md
@@ -0,0 +1,7 @@
+---
+'command-stream': minor
+---
+
+Add PTY-backed terminal capture with resize and input control, settled frame
+deduplication, unrolled text transcripts, asciicast v2 interchange, and animated
+SVG recording artifacts.
diff --git a/js/README.md b/js/README.md
index 33d838e..4f69fa5 100644
--- a/js/README.md
+++ b/js/README.md
@@ -400,6 +400,43 @@ console.log(result.stdout); // "hello\n"
$`echo "world"`.on('end', (result) => console.log('Done:', result)).sync();
```
+### TUI Capture
+
+`captureTerminal()` runs a command in a real pseudoterminal and uses xterm's
+terminal model to capture settled screen states. It can send text and control
+keys, resize the terminal, and stop after an output marker:
+
+```javascript
+import { captureTerminal } from 'command-stream';
+
+const capture = await captureTerminal({
+ file: 'my-tui',
+ args: ['--interactive'],
+ cols: 80,
+ rows: 24,
+ interactions: [
+ { after: 'Choose an option', key: 'DOWN' },
+ { after: 'Second option', key: 'ENTER' },
+ { after: 'Your name', text: 'Ada', key: 'ENTER' },
+ { after: 'Dashboard', resize: { cols: 120, rows: 40 } },
+ ],
+ stopMarker: 'Done',
+ artifactDirectory: 'artifacts/my-tui',
+});
+
+console.log(capture.transcript);
+console.log(`${capture.frames.length} distinct settled states`);
+```
+
+The artifact directory contains an unrolled `transcript.txt`, machine-readable
+`frames.json`, an asciicast v2 `session.cast`, a final `snapshot.svg`, and a
+self-contained animated `recording.svg`. Exact consecutive repaints are
+deduplicated, while repeated content after an intervening state remains in the
+transcript. Scrolled-off terminal history is retained.
+
+Set `settleMilliseconds` to tune repaint coalescing. For opt-in diagnostics,
+pass `onTrace(event)`; capture is silent by default.
+
### Async Iteration (Real-time Streaming)
```javascript
diff --git a/js/bun.lock b/js/bun.lock
index 31df98f..a7ef75c 100644
--- a/js/bun.lock
+++ b/js/bun.lock
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "command-stream",
+ "dependencies": {
+ "@xterm/headless": "^6.0.0",
+ "node-pty": "^1.2.0-beta.14",
+ },
"devDependencies": {
"@changesets/cli": "^2.29.7",
"eslint": "^9.38.0",
@@ -119,6 +123,8 @@
"@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="],
+ "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -397,6 +403,10 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
+ "node-pty": ["node-pty@1.2.0-beta.14", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-XORU9BQgpxVgqr7WivjJ17mLenOHUgKKWzuZZNaw3NDYgHc/wPJQMSaoLDrpEgqV6aU1nNwil1o/OqYj6lWmUA=="],
+
"node-sarif-builder": ["node-sarif-builder@2.0.3", "", { "dependencies": { "@types/sarif": "^2.1.4", "fs-extra": "^10.0.0" } }, "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
diff --git a/js/package-lock.json b/js/package-lock.json
index 0f94a22..d6712a4 100644
--- a/js/package-lock.json
+++ b/js/package-lock.json
@@ -8,6 +8,10 @@
"name": "command-stream",
"version": "0.14.1",
"license": "Unlicense",
+ "dependencies": {
+ "@xterm/headless": "^6.0.0",
+ "node-pty": "^1.1.0"
+ },
"devDependencies": {
"@changesets/cli": "^2.29.7",
"eslint": "^9.38.0",
@@ -770,6 +774,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@xterm/headless": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
+ "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
"node_modules/acorn": {
"version": "8.15.0",
"dev": true,
@@ -2411,6 +2424,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
+ "node_modules/node-pty": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
+ "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^7.1.0"
+ }
+ },
"node_modules/node-sarif-builder": {
"version": "2.0.3",
"dev": true,
diff --git a/js/package.json b/js/package.json
index 7ed00c4..f95484c 100644
--- a/js/package.json
+++ b/js/package.json
@@ -81,5 +81,9 @@
"prettier --write",
"prettier --check"
]
+ },
+ "dependencies": {
+ "@xterm/headless": "^6.0.0",
+ "node-pty": "^1.2.0-beta.14"
}
}
diff --git a/js/src/$.mjs b/js/src/$.mjs
index 2b3be17..c028519 100755
--- a/js/src/$.mjs
+++ b/js/src/$.mjs
@@ -18,6 +18,11 @@ import {
getAnsiConfig,
processOutput,
} from './$.ansi.mjs';
+import {
+ captureTerminal,
+ readAsciicast,
+ unrollTerminalFrames,
+} from './terminal-capture.mjs';
// Import ProcessRunner base and method modules
import { ProcessRunner } from './$.process-runner-base.mjs';
@@ -458,5 +463,8 @@ export {
getAnsiConfig,
processOutput,
forceCleanupAll,
+ captureTerminal,
+ readAsciicast,
+ unrollTerminalFrames,
};
export default $tagged;
diff --git a/js/src/terminal-artifacts.mjs b/js/src/terminal-artifacts.mjs
new file mode 100644
index 0000000..49d3322
--- /dev/null
+++ b/js/src/terminal-artifacts.mjs
@@ -0,0 +1,185 @@
+const EVENT_TYPES = {
+ i: 'input',
+ o: 'output',
+ r: 'resize',
+};
+
+const escapeXml = (value) =>
+ String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+
+const trimBlankEdges = (lines) => {
+ let first = 0;
+ let last = lines.length;
+ while (first < last && lines[first] === '') {
+ first += 1;
+ }
+ while (last > first && lines[last - 1] === '') {
+ last -= 1;
+ }
+ return lines.slice(first, last);
+};
+
+const overlapLength = (previous, current) => {
+ const maximum = Math.min(previous.length, current.length);
+ for (let size = maximum; size > 0; size -= 1) {
+ if (
+ previous
+ .slice(previous.length - size)
+ .every((line, index) => line === current[index])
+ ) {
+ return size;
+ }
+ }
+ return 0;
+};
+
+/**
+ * Convert settled terminal states into a readable, chronological transcript.
+ *
+ * Prefixes and suffixes already present in the immediately preceding state are
+ * collapsed. Content that appears again after another state is retained.
+ */
+export const unrollTerminalFrames = (frames) => {
+ const transcript = [];
+ let previous = [];
+
+ for (const frame of frames) {
+ const current = trimBlankEdges(frame.lines);
+ if (
+ current.length === previous.length &&
+ current.every((line, index) => line === previous[index])
+ ) {
+ continue;
+ }
+
+ const overlap = overlapLength(previous, current);
+ if (overlap > 0) {
+ transcript.push(...current.slice(overlap));
+ } else {
+ let commonPrefix = 0;
+ while (
+ commonPrefix < previous.length &&
+ commonPrefix < current.length &&
+ previous[commonPrefix] === current[commonPrefix]
+ ) {
+ commonPrefix += 1;
+ }
+ transcript.push(...current.slice(commonPrefix));
+ }
+ previous = current;
+ }
+
+ return trimBlankEdges(transcript).join('\n');
+};
+
+const svgText = (lines) =>
+ lines
+ .map(
+ (line, index) =>
+ `${escapeXml(line || ' ')}`
+ )
+ .join('');
+
+const svgShell = ({ width, height, body }) =>
+ [
+ '',
+ `',
+ ].join('');
+
+const renderSnapshotSvg = (frame) => {
+ const width = frame.cols * 9 + 24;
+ const height = frame.rows * 18 + 18;
+ return svgShell({
+ width,
+ height,
+ body: svgText(frame.screen),
+ });
+};
+
+const renderRecordingSvg = (frames) => {
+ const columns = Math.max(...frames.map((frame) => frame.cols), 1);
+ const rows = Math.max(...frames.map((frame) => frame.rows), 1);
+ const width = columns * 9 + 24;
+ const height = rows * 18 + 18;
+ const frameCount = Math.max(frames.length, 1);
+ const duration = Math.max(frameCount * 0.35, 0.35);
+ const keyTimes = Array.from(
+ { length: frameCount + 1 },
+ (_, index) => index / frameCount
+ ).join(';');
+
+ const groups = frames
+ .map((frame, frameIndex) => {
+ const values = Array.from({ length: frameCount + 1 }, (_, index) =>
+ index === frameIndex || (index === frameCount && frameIndex === 0)
+ ? 1
+ : 0
+ ).join(';');
+ return [
+ '',
+ ``,
+ svgText(frame.screen),
+ '',
+ ].join('');
+ })
+ .join('');
+
+ return svgShell({ width, height, body: groups });
+};
+
+export const serializeAsciicast = ({ header, events }) => {
+ const lines = [
+ JSON.stringify(header),
+ ...events.map((event) =>
+ JSON.stringify([event.time, event.code, event.data])
+ ),
+ ];
+ return `${lines.join('\n')}\n`;
+};
+
+export const readAsciicast = async (path) => {
+ const { readFile } = await import('node:fs/promises');
+ const lines = (await readFile(path, 'utf8')).trimEnd().split('\n');
+ const header = JSON.parse(lines.shift());
+ const events = lines.filter(Boolean).map((line) => {
+ const [time, code, data] = JSON.parse(line);
+ return { time, type: EVENT_TYPES[code] ?? code, data };
+ });
+ return { header, events };
+};
+
+export const writeTerminalArtifacts = async ({
+ directory,
+ frames,
+ transcript,
+ asciicast,
+}) => {
+ const { mkdir, writeFile } = await import('node:fs/promises');
+ const { join } = await import('node:path');
+ const finalFrame = frames.at(-1) ?? {
+ cols: asciicast.header.width,
+ rows: asciicast.header.height,
+ screen: [],
+ };
+
+ await mkdir(directory, { recursive: true });
+ await Promise.all([
+ writeFile(join(directory, 'transcript.txt'), `${transcript}\n`),
+ writeFile(
+ join(directory, 'frames.json'),
+ `${JSON.stringify(frames, null, 2)}\n`
+ ),
+ writeFile(join(directory, 'session.cast'), serializeAsciicast(asciicast)),
+ writeFile(join(directory, 'snapshot.svg'), renderSnapshotSvg(finalFrame)),
+ writeFile(join(directory, 'recording.svg'), renderRecordingSvg(frames)),
+ ]);
+};
diff --git a/js/src/terminal-capture.mjs b/js/src/terminal-capture.mjs
new file mode 100644
index 0000000..3b531a2
--- /dev/null
+++ b/js/src/terminal-capture.mjs
@@ -0,0 +1,357 @@
+import {
+ readAsciicast,
+ unrollTerminalFrames,
+ writeTerminalArtifacts,
+} from './terminal-artifacts.mjs';
+import { spawnTerminalPty } from './terminal-pty.mjs';
+
+const KEY_SEQUENCES = {
+ BACKSPACE: '\u007f',
+ CTRL_C: '\u0003',
+ CTRL_D: '\u0004',
+ DOWN: '\u001b[B',
+ ENTER: '\r',
+ ESCAPE: '\u001b',
+ LEFT: '\u001b[D',
+ RIGHT: '\u001b[C',
+ TAB: '\t',
+ UP: '\u001b[A',
+};
+const CLEAR_SCREEN = '\u001b[2J\u001b[H';
+
+const splitRenderSegments = (data) => {
+ const pieces = data.split(CLEAR_SCREEN);
+ if (pieces.length === 1) {
+ return [data];
+ }
+
+ const segments = pieces.slice(1).map((piece) => `${CLEAR_SCREEN}${piece}`);
+ if (pieces[0]) {
+ segments.unshift(pieces[0]);
+ }
+ return segments;
+};
+
+const normalizeLines = (lines) => {
+ let last = lines.length;
+ while (last > 0 && lines[last - 1] === '') {
+ last -= 1;
+ }
+ return lines.slice(0, last);
+};
+
+const terminalFrame = (terminal, elapsed) => {
+ const buffer = terminal.buffer.active;
+ const allLines = Array.from(
+ { length: buffer.length },
+ (_, index) => buffer.getLine(index)?.translateToString(true).trimEnd() ?? ''
+ );
+ const screen = Array.from(
+ { length: terminal.rows },
+ (_, index) =>
+ buffer
+ .getLine(buffer.viewportY + index)
+ ?.translateToString(true)
+ .trimEnd() ?? ''
+ );
+ return {
+ time: elapsed(),
+ cols: terminal.cols,
+ rows: terminal.rows,
+ cursor: {
+ x: buffer.cursorX,
+ y: buffer.cursorY,
+ },
+ alternate: buffer === terminal.buffer.alternate,
+ lines: normalizeLines(allLines),
+ screen: normalizeLines(screen),
+ };
+};
+
+const sameFrame = (left, right) =>
+ left.cols === right.cols &&
+ left.rows === right.rows &&
+ left.cursor.x === right.cursor.x &&
+ left.cursor.y === right.cursor.y &&
+ left.alternate === right.alternate &&
+ left.lines.length === right.lines.length &&
+ left.lines.every((line, index) => line === right.lines[index]);
+
+const runtimeDependencies = async () => {
+ const xtermModule = await import('@xterm/headless');
+ return {
+ Terminal: (xtermModule.default ?? xtermModule).Terminal,
+ };
+};
+
+const captureEnvironment = (environment) =>
+ Object.fromEntries(
+ Object.entries(environment)
+ .filter(([, value]) => value !== undefined)
+ .map(([name, value]) => [name, String(value)])
+ );
+
+const createAsciicast = ({ cols, rows, file, environment }) => ({
+ header: {
+ version: 2,
+ width: cols,
+ height: rows,
+ timestamp: Math.floor(Date.now() / 1000),
+ env: {
+ SHELL: file,
+ TERM: environment.TERM,
+ },
+ },
+ events: [],
+});
+
+const createCaptureRecorder = (asciicast, onTrace) => {
+ const startedAt = Date.now();
+ const elapsed = () => Number(((Date.now() - startedAt) / 1000).toFixed(6));
+ return {
+ elapsed,
+ record: (code, data) => {
+ asciicast.events.push({ time: elapsed(), code, data });
+ },
+ trace: (type, details = {}) => {
+ onTrace?.({ time: elapsed(), type, ...details });
+ },
+ };
+};
+
+const interactionAfter = (interaction, output) =>
+ interaction.after === undefined || output.includes(interaction.after);
+
+const applyInteraction = ({ interaction, process, terminal, record }) => {
+ if (interaction.text !== undefined) {
+ const text = String(interaction.text);
+ process.write(text);
+ record('i', text);
+ }
+ if (interaction.key !== undefined) {
+ const sequence = KEY_SEQUENCES[interaction.key] ?? interaction.key;
+ process.write(sequence);
+ record('i', sequence);
+ }
+ if (interaction.resize !== undefined) {
+ const { cols, rows } = interaction.resize;
+ process.resize(cols, rows);
+ terminal.resize(cols, rows);
+ record('r', `${cols}x${rows}`);
+ }
+};
+
+const startTerminal = async ({ file, args, cwd, env, cols, rows }) => {
+ const { Terminal } = await runtimeDependencies();
+ const environment = captureEnvironment({
+ ...env,
+ TERM: env.TERM ?? 'xterm-256color',
+ });
+ const terminal = new Terminal({
+ cols,
+ rows,
+ scrollback: 100_000,
+ allowProposedApi: true,
+ });
+ const child = await spawnTerminalPty(file, args.map(String), {
+ name: environment.TERM,
+ cols,
+ rows,
+ cwd,
+ env: environment,
+ });
+ return { child, environment, terminal };
+};
+
+const captureResult = ({
+ status,
+ output,
+ transcript,
+ frames,
+ interactionCount,
+ asciicast,
+}) => ({
+ ...status,
+ output,
+ transcript,
+ frames,
+ interactionCount,
+ asciicast,
+});
+
+const persistArtifacts = async ({
+ artifactDirectory,
+ frames,
+ transcript,
+ asciicast,
+}) => {
+ if (artifactDirectory) {
+ await writeTerminalArtifacts({
+ directory: artifactDirectory,
+ frames,
+ transcript,
+ asciicast,
+ });
+ }
+};
+
+/**
+ * Run a command inside a real pseudoterminal and retain its settled TUI states.
+ */
+export const captureTerminal = async ({
+ file,
+ args = [],
+ cwd = process.cwd(),
+ env = process.env,
+ cols = 80,
+ rows = 24,
+ settleMilliseconds = 35,
+ interactions = [],
+ stopMarker,
+ stopMarkerGraceMilliseconds = 250,
+ timeoutMilliseconds = 30_000,
+ artifactDirectory,
+ onTrace,
+} = {}) => {
+ if (!file) {
+ throw new TypeError('captureTerminal requires a file');
+ }
+
+ const { child, environment, terminal } = await startTerminal({
+ file,
+ args,
+ cwd,
+ env,
+ cols,
+ rows,
+ });
+ const asciicast = createAsciicast({ cols, rows, file, environment });
+ const {
+ elapsed,
+ record,
+ trace: traceCapture,
+ } = createCaptureRecorder(asciicast, onTrace);
+ const frames = [];
+ let output = '';
+ let interactionIndex = 0;
+ let settleTimer;
+ let stopTimer;
+ let stopMarkerSeen = false;
+ let writeQueue = Promise.resolve();
+ let interactionScheduled = false;
+ let captureError;
+
+ const appendFrame = () => {
+ const frame = terminalFrame(terminal, elapsed);
+ if (!frames.at(-1) || !sameFrame(frames.at(-1), frame)) {
+ frames.push(frame);
+ }
+ };
+ const settle = () => {
+ clearTimeout(settleTimer);
+ settleTimer = setTimeout(appendFrame, settleMilliseconds);
+ };
+ const queueOutput = (data) => {
+ const segments = splitRenderSegments(data);
+ for (const [index, segment] of segments.entries()) {
+ writeQueue = writeQueue.then(
+ () =>
+ new Promise((written) => {
+ terminal.write(segment, written);
+ })
+ );
+ if (index < segments.length - 1) {
+ writeQueue = writeQueue.then(appendFrame);
+ }
+ }
+ };
+ const advanceInteractions = () => {
+ if (
+ interactionScheduled ||
+ interactionIndex >= interactions.length ||
+ !interactionAfter(interactions[interactionIndex], output)
+ ) {
+ return;
+ }
+
+ interactionScheduled = true;
+ traceCapture('interaction-scheduled', { interactionIndex });
+ writeQueue.then(() => {
+ appendFrame();
+ traceCapture('interaction-applied', { interactionIndex });
+ applyInteraction({
+ interaction: interactions[interactionIndex],
+ process: child,
+ terminal,
+ record,
+ });
+ interactionIndex += 1;
+ interactionScheduled = false;
+ advanceInteractions();
+ });
+ };
+
+ const completion = new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ captureError = new Error(
+ `Terminal command timed out after ${timeoutMilliseconds} ms`
+ );
+ traceCapture('timeout', { timeoutMilliseconds });
+ child.kill('SIGTERM');
+ }, timeoutMilliseconds);
+
+ child.onData((data) => {
+ traceCapture('output', { data });
+ output += data;
+ record('o', data);
+ queueOutput(data);
+ writeQueue.then(settle);
+ advanceInteractions();
+
+ if (stopMarker && output.includes(stopMarker) && !stopMarkerSeen) {
+ stopMarkerSeen = true;
+ writeQueue.then(appendFrame);
+ stopTimer = setTimeout(
+ () => child.kill('SIGTERM'),
+ stopMarkerGraceMilliseconds
+ );
+ }
+ });
+ child.onExit(({ exitCode, signal, error }) => {
+ traceCapture('exit', { exitCode, signal });
+ clearTimeout(timeout);
+ clearTimeout(stopTimer);
+ captureError ??= error;
+ resolve({ exitCode, signal });
+ });
+ });
+
+ let status;
+ try {
+ status = await completion;
+ await writeQueue;
+ clearTimeout(settleTimer);
+ appendFrame();
+ } finally {
+ terminal.dispose();
+ }
+
+ const transcript = unrollTerminalFrames(frames);
+ await persistArtifacts({ artifactDirectory, frames, transcript, asciicast });
+
+ const capture = captureResult({
+ status,
+ output,
+ transcript,
+ frames,
+ interactionCount: interactionIndex,
+ asciicast,
+ });
+ if (captureError) {
+ captureError.capture = capture;
+ throw captureError;
+ }
+ return capture;
+};
+
+export { readAsciicast, unrollTerminalFrames };
diff --git a/js/src/terminal-pty-host-platform.mjs b/js/src/terminal-pty-host-platform.mjs
new file mode 100644
index 0000000..03211f1
--- /dev/null
+++ b/js/src/terminal-pty-host-platform.mjs
@@ -0,0 +1,14 @@
+export const stopTerminal = (
+ terminal,
+ signal = 'SIGTERM',
+ platform = process.platform
+) => {
+ if (!terminal) {
+ return;
+ }
+ if (platform === 'win32') {
+ terminal.kill();
+ return;
+ }
+ terminal.kill(signal);
+};
diff --git a/js/src/terminal-pty-host.mjs b/js/src/terminal-pty-host.mjs
new file mode 100644
index 0000000..4e46770
--- /dev/null
+++ b/js/src/terminal-pty-host.mjs
@@ -0,0 +1,33 @@
+import ptyModule from 'node-pty';
+import { createInterface } from 'node:readline';
+
+import { stopTerminal } from './terminal-pty-host-platform.mjs';
+
+const send = (message, callback) => {
+ process.stdout.write(`${JSON.stringify(message)}\n`, callback);
+};
+
+let terminal;
+const messages = createInterface({ input: process.stdin });
+
+messages.on('line', (line) => {
+ const message = JSON.parse(line);
+ if (message.type === 'spawn') {
+ terminal = ptyModule.spawn(message.file, message.args, message.options);
+ terminal.onData((data) => send({ type: 'data', data }));
+ terminal.onExit(({ exitCode, signal }) => {
+ send({ type: 'exit', exitCode, signal }, () => process.exit(0));
+ });
+ send({ type: 'ready' });
+ } else if (message.type === 'input') {
+ terminal.write(message.data);
+ } else if (message.type === 'resize') {
+ terminal.resize(message.cols, message.rows);
+ } else if (message.type === 'kill') {
+ stopTerminal(terminal, message.signal);
+ }
+});
+
+messages.on('close', () => {
+ stopTerminal(terminal);
+});
diff --git a/js/src/terminal-pty.mjs b/js/src/terminal-pty.mjs
new file mode 100644
index 0000000..e679d80
--- /dev/null
+++ b/js/src/terminal-pty.mjs
@@ -0,0 +1,140 @@
+const parseMessages = (stream, receive) => {
+ let pending = '';
+ stream.setEncoding('utf8');
+ stream.on('data', (chunk) => {
+ pending += chunk;
+ const lines = pending.split('\n');
+ pending = lines.pop();
+ for (const line of lines) {
+ if (line) {
+ receive(JSON.parse(line));
+ }
+ }
+ });
+};
+
+export const spawnTerminalPty = async (
+ file,
+ args,
+ options,
+ { hostPath: hostPathOverride, nodeBinary: nodeBinaryOverride } = {}
+) => {
+ const [{ spawn }, { dirname }, { fileURLToPath }] = await Promise.all([
+ import('node:child_process'),
+ import('node:path'),
+ import('node:url'),
+ ]);
+ const hostPath =
+ hostPathOverride ??
+ fileURLToPath(new URL('./terminal-pty-host.mjs', import.meta.url));
+ const nodeBinary =
+ nodeBinaryOverride ?? process.env.COMMAND_STREAM_NODE_BINARY ?? 'node';
+ const host = spawn(nodeBinary, [hostPath], {
+ cwd: dirname(hostPath),
+ env: process.env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ const dataListeners = new Set();
+ const exitListeners = new Set();
+ const pendingData = [];
+ let pendingExit;
+ let hostErrors = '';
+ let exited = false;
+ let terminalExitReceived = false;
+ let resolveReady;
+ let rejectReady;
+ const ready = new Promise((resolve, reject) => {
+ resolveReady = resolve;
+ rejectReady = reject;
+ });
+
+ const send = (message) => {
+ host.stdin.write(`${JSON.stringify(message)}\n`);
+ };
+ parseMessages(host.stdout, (message) => {
+ if (message.type === 'data') {
+ if (dataListeners.size === 0) {
+ pendingData.push(message.data);
+ } else {
+ for (const listener of dataListeners) {
+ listener(message.data);
+ }
+ }
+ } else if (message.type === 'exit') {
+ terminalExitReceived = true;
+ if (exitListeners.size === 0) {
+ pendingExit = message;
+ } else {
+ for (const listener of exitListeners) {
+ listener(message);
+ }
+ }
+ } else if (message.type === 'ready') {
+ resolveReady();
+ }
+ });
+ host.stderr.setEncoding('utf8');
+ host.stderr.on('data', (chunk) => {
+ hostErrors += chunk;
+ });
+ host.on('error', (error) => {
+ if (!exited) {
+ exited = true;
+ rejectReady(error);
+ const message = { exitCode: 1, signal: 0, error };
+ if (exitListeners.size === 0) {
+ pendingExit = message;
+ } else {
+ for (const listener of exitListeners) {
+ listener(message);
+ }
+ }
+ }
+ });
+ host.on('exit', (exitCode) => {
+ if (!terminalExitReceived && !exited) {
+ exited = true;
+ const error = new Error(
+ `PTY host exited with code ${exitCode}: ${hostErrors.trim()}`
+ );
+ rejectReady(error);
+ const message = { exitCode: exitCode ?? 1, signal: 0, error };
+ if (exitListeners.size === 0) {
+ pendingExit = message;
+ } else {
+ for (const listener of exitListeners) {
+ listener(message);
+ }
+ }
+ }
+ });
+
+ send({ type: 'spawn', file, args, options });
+ await ready;
+ return {
+ onData(listener) {
+ dataListeners.add(listener);
+ for (const data of pendingData.splice(0)) {
+ listener(data);
+ }
+ return { dispose: () => dataListeners.delete(listener) };
+ },
+ onExit(listener) {
+ exitListeners.add(listener);
+ if (pendingExit) {
+ listener(pendingExit);
+ pendingExit = undefined;
+ }
+ return { dispose: () => exitListeners.delete(listener) };
+ },
+ write(data) {
+ send({ type: 'input', data });
+ },
+ resize(cols, rows) {
+ send({ type: 'resize', cols, rows });
+ },
+ kill(signal = 'SIGTERM') {
+ send({ type: 'kill', signal });
+ },
+ };
+};
diff --git a/js/tests/fixtures/pty-host-exit.mjs b/js/tests/fixtures/pty-host-exit.mjs
new file mode 100644
index 0000000..352849c
--- /dev/null
+++ b/js/tests/fixtures/pty-host-exit.mjs
@@ -0,0 +1,2 @@
+process.stderr.write('fixture exited before ready\n');
+process.exit(7);
diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs
new file mode 100644
index 0000000..652b9ea
--- /dev/null
+++ b/js/tests/fixtures/tui-capture-fixture.mjs
@@ -0,0 +1,42 @@
+const clear = '\u001b[2J\u001b[H';
+const pause = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+process.stdin.setRawMode?.(true);
+process.stdin.resume();
+
+const render = (body) => {
+ process.stdout.write(`${clear}${body}`);
+};
+
+let input = '';
+process.stdin.on('data', (chunk) => {
+ for (const character of chunk.toString()) {
+ if (character === '\r') {
+ render(`typed:${input}`);
+ input = '';
+ } else {
+ input += character;
+ }
+ }
+});
+
+const initialDimensions = `${process.stdout.columns}x${process.stdout.rows}`;
+const resizeWatcher = setInterval(() => {
+ const dimensions = `${process.stdout.columns}x${process.stdout.rows}`;
+ if (dimensions !== initialDimensions) {
+ clearInterval(resizeWatcher);
+ render(`resized:${dimensions}`);
+ setTimeout(() => process.exit(0), 20);
+ }
+}, 5);
+
+render(
+ `ready:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}`
+);
+for (let index = 0; index < 8; index += 1) {
+ await pause(5);
+ render(
+ `ready:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}`
+ );
+}
diff --git a/js/tests/fixtures/tui-hang-fixture.mjs b/js/tests/fixtures/tui-hang-fixture.mjs
new file mode 100644
index 0000000..6050b52
--- /dev/null
+++ b/js/tests/fixtures/tui-hang-fixture.mjs
@@ -0,0 +1,2 @@
+process.stdout.write('waiting for input');
+setInterval(() => {}, 1_000);
diff --git a/js/tests/fixtures/tui-scroll-fixture.mjs b/js/tests/fixtures/tui-scroll-fixture.mjs
new file mode 100644
index 0000000..8395460
--- /dev/null
+++ b/js/tests/fixtures/tui-scroll-fixture.mjs
@@ -0,0 +1,7 @@
+const pause = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+for (const line of ['alpha', 'beta', 'gamma', 'delta', 'epsilon']) {
+ process.stdout.write(`${line}\r\n`);
+ await pause(15);
+}
diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs
new file mode 100644
index 0000000..d1ad083
--- /dev/null
+++ b/js/tests/terminal-capture.test.mjs
@@ -0,0 +1,151 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import {
+ captureTerminal,
+ readAsciicast,
+ unrollTerminalFrames,
+} from '../src/$.mjs';
+import { stopTerminal } from '../src/terminal-pty-host-platform.mjs';
+import { spawnTerminalPty } from '../src/terminal-pty.mjs';
+
+const directory = dirname(fileURLToPath(import.meta.url));
+const temporaryDirectories = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories
+ .splice(0)
+ .map((path) => rm(path, { force: true, recursive: true }))
+ );
+});
+
+describe('PTY terminal capture', () => {
+ test('reports a PTY host that exits before its ready handshake', async () => {
+ await expect(
+ spawnTerminalPty(
+ process.execPath,
+ [join(directory, 'fixtures/pty-host-exit.mjs')],
+ {},
+ {
+ hostPath: join(directory, 'fixtures/pty-host-exit.mjs'),
+ nodeBinary: process.execPath,
+ }
+ )
+ ).rejects.toThrow('fixture exited before ready');
+ });
+
+ test('stops Windows terminals without passing an unsupported signal', () => {
+ const calls = [];
+ const terminal = {
+ kill(...args) {
+ calls.push(args);
+ },
+ };
+
+ stopTerminal(terminal, 'SIGTERM', 'win32');
+
+ expect(calls).toEqual([[]]);
+ });
+
+ test('drives input and resize while retaining settled, deduplicated frames', async () => {
+ const artifactDirectory = await mkdtemp(
+ join(tmpdir(), 'command-stream-tui-')
+ );
+ temporaryDirectories.push(artifactDirectory);
+
+ const capture = await captureTerminal({
+ file: process.execPath,
+ args: [join(directory, 'fixtures/tui-capture-fixture.mjs')],
+ cols: 20,
+ rows: 4,
+ settleMilliseconds: 10,
+ interactions: [
+ { after: 'ready:true:20x4', text: 'hello', key: 'ENTER' },
+ { after: 'typed:hello', resize: { cols: 32, rows: 6 } },
+ ],
+ stopMarker: 'resized:32x6',
+ artifactDirectory,
+ });
+
+ expect(capture.exitCode).toBe(0);
+ expect(capture.interactionCount).toBe(2);
+ expect(capture.transcript).toContain('ready:true:20x4');
+ expect(capture.transcript).toContain('typed:hello');
+ expect(capture.transcript).toContain('resized:32x6');
+ expect(capture.frames.length).toBeLessThan(8);
+
+ const replay = await readAsciicast(join(artifactDirectory, 'session.cast'));
+ expect(replay.header.width).toBe(20);
+ expect(replay.events.some((event) => event.type === 'resize')).toBe(true);
+
+ for (const artifact of [
+ 'transcript.txt',
+ 'frames.json',
+ 'session.cast',
+ 'snapshot.svg',
+ 'recording.svg',
+ ]) {
+ expect(
+ (await readFile(join(artifactDirectory, artifact))).length
+ ).toBeGreaterThan(0);
+ }
+ expect(
+ await readFile(join(artifactDirectory, 'recording.svg'), 'utf8')
+ ).toContain(' {
+ const capture = await captureTerminal({
+ file: process.execPath,
+ args: [join(directory, 'fixtures/tui-scroll-fixture.mjs')],
+ cols: 20,
+ rows: 3,
+ settleMilliseconds: 5,
+ });
+
+ expect(capture.transcript).toContain('alpha\nbeta\ngamma\ndelta\nepsilon');
+ for (const line of ['alpha', 'beta', 'gamma', 'delta', 'epsilon']) {
+ expect(capture.transcript.split(line)).toHaveLength(2);
+ }
+ });
+
+ test('retains repeated content when another state appeared between it', () => {
+ const frame = (lines) => ({ lines });
+ expect(
+ unrollTerminalFrames([
+ frame(['same']),
+ frame(['different']),
+ frame(['same']),
+ ])
+ ).toBe('same\ndifferent\nsame');
+ });
+
+ test('writes partial artifacts when a terminal command times out', async () => {
+ const artifactDirectory = await mkdtemp(
+ join(tmpdir(), 'command-stream-tui-timeout-')
+ );
+ temporaryDirectories.push(artifactDirectory);
+
+ let failure;
+ try {
+ await captureTerminal({
+ file: process.execPath,
+ args: [join(directory, 'fixtures/tui-hang-fixture.mjs')],
+ timeoutMilliseconds: 250,
+ artifactDirectory,
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure?.message).toContain('timed out');
+ expect(failure?.capture.transcript).toContain('waiting for input');
+ expect(
+ (await readFile(join(artifactDirectory, 'recording.svg'))).length
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 8734f6f..5b5240b 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -26,6 +26,18 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
[[package]]
name = "assert_cmd"
version = "2.1.1"
@@ -80,6 +92,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -125,6 +143,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
+
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -154,15 +178,17 @@ dependencies = [
"filetime",
"glob",
"libc",
- "nix",
+ "nix 0.29.0",
"once_cell",
+ "portable-pty",
"regex",
"serde",
"serde_json",
"tempfile",
- "thiserror",
+ "thiserror 2.0.17",
"tokio",
"tokio-test",
+ "vt100",
"which",
]
@@ -178,6 +204,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
[[package]]
name = "either"
version = "1.15.0"
@@ -206,6 +238,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
[[package]]
name = "filetime"
version = "0.2.26"
@@ -288,6 +331,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "libc"
version = "0.2.178"
@@ -300,7 +349,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"libc",
"redox_syscall 0.7.0",
]
@@ -343,15 +392,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nix"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "cfg_aliases 0.1.1",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"cfg-if",
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"libc",
]
@@ -399,6 +460,27 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+[[package]]
+name = "portable-pty"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
+dependencies = [
+ "anyhow",
+ "bitflags 1.3.2",
+ "downcast-rs",
+ "filedescriptor",
+ "lazy_static",
+ "libc",
+ "log",
+ "nix 0.28.0",
+ "serial2",
+ "shared_library",
+ "shell-words",
+ "winapi",
+ "winreg",
+]
+
[[package]]
name = "predicates"
version = "3.1.3"
@@ -456,7 +538,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
]
[[package]]
@@ -465,7 +547,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
]
[[package]]
@@ -503,7 +585,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
@@ -565,6 +647,33 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serial2"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "shared_library"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
+dependencies = [
+ "lazy_static",
+ "libc",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -627,13 +736,33 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 2.0.17",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
@@ -705,6 +834,51 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vt100"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de"
+dependencies = [
+ "itoa",
+ "log",
+ "unicode-width",
+ "vte",
+]
+
+[[package]]
+name = "vte"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
+dependencies = [
+ "arrayvec",
+ "utf8parse",
+ "vte_generate_state_changes",
+]
+
+[[package]]
+name = "vte_generate_state_changes"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -786,6 +960,28 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -928,6 +1124,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+[[package]]
+name = "winreg"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "winsafe"
version = "0.0.19"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 9490a40..dee0e14 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -24,14 +24,16 @@ async-trait = "0.1"
thiserror = "2.0"
once_cell = "1.20"
regex = "1.11"
-serde = { version = "1.0", features = ["derive"], optional = true }
-serde_json = { version = "1.0", optional = true }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
nix = { version = "0.29", features = ["signal", "process"] }
libc = "0.2"
which = "7.0"
glob = "0.3"
chrono = "0.4"
filetime = "0.2"
+portable-pty = "0.9"
+vt100 = "0.15"
[dev-dependencies]
tokio-test = "0.4"
@@ -40,7 +42,7 @@ assert_cmd = "2.0"
[features]
default = []
-json = ["serde", "serde_json"]
+json = []
[profile.release]
opt-level = 3
diff --git a/rust/README.md b/rust/README.md
index af774bd..589cdac 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -88,6 +88,39 @@ The crate also builds a `command-stream` binary:
cargo run -- echo hello
```
+## TUI Capture
+
+`capture_terminal` runs an interactive program in a real pseudoterminal and
+uses `vt100` to retain settled terminal states:
+
+```rust
+use command_stream::terminal::{
+ capture_terminal, TerminalCaptureOptions, TerminalInteraction, TerminalKey,
+};
+
+let capture = capture_terminal(TerminalCaptureOptions {
+ file: "codex".into(),
+ args: vec!["--no-alt-screen".into()],
+ interactions: vec![TerminalInteraction {
+ text: Some("Inspect the failing test".into()),
+ key: Some(TerminalKey::Enter),
+ ..TerminalInteraction::default()
+ }],
+ artifact_directory: Some("artifacts/codex".into()),
+ ..TerminalCaptureOptions::default()
+})?;
+
+println!("{}", capture.transcript);
+# Ok::<(), command_stream::terminal::TerminalCaptureError>(())
+```
+
+The capture contains the raw PTY output, consecutive-deduplicated frames, an
+ordered unrolled transcript, and asciicast v2 input/output/resize events. The
+artifact directory receives `transcript.txt`, `frames.json`, `session.cast`,
+`snapshot.svg`, and an animated `recording.svg`; timeout errors retain the
+partial capture and those diagnostic files. Use `capture_terminal_async` from
+an async application.
+
## Features
- Shell parser for pipelines, command lists, logical operators, and redirection.
diff --git a/rust/changelog.d/20260724_221500_terminal_capture.md b/rust/changelog.d/20260724_221500_terminal_capture.md
new file mode 100644
index 0000000..1c34e38
--- /dev/null
+++ b/rust/changelog.d/20260724_221500_terminal_capture.md
@@ -0,0 +1,9 @@
+---
+bump: minor
+---
+
+### Added
+
+- Added PTY-backed terminal capture with input and resize controls, settled
+ frames, unrolled transcripts, asciicast v2 recordings, SVG artifacts, and
+ partial timeout diagnostics.
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 6157d1c..5544620 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -70,6 +70,7 @@ pub mod pipeline;
pub mod quote;
pub mod state;
pub mod stream;
+pub mod terminal;
pub mod trace;
// Core modules
diff --git a/rust/src/terminal/artifacts.rs b/rust/src/terminal/artifacts.rs
new file mode 100644
index 0000000..d7d14cc
--- /dev/null
+++ b/rust/src/terminal/artifacts.rs
@@ -0,0 +1,211 @@
+use super::types::{
+ Asciicast, AsciicastEvent, AsciicastHeader, TerminalCaptureError, TerminalFrame,
+};
+use std::fs;
+use std::path::Path;
+
+fn trim_blank_edges(lines: &[String]) -> &[String] {
+ let first = lines
+ .iter()
+ .position(|line| !line.is_empty())
+ .unwrap_or(lines.len());
+ let last = lines
+ .iter()
+ .rposition(|line| !line.is_empty())
+ .map_or(first, |index| index + 1);
+ &lines[first..last]
+}
+
+fn overlap_length(previous: &[String], current: &[String]) -> usize {
+ let maximum = previous.len().min(current.len());
+ (1..=maximum)
+ .rev()
+ .find(|size| previous[previous.len() - size..] == current[..*size])
+ .unwrap_or(0)
+}
+
+pub fn unroll_terminal_frames(frames: &[TerminalFrame]) -> String {
+ let mut transcript = Vec::new();
+ let mut previous: &[String] = &[];
+
+ for frame in frames {
+ let current = trim_blank_edges(&frame.lines);
+ if current == previous {
+ continue;
+ }
+
+ let overlap = overlap_length(previous, current);
+ if overlap > 0 {
+ transcript.extend_from_slice(¤t[overlap..]);
+ } else {
+ let common_prefix = previous
+ .iter()
+ .zip(current)
+ .take_while(|(left, right)| left == right)
+ .count();
+ transcript.extend_from_slice(¤t[common_prefix..]);
+ }
+ previous = current;
+ }
+
+ trim_blank_edges(&transcript).join("\n")
+}
+
+fn escape_xml(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+fn svg_text(lines: &[String]) -> String {
+ lines
+ .iter()
+ .enumerate()
+ .map(|(index, line)| {
+ format!(
+ r#"{}"#,
+ 24 + index * 18,
+ escape_xml(if line.is_empty() { " " } else { line })
+ )
+ })
+ .collect()
+}
+
+fn svg_shell(width: usize, height: usize, body: &str) -> String {
+ format!(
+ concat!(
+ r#""#,
+ r#""
+ ),
+ width, height, body
+ )
+}
+
+fn render_snapshot_svg(frame: &TerminalFrame) -> String {
+ svg_shell(
+ usize::from(frame.cols) * 9 + 24,
+ usize::from(frame.rows) * 18 + 18,
+ &svg_text(&frame.screen),
+ )
+}
+
+fn render_recording_svg(frames: &[TerminalFrame]) -> String {
+ let columns = frames.iter().map(|frame| frame.cols).max().unwrap_or(1);
+ let rows = frames.iter().map(|frame| frame.rows).max().unwrap_or(1);
+ let count = frames.len().max(1);
+ let key_times = (0..=count)
+ .map(|index| (index as f64 / count as f64).to_string())
+ .collect::>()
+ .join(";");
+ let duration = (count as f64 * 0.35).max(0.35);
+ let groups = frames
+ .iter()
+ .enumerate()
+ .map(|(frame_index, frame)| {
+ let values = (0..=count)
+ .map(|index| {
+ if index == frame_index || (index == count && frame_index == 0) {
+ "1"
+ } else {
+ "0"
+ }
+ })
+ .collect::>()
+ .join(";");
+ format!(
+ concat!(
+ "",
+ r#""#,
+ "{}"
+ ),
+ values,
+ key_times,
+ duration,
+ svg_text(&frame.screen)
+ )
+ })
+ .collect::();
+ svg_shell(
+ usize::from(columns) * 9 + 24,
+ usize::from(rows) * 18 + 18,
+ &groups,
+ )
+}
+
+pub fn serialize_asciicast(asciicast: &Asciicast) -> Result {
+ let mut lines = vec![serde_json::to_string(&asciicast.header)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?];
+ for event in &asciicast.events {
+ lines.push(
+ serde_json::to_string(&(event.time, &event.code, &event.data))
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
+ );
+ }
+ Ok(format!("{}\n", lines.join("\n")))
+}
+
+pub fn read_asciicast(path: impl AsRef) -> Result {
+ let contents = fs::read_to_string(path)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let mut lines = contents.lines();
+ let header: AsciicastHeader = serde_json::from_str(
+ lines
+ .next()
+ .ok_or_else(|| TerminalCaptureError::new("empty asciicast", None))?,
+ )
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let events = lines
+ .filter(|line| !line.is_empty())
+ .map(|line| {
+ let (time, code, data): (f64, String, String) = serde_json::from_str(line)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ Ok(AsciicastEvent { time, code, data })
+ })
+ .collect::, TerminalCaptureError>>()?;
+ Ok(Asciicast { header, events })
+}
+
+pub(crate) fn write_terminal_artifacts(
+ directory: &Path,
+ frames: &[TerminalFrame],
+ transcript: &str,
+ asciicast: &Asciicast,
+) -> Result<(), TerminalCaptureError> {
+ fs::create_dir_all(directory)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let fallback = TerminalFrame {
+ time: 0.0,
+ cols: asciicast.header.width,
+ rows: asciicast.header.height,
+ cursor: super::types::TerminalCursor { x: 0, y: 0 },
+ alternate: false,
+ lines: Vec::new(),
+ screen: Vec::new(),
+ };
+ let final_frame = frames.last().unwrap_or(&fallback);
+ let writes = [
+ ("transcript.txt", format!("{transcript}\n")),
+ (
+ "frames.json",
+ format!(
+ "{}\n",
+ serde_json::to_string_pretty(frames)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
+ ),
+ ),
+ ("session.cast", serialize_asciicast(asciicast)?),
+ ("snapshot.svg", render_snapshot_svg(final_frame)),
+ ("recording.svg", render_recording_svg(frames)),
+ ];
+ for (name, contents) in writes {
+ fs::write(directory.join(name), contents)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ }
+ Ok(())
+}
diff --git a/rust/src/terminal/capture.rs b/rust/src/terminal/capture.rs
new file mode 100644
index 0000000..a60d386
--- /dev/null
+++ b/rust/src/terminal/capture.rs
@@ -0,0 +1,375 @@
+use super::artifacts::{unroll_terminal_frames, write_terminal_artifacts};
+use super::types::{
+ Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError,
+ TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalResize,
+};
+use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+const CLEAR_SCREEN: &[u8] = b"\x1b[2J\x1b[H";
+
+fn elapsed(started: Instant) -> f64 {
+ (started.elapsed().as_secs_f64() * 1_000_000.0).round() / 1_000_000.0
+}
+
+fn trim_trailing_blank(mut lines: Vec) -> Vec {
+ while lines.last().is_some_and(String::is_empty) {
+ lines.pop();
+ }
+ lines
+}
+
+fn frame(parser: &vt100::Parser, started: Instant) -> TerminalFrame {
+ let screen = parser.screen();
+ let (rows, cols) = screen.size();
+ let (cursor_y, cursor_x) = screen.cursor_position();
+ let lines = trim_trailing_blank(screen.rows(0, cols).collect());
+ TerminalFrame {
+ time: elapsed(started),
+ cols,
+ rows,
+ cursor: TerminalCursor {
+ x: cursor_x,
+ y: cursor_y,
+ },
+ alternate: screen.alternate_screen(),
+ screen: lines.clone(),
+ lines,
+ }
+}
+
+fn same_frame(left: &TerminalFrame, right: &TerminalFrame) -> bool {
+ left.cols == right.cols
+ && left.rows == right.rows
+ && left.cursor == right.cursor
+ && left.alternate == right.alternate
+ && left.lines == right.lines
+}
+
+fn append_frame(frames: &mut Vec, parser: &vt100::Parser, started: Instant) {
+ let next = frame(parser, started);
+ if frames
+ .last()
+ .is_none_or(|previous| !same_frame(previous, &next))
+ {
+ frames.push(next);
+ }
+}
+
+fn render_segments(data: &[u8]) -> Vec<&[u8]> {
+ let positions = data
+ .windows(CLEAR_SCREEN.len())
+ .enumerate()
+ .filter_map(|(index, window)| (window == CLEAR_SCREEN).then_some(index))
+ .collect::>();
+ if positions.is_empty() {
+ return vec![data];
+ }
+
+ let mut segments = Vec::new();
+ if positions[0] > 0 {
+ segments.push(&data[..positions[0]]);
+ }
+ for (index, position) in positions.iter().enumerate() {
+ let end = positions.get(index + 1).copied().unwrap_or(data.len());
+ segments.push(&data[*position..end]);
+ }
+ segments
+}
+
+fn record(asciicast: &mut Asciicast, started: Instant, code: &str, data: impl Into) {
+ asciicast.events.push(AsciicastEvent {
+ time: elapsed(started),
+ code: code.into(),
+ data: data.into(),
+ });
+}
+
+fn apply_interaction(
+ interaction: &TerminalInteraction,
+ writer: &mut dyn Write,
+ master: &dyn MasterPty,
+ parser: &mut vt100::Parser,
+ asciicast: &mut Asciicast,
+ started: Instant,
+) -> Result<(), TerminalCaptureError> {
+ if let Some(text) = &interaction.text {
+ writer
+ .write_all(text.as_bytes())
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ writer
+ .flush()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ record(asciicast, started, "i", text.clone());
+ }
+ if let Some(key) = &interaction.key {
+ writer
+ .write_all(key.sequence().as_bytes())
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ writer
+ .flush()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ record(asciicast, started, "i", key.sequence());
+ }
+ if let Some(resize) = interaction.resize {
+ resize_terminal(master, parser, resize)?;
+ record(
+ asciicast,
+ started,
+ "r",
+ format!("{}x{}", resize.cols, resize.rows),
+ );
+ }
+ Ok(())
+}
+
+fn resize_terminal(
+ master: &dyn MasterPty,
+ parser: &mut vt100::Parser,
+ resize: TerminalResize,
+) -> Result<(), TerminalCaptureError> {
+ master
+ .resize(PtySize {
+ rows: resize.rows,
+ cols: resize.cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ parser.set_size(resize.rows, resize.cols);
+ Ok(())
+}
+
+fn asciicast(options: &TerminalCaptureOptions) -> Asciicast {
+ let mut env = HashMap::new();
+ env.insert("SHELL".into(), options.file.clone());
+ env.insert(
+ "TERM".into(),
+ options
+ .env
+ .get("TERM")
+ .cloned()
+ .unwrap_or_else(|| "xterm-256color".into()),
+ );
+ Asciicast {
+ header: AsciicastHeader {
+ version: 2,
+ width: options.cols,
+ height: options.rows,
+ timestamp: chrono::Utc::now().timestamp(),
+ env,
+ },
+ events: Vec::new(),
+ }
+}
+
+fn spawn_reader(mut reader: Box) -> mpsc::Receiver> {
+ let (sender, receiver) = mpsc::channel();
+ std::thread::spawn(move || {
+ let mut buffer = [0_u8; 8192];
+ loop {
+ match reader.read(&mut buffer) {
+ Ok(0) | Err(_) => break,
+ Ok(length) => {
+ if sender.send(buffer[..length].to_vec()).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ });
+ receiver
+}
+
+fn capture_result(
+ status: portable_pty::ExitStatus,
+ output: String,
+ frames: Vec,
+ interaction_count: usize,
+ asciicast: Asciicast,
+) -> TerminalCapture {
+ TerminalCapture {
+ exit_code: status.exit_code() as i32,
+ signal: status.signal().map(str::to_owned),
+ transcript: unroll_terminal_frames(&frames),
+ output,
+ frames,
+ interaction_count,
+ asciicast,
+ }
+}
+
+pub fn capture_terminal(
+ options: TerminalCaptureOptions,
+) -> Result {
+ if options.file.is_empty() {
+ return Err(TerminalCaptureError::new(
+ "capture_terminal requires a file",
+ None,
+ ));
+ }
+ let pty = native_pty_system()
+ .openpty(PtySize {
+ rows: options.rows,
+ cols: options.cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let mut command = CommandBuilder::new(&options.file);
+ command.args(&options.args);
+ if let Some(cwd) = &options.cwd {
+ command.cwd(cwd);
+ }
+ command.env(
+ "TERM",
+ options
+ .env
+ .get("TERM")
+ .map_or("xterm-256color", String::as_str),
+ );
+ for (name, value) in &options.env {
+ command.env(name, value);
+ }
+ let mut child = pty
+ .slave
+ .spawn_command(command)
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ drop(pty.slave);
+ let reader = pty
+ .master
+ .try_clone_reader()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let mut writer = pty
+ .master
+ .take_writer()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ let receiver = spawn_reader(reader);
+ let started = Instant::now();
+ let mut parser = vt100::Parser::new(options.rows, options.cols, 100_000);
+ let mut recording = asciicast(&options);
+ let mut output = String::new();
+ let mut frames = Vec::new();
+ let mut interaction_index = 0;
+ let mut last_output = None;
+ let mut dirty = false;
+ let mut reader_closed = false;
+ let mut status = None;
+ let mut timed_out = false;
+ let mut stop_deadline = None;
+
+ loop {
+ match receiver.recv_timeout(Duration::from_millis(5)) {
+ Ok(data) => {
+ let text = String::from_utf8_lossy(&data);
+ output.push_str(&text);
+ record(&mut recording, started, "o", text.into_owned());
+ let segments = render_segments(&data);
+ let segment_count = segments.len();
+ for (index, segment) in segments.into_iter().enumerate() {
+ parser.process(segment);
+ if index + 1 < segment_count {
+ append_frame(&mut frames, &parser, started);
+ }
+ }
+ last_output = Some(Instant::now());
+ dirty = true;
+ if options
+ .stop_marker
+ .as_ref()
+ .is_some_and(|marker| output.contains(marker))
+ && stop_deadline.is_none()
+ {
+ append_frame(&mut frames, &parser, started);
+ stop_deadline = Some(Instant::now() + options.stop_marker_grace);
+ }
+ }
+ Err(mpsc::RecvTimeoutError::Disconnected) => reader_closed = true,
+ Err(mpsc::RecvTimeoutError::Timeout) => {}
+ }
+
+ while let Some(interaction) = options.interactions.get(interaction_index) {
+ if interaction
+ .after
+ .as_ref()
+ .is_some_and(|marker| !output.contains(marker))
+ {
+ break;
+ }
+ append_frame(&mut frames, &parser, started);
+ apply_interaction(
+ interaction,
+ writer.as_mut(),
+ pty.master.as_ref(),
+ &mut parser,
+ &mut recording,
+ started,
+ )?;
+ interaction_index += 1;
+ }
+
+ if dirty && last_output.is_some_and(|instant| instant.elapsed() >= options.settle_duration)
+ {
+ append_frame(&mut frames, &parser, started);
+ dirty = false;
+ }
+ if status.is_none() {
+ status = child
+ .try_wait()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
+ }
+ if status.is_some() && reader_closed {
+ break;
+ }
+ if status.is_none()
+ && (started.elapsed() >= options.timeout
+ || stop_deadline.is_some_and(|deadline| Instant::now() >= deadline))
+ {
+ timed_out = started.elapsed() >= options.timeout;
+ let _ = child.kill();
+ status = Some(
+ child
+ .wait()
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
+ );
+ }
+ }
+
+ append_frame(&mut frames, &parser, started);
+ let capture = capture_result(
+ status.expect("child status is available after capture loop"),
+ output,
+ frames,
+ interaction_index,
+ recording,
+ );
+ if let Some(directory) = &options.artifact_directory {
+ write_terminal_artifacts(
+ directory,
+ &capture.frames,
+ &capture.transcript,
+ &capture.asciicast,
+ )?;
+ }
+ if timed_out {
+ return Err(TerminalCaptureError::new(
+ format!(
+ "terminal command timed out after {} ms",
+ options.timeout.as_millis()
+ ),
+ Some(capture),
+ ));
+ }
+ Ok(capture)
+}
+
+pub async fn capture_terminal_async(
+ options: TerminalCaptureOptions,
+) -> Result {
+ tokio::task::spawn_blocking(move || capture_terminal(options))
+ .await
+ .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
+}
diff --git a/rust/src/terminal/mod.rs b/rust/src/terminal/mod.rs
new file mode 100644
index 0000000..cda237e
--- /dev/null
+++ b/rust/src/terminal/mod.rs
@@ -0,0 +1,11 @@
+mod artifacts;
+mod capture;
+mod types;
+
+pub use artifacts::{read_asciicast, serialize_asciicast, unroll_terminal_frames};
+pub use capture::{capture_terminal, capture_terminal_async};
+pub use types::{
+ Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError,
+ TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalKey,
+ TerminalResize,
+};
diff --git a/rust/src/terminal/types.rs b/rust/src/terminal/types.rs
new file mode 100644
index 0000000..279f3bc
--- /dev/null
+++ b/rust/src/terminal/types.rs
@@ -0,0 +1,164 @@
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fmt;
+use std::path::PathBuf;
+use std::time::Duration;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TerminalResize {
+ pub cols: u16,
+ pub rows: u16,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TerminalKey {
+ Backspace,
+ CtrlC,
+ CtrlD,
+ Down,
+ Enter,
+ Escape,
+ Left,
+ Right,
+ Tab,
+ Up,
+ Raw(String),
+}
+
+impl TerminalKey {
+ pub(crate) fn sequence(&self) -> &str {
+ match self {
+ Self::Backspace => "\u{7f}",
+ Self::CtrlC => "\u{3}",
+ Self::CtrlD => "\u{4}",
+ Self::Down => "\u{1b}[B",
+ Self::Enter => "\r",
+ Self::Escape => "\u{1b}",
+ Self::Left => "\u{1b}[D",
+ Self::Right => "\u{1b}[C",
+ Self::Tab => "\t",
+ Self::Up => "\u{1b}[A",
+ Self::Raw(sequence) => sequence,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct TerminalInteraction {
+ pub after: Option,
+ pub text: Option,
+ pub key: Option,
+ pub resize: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct TerminalCaptureOptions {
+ pub file: String,
+ pub args: Vec,
+ pub cwd: Option,
+ pub env: HashMap,
+ pub cols: u16,
+ pub rows: u16,
+ pub settle_duration: Duration,
+ pub interactions: Vec,
+ pub stop_marker: Option,
+ pub stop_marker_grace: Duration,
+ pub timeout: Duration,
+ pub artifact_directory: Option,
+}
+
+impl Default for TerminalCaptureOptions {
+ fn default() -> Self {
+ Self {
+ file: String::new(),
+ args: Vec::new(),
+ cwd: None,
+ env: HashMap::new(),
+ cols: 80,
+ rows: 24,
+ settle_duration: Duration::from_millis(35),
+ interactions: Vec::new(),
+ stop_marker: None,
+ stop_marker_grace: Duration::from_millis(250),
+ timeout: Duration::from_secs(30),
+ artifact_directory: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TerminalCursor {
+ pub x: u16,
+ pub y: u16,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct TerminalFrame {
+ pub time: f64,
+ pub cols: u16,
+ pub rows: u16,
+ pub cursor: TerminalCursor,
+ pub alternate: bool,
+ pub lines: Vec,
+ pub screen: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct AsciicastHeader {
+ pub version: u8,
+ pub width: u16,
+ pub height: u16,
+ pub timestamp: i64,
+ pub env: HashMap,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct AsciicastEvent {
+ pub time: f64,
+ pub code: String,
+ pub data: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Asciicast {
+ pub header: AsciicastHeader,
+ pub events: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct TerminalCapture {
+ pub exit_code: i32,
+ pub signal: Option,
+ pub output: String,
+ pub transcript: String,
+ pub frames: Vec,
+ pub interaction_count: usize,
+ pub asciicast: Asciicast,
+}
+
+#[derive(Debug)]
+pub struct TerminalCaptureError {
+ message: String,
+ partial: Option>,
+}
+
+impl TerminalCaptureError {
+ pub(crate) fn new(message: impl Into, partial: Option) -> Self {
+ Self {
+ message: message.into(),
+ partial: partial.map(Box::new),
+ }
+ }
+
+ pub fn partial_capture(&self) -> Option<&TerminalCapture> {
+ self.partial.as_deref()
+ }
+}
+
+impl fmt::Display for TerminalCaptureError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.message)
+ }
+}
+
+impl std::error::Error for TerminalCaptureError {}
diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs
new file mode 100644
index 0000000..da62a96
--- /dev/null
+++ b/rust/tests/terminal_capture.rs
@@ -0,0 +1,133 @@
+#![cfg(unix)]
+
+use command_stream::terminal::{
+ capture_terminal, read_asciicast, TerminalCaptureOptions, TerminalInteraction, TerminalKey,
+ TerminalResize,
+};
+use std::fs;
+use std::time::Duration;
+use tempfile::tempdir;
+
+fn shell_options(script: &str) -> TerminalCaptureOptions {
+ TerminalCaptureOptions {
+ file: "/bin/sh".into(),
+ args: vec!["-c".into(), script.into()],
+ cols: 24,
+ rows: 6,
+ settle_duration: Duration::from_millis(10),
+ timeout: Duration::from_secs(3),
+ ..TerminalCaptureOptions::default()
+ }
+}
+
+#[test]
+fn drives_input_control_keys_and_terminal_resize() {
+ let script = r#"
+printf '\033[2J\033[Hready:'
+stty size
+IFS= read -r answer
+printf '\033[2J\033[Huser:%s\nwaiting-resize\n' "$answer"
+while [ "$(stty size)" != "10 40" ]; do sleep 0.01; done
+printf '\033[2J\033[Huser:%s\nassistant:done\nresized:' "$answer"
+stty size
+"#;
+ let mut options = shell_options(script);
+ options.interactions = vec![
+ TerminalInteraction {
+ after: Some("ready:".into()),
+ text: Some("hello".into()),
+ key: Some(TerminalKey::Enter),
+ resize: None,
+ },
+ TerminalInteraction {
+ after: Some("waiting-resize".into()),
+ text: None,
+ key: None,
+ resize: Some(TerminalResize { cols: 40, rows: 10 }),
+ },
+ ];
+
+ let capture = capture_terminal(options).expect("capture should complete");
+
+ assert_eq!(capture.exit_code, 0);
+ assert_eq!(capture.interaction_count, 2);
+ assert!(capture.transcript.contains("user:hello"));
+ assert!(capture.transcript.contains("assistant:done"));
+ assert!(capture.transcript.contains("resized:10 40"));
+ assert!(capture
+ .asciicast
+ .events
+ .iter()
+ .any(|event| event.code == "i" && event.data == "\r"));
+ assert!(capture
+ .asciicast
+ .events
+ .iter()
+ .any(|event| event.code == "r" && event.data == "40x10"));
+}
+
+#[test]
+fn retains_settled_repaints_scrollback_and_repeated_states_in_order() {
+ let script = r#"
+printf '\033[2J\033[Halpha\n'
+sleep 0.06
+printf '\033[2J\033[Hbeta\n'
+sleep 0.06
+printf '\033[2J\033[Halpha\n'
+"#;
+
+ let capture = capture_terminal(shell_options(script)).expect("capture should complete");
+ let states: Vec<&str> = capture
+ .frames
+ .iter()
+ .filter_map(|frame| frame.lines.first().map(String::as_str))
+ .collect();
+
+ assert_eq!(states, vec!["alpha", "beta", "alpha"]);
+ assert_eq!(capture.transcript, "alpha\nbeta\nalpha");
+}
+
+#[test]
+fn retains_lines_after_they_scroll_off_the_visible_terminal() {
+ let mut options = shell_options(
+ "printf 'one\\r\\n'; sleep 0.04; printf 'two\\r\\n'; sleep 0.04; \
+ printf 'three\\r\\n'; sleep 0.04; printf 'four\\r\\n'",
+ );
+ options.rows = 2;
+
+ let capture = capture_terminal(options).expect("capture should complete");
+
+ for line in ["one", "two", "three", "four"] {
+ assert!(
+ capture.transcript.lines().any(|seen| seen == line),
+ "{line}"
+ );
+ }
+}
+
+#[test]
+fn writes_replay_artifacts_and_preserves_partial_capture_on_timeout() {
+ let artifacts = tempdir().expect("artifact directory");
+ let mut options = shell_options("printf 'waiting for input'; sleep 5");
+ options.timeout = Duration::from_millis(100);
+ options.artifact_directory = Some(artifacts.path().into());
+
+ let error = capture_terminal(options).expect_err("capture should time out");
+ let partial = error.partial_capture().expect("partial capture");
+
+ assert!(partial.output.contains("waiting for input"));
+ for name in [
+ "transcript.txt",
+ "frames.json",
+ "session.cast",
+ "snapshot.svg",
+ "recording.svg",
+ ] {
+ assert!(artifacts.path().join(name).is_file(), "{name}");
+ }
+ let animation = fs::read_to_string(artifacts.path().join("recording.svg")).expect("animation");
+ assert!(animation.contains("