From d28dbe3fa4ecf93a9de320f016e855ca4d0b327c Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:38:06 +0000 Subject: [PATCH 01/14] test(js): reproduce terminal capture requirements --- js/tests/fixtures/tui-capture-fixture.mjs | 33 ++++++++++ js/tests/fixtures/tui-scroll-fixture.mjs | 7 ++ js/tests/terminal-capture.test.mjs | 78 +++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 js/tests/fixtures/tui-capture-fixture.mjs create mode 100644 js/tests/fixtures/tui-scroll-fixture.mjs create mode 100644 js/tests/terminal-capture.test.mjs diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs new file mode 100644 index 0000000..e8842ce --- /dev/null +++ b/js/tests/fixtures/tui-capture-fixture.mjs @@ -0,0 +1,33 @@ +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}`); +}; + +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}`); +} + +let input = ''; +process.stdin.on('data', (chunk) => { + for (const character of chunk.toString()) { + if (character === '\r') { + render(`typed:${input}`); + input = ''; + } else { + input += character; + } + } +}); + +process.on('SIGWINCH', () => { + render(`resized:${process.stdout.columns}x${process.stdout.rows}`); + setTimeout(() => process.exit(0), 20); +}); 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..0ce9ee4 --- /dev/null +++ b/js/tests/terminal-capture.test.mjs @@ -0,0 +1,78 @@ +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 } from '../src/$.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('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); + } + }); +}); From f382c61cb864806507d5a0a5bb69d55025017896 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:43:59 +0000 Subject: [PATCH 02/14] test(js): accept input as soon as fixture is ready --- js/tests/fixtures/tui-capture-fixture.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs index e8842ce..3cc40b4 100644 --- a/js/tests/fixtures/tui-capture-fixture.mjs +++ b/js/tests/fixtures/tui-capture-fixture.mjs @@ -9,12 +9,6 @@ const render = (body) => { process.stdout.write(`${clear}${body}`); }; -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}`); -} - let input = ''; process.stdin.on('data', (chunk) => { for (const character of chunk.toString()) { @@ -27,6 +21,12 @@ process.stdin.on('data', (chunk) => { } }); +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}`); +} + process.on('SIGWINCH', () => { render(`resized:${process.stdout.columns}x${process.stdout.rows}`); setTimeout(() => process.exit(0), 20); From f8c26bc77dc7116f10fae13a9e9b332619a9696c Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:46:06 +0000 Subject: [PATCH 03/14] test(js): accept resize as soon as fixture is ready --- js/tests/fixtures/tui-capture-fixture.mjs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs index 3cc40b4..e874dae 100644 --- a/js/tests/fixtures/tui-capture-fixture.mjs +++ b/js/tests/fixtures/tui-capture-fixture.mjs @@ -21,13 +21,17 @@ process.stdin.on('data', (chunk) => { } }); -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}`); -} - process.on('SIGWINCH', () => { render(`resized:${process.stdout.columns}x${process.stdout.rows}`); setTimeout(() => process.exit(0), 20); }); + +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}` + ); +} From 62fff93bc958b6ca37c4e01271b295f56e7d9f19 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:46:17 +0000 Subject: [PATCH 04/14] test(js): observe dimensions after resize signal --- js/tests/fixtures/tui-capture-fixture.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs index e874dae..021cf10 100644 --- a/js/tests/fixtures/tui-capture-fixture.mjs +++ b/js/tests/fixtures/tui-capture-fixture.mjs @@ -22,8 +22,10 @@ process.stdin.on('data', (chunk) => { }); process.on('SIGWINCH', () => { - render(`resized:${process.stdout.columns}x${process.stdout.rows}`); - setTimeout(() => process.exit(0), 20); + setTimeout(() => { + render(`resized:${process.stdout.columns}x${process.stdout.rows}`); + setTimeout(() => process.exit(0), 20); + }, 5); }); render( From c5fad59134b989ebcd906495a5d43da4966969a6 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:49:29 +0000 Subject: [PATCH 05/14] test(js): require capture artifacts on timeout --- js/tests/fixtures/tui-hang-fixture.mjs | 2 + js/tests/terminal-capture.test.mjs | 62 ++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 js/tests/fixtures/tui-hang-fixture.mjs 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/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index 0ce9ee4..9e49d80 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -4,22 +4,28 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { captureTerminal, readAsciicast } from '../src/$.mjs'; +import { + captureTerminal, + readAsciicast, + unrollTerminalFrames, +} from '../src/$.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 }) - ) + temporaryDirectories + .splice(0) + .map((path) => rm(path, { force: true, recursive: true })) ); }); describe('PTY terminal capture', () => { test('drives input and resize while retaining settled, deduplicated frames', async () => { - const artifactDirectory = await mkdtemp(join(tmpdir(), 'command-stream-tui-')); + const artifactDirectory = await mkdtemp( + join(tmpdir(), 'command-stream-tui-') + ); temporaryDirectories.push(artifactDirectory); const capture = await captureTerminal({ @@ -54,11 +60,13 @@ describe('PTY terminal capture', () => { 'snapshot.svg', 'recording.svg', ]) { - expect((await readFile(join(artifactDirectory, artifact))).length).toBeGreaterThan(0); + expect( + (await readFile(join(artifactDirectory, artifact))).length + ).toBeGreaterThan(0); } - expect(await readFile(join(artifactDirectory, 'recording.svg'), 'utf8')).toContain( - ' { @@ -75,4 +83,40 @@ describe('PTY terminal capture', () => { 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); + }); }); From 76bcec80a6ab3d6c83743397a2fd8c03964cd5cf Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:51:21 +0000 Subject: [PATCH 06/14] test(js): detect terminal resize across platforms --- js/tests/fixtures/tui-capture-fixture.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/js/tests/fixtures/tui-capture-fixture.mjs b/js/tests/fixtures/tui-capture-fixture.mjs index 021cf10..652b9ea 100644 --- a/js/tests/fixtures/tui-capture-fixture.mjs +++ b/js/tests/fixtures/tui-capture-fixture.mjs @@ -21,12 +21,15 @@ process.stdin.on('data', (chunk) => { } }); -process.on('SIGWINCH', () => { - setTimeout(() => { - render(`resized:${process.stdout.columns}x${process.stdout.rows}`); +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); -}); + } +}, 5); render( `ready:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}` From 4b8a864c578d270d50c7d542b6368ba0450c3174 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:55:16 +0000 Subject: [PATCH 07/14] feat(js): capture PTY terminal sessions --- js/.changeset/fuzzy-pianos-record.md | 7 + js/README.md | 37 +++ js/bun.lock | 10 + js/package-lock.json | 29 +++ js/package.json | 4 + js/src/$.mjs | 8 + js/src/terminal-artifacts.mjs | 185 ++++++++++++++ js/src/terminal-capture.mjs | 357 +++++++++++++++++++++++++++ js/src/terminal-pty-host.mjs | 31 +++ js/src/terminal-pty.mjs | 122 +++++++++ 10 files changed, 790 insertions(+) create mode 100644 js/.changeset/fuzzy-pianos-record.md create mode 100644 js/src/terminal-artifacts.mjs create mode 100644 js/src/terminal-capture.mjs create mode 100644 js/src/terminal-pty-host.mjs create mode 100644 js/src/terminal-pty.mjs 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..c12216d 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.1.0", + }, "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.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "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..3e77373 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.1.0" } } 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 }) => + [ + '', + ``, + '', + '', + 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.mjs b/js/src/terminal-pty-host.mjs new file mode 100644 index 0000000..d86279d --- /dev/null +++ b/js/src/terminal-pty-host.mjs @@ -0,0 +1,31 @@ +import ptyModule from 'node-pty'; +import { createInterface } from 'node:readline'; + +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') { + terminal.kill(message.signal); + } +}); + +messages.on('close', () => { + terminal?.kill('SIGTERM'); +}); diff --git a/js/src/terminal-pty.mjs b/js/src/terminal-pty.mjs new file mode 100644 index 0000000..d49646a --- /dev/null +++ b/js/src/terminal-pty.mjs @@ -0,0 +1,122 @@ +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) => { + const [{ spawn }, { dirname }, { fileURLToPath }] = await Promise.all([ + import('node:child_process'), + import('node:path'), + import('node:url'), + ]); + const hostPath = fileURLToPath( + new URL('./terminal-pty-host.mjs', import.meta.url) + ); + const nodeBinary = 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 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') { + 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); + for (const listener of exitListeners) { + listener({ exitCode: 1, signal: 0, error }); + } + } + }); + host.on('exit', (exitCode) => { + if (exitCode && exitListeners.size > 0 && !exited) { + exited = true; + const error = new Error( + `PTY host exited with code ${exitCode}: ${hostErrors.trim()}` + ); + rejectReady(error); + for (const listener of exitListeners) { + listener({ exitCode, signal: 0, error }); + } + } + }); + + 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 }); + }, + }; +}; From e5819d17a4df2118c6279f1de853f58eff9ce68a Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:14:02 +0000 Subject: [PATCH 08/14] test(rust): reproduce terminal capture requirements --- rust/tests/terminal_capture.rs | 112 +++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 rust/tests/terminal_capture.rs diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs new file mode 100644 index 0000000..8fe7536 --- /dev/null +++ b/rust/tests/terminal_capture.rs @@ -0,0 +1,112 @@ +#![cfg(unix)] + +use command_stream::terminal::{ + capture_terminal, 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 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(" Date: Fri, 24 Jul 2026 22:22:29 +0000 Subject: [PATCH 09/14] feat(rust): capture PTY terminal sessions --- rust/Cargo.lock | 223 ++++++++++- rust/Cargo.toml | 8 +- rust/README.md | 33 ++ .../20260724_221500_terminal_capture.md | 9 + rust/src/lib.rs | 1 + rust/src/terminal/artifacts.rs | 211 ++++++++++ rust/src/terminal/capture.rs | 375 ++++++++++++++++++ rust/src/terminal/mod.rs | 11 + rust/src/terminal/types.rs | 164 ++++++++ rust/tests/terminal_capture.rs | 27 +- 10 files changed, 1047 insertions(+), 15 deletions(-) create mode 100644 rust/changelog.d/20260724_221500_terminal_capture.md create mode 100644 rust/src/terminal/artifacts.rs create mode 100644 rust/src/terminal/capture.rs create mode 100644 rust/src/terminal/mod.rs create mode 100644 rust/src/terminal/types.rs 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#""#, + r##""##, + r##""##, + "{2}" + ), + 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 index 8fe7536..da62a96 100644 --- a/rust/tests/terminal_capture.rs +++ b/rust/tests/terminal_capture.rs @@ -1,7 +1,8 @@ #![cfg(unix)] use command_stream::terminal::{ - capture_terminal, TerminalCaptureOptions, TerminalInteraction, TerminalKey, TerminalResize, + capture_terminal, read_asciicast, TerminalCaptureOptions, TerminalInteraction, TerminalKey, + TerminalResize, }; use std::fs; use std::time::Duration; @@ -86,6 +87,24 @@ printf '\033[2J\033[Halpha\n' 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"); @@ -106,7 +125,9 @@ fn writes_replay_artifacts_and_preserves_partial_capture_on_timeout() { ] { assert!(artifacts.path().join(name).is_file(), "{name}"); } - let animation = - fs::read_to_string(artifacts.path().join("recording.svg")).expect("animation"); + let animation = fs::read_to_string(artifacts.path().join("recording.svg")).expect("animation"); assert!(animation.contains(" Date: Fri, 24 Jul 2026 22:26:59 +0000 Subject: [PATCH 10/14] test(js): reproduce Windows PTY shutdown --- js/tests/terminal-capture.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index 9e49d80..bec790c 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -9,6 +9,7 @@ import { readAsciicast, unrollTerminalFrames, } from '../src/$.mjs'; +import { stopTerminal } from '../src/terminal-pty-host-platform.mjs'; const directory = dirname(fileURLToPath(import.meta.url)); const temporaryDirectories = []; @@ -22,6 +23,19 @@ afterEach(async () => { }); describe('PTY terminal capture', () => { + 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-') From c1b3784ff61c90f9a780fa781003b30d299a73bf Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:28:05 +0000 Subject: [PATCH 11/14] fix(js): stop PTY hosts on Windows without signals --- js/src/terminal-pty-host-platform.mjs | 14 ++++++++++++++ js/src/terminal-pty-host.mjs | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 js/src/terminal-pty-host-platform.mjs 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 index d86279d..4e46770 100644 --- a/js/src/terminal-pty-host.mjs +++ b/js/src/terminal-pty-host.mjs @@ -1,6 +1,8 @@ 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); }; @@ -22,10 +24,10 @@ messages.on('line', (line) => { } else if (message.type === 'resize') { terminal.resize(message.cols, message.rows); } else if (message.type === 'kill') { - terminal.kill(message.signal); + stopTerminal(terminal, message.signal); } }); messages.on('close', () => { - terminal?.kill('SIGTERM'); + stopTerminal(terminal); }); From bc7e9e2713d85b3350f1576fe6aa84bba6e84e12 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:36:58 +0000 Subject: [PATCH 12/14] test(js): reproduce premature PTY host exit --- js/tests/fixtures/pty-host-exit.mjs | 2 ++ js/tests/terminal-capture.test.mjs | 15 +++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 js/tests/fixtures/pty-host-exit.mjs 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/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index bec790c..d1ad083 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -10,6 +10,7 @@ import { 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 = []; @@ -23,6 +24,20 @@ afterEach(async () => { }); 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 = { From 8b71b71b42b181a1fabdbed92214f60ace1549c9 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:37:48 +0000 Subject: [PATCH 13/14] fix(js): diagnose and provision PTY host runtime --- .github/workflows/js.yml | 6 ++++++ js/src/terminal-pty.mjs | 38 ++++++++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 10 deletions(-) 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/src/terminal-pty.mjs b/js/src/terminal-pty.mjs index d49646a..e679d80 100644 --- a/js/src/terminal-pty.mjs +++ b/js/src/terminal-pty.mjs @@ -13,16 +13,22 @@ const parseMessages = (stream, receive) => { }); }; -export const spawnTerminalPty = async (file, args, options) => { +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 = fileURLToPath( - new URL('./terminal-pty-host.mjs', import.meta.url) - ); - const nodeBinary = process.env.COMMAND_STREAM_NODE_BINARY ?? 'node'; + 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, @@ -34,6 +40,7 @@ export const spawnTerminalPty = async (file, args, options) => { let pendingExit; let hostErrors = ''; let exited = false; + let terminalExitReceived = false; let resolveReady; let rejectReady; const ready = new Promise((resolve, reject) => { @@ -54,6 +61,7 @@ export const spawnTerminalPty = async (file, args, options) => { } } } else if (message.type === 'exit') { + terminalExitReceived = true; if (exitListeners.size === 0) { pendingExit = message; } else { @@ -73,20 +81,30 @@ export const spawnTerminalPty = async (file, args, options) => { if (!exited) { exited = true; rejectReady(error); - for (const listener of exitListeners) { - listener({ exitCode: 1, signal: 0, 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 (exitCode && exitListeners.size > 0 && !exited) { + if (!terminalExitReceived && !exited) { exited = true; const error = new Error( `PTY host exited with code ${exitCode}: ${hostErrors.trim()}` ); rejectReady(error); - for (const listener of exitListeners) { - listener({ exitCode, signal: 0, error }); + const message = { exitCode: exitCode ?? 1, signal: 0, error }; + if (exitListeners.size === 0) { + pendingExit = message; + } else { + for (const listener of exitListeners) { + listener(message); + } } } }); From 98be70b79c3274cba548a8d4b72c272ef7649ac0 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:41:44 +0000 Subject: [PATCH 14/14] fix(js): use executable macOS PTY prebuild --- js/bun.lock | 4 ++-- js/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/bun.lock b/js/bun.lock index c12216d..a7ef75c 100644 --- a/js/bun.lock +++ b/js/bun.lock @@ -6,7 +6,7 @@ "name": "command-stream", "dependencies": { "@xterm/headless": "^6.0.0", - "node-pty": "^1.1.0", + "node-pty": "^1.2.0-beta.14", }, "devDependencies": { "@changesets/cli": "^2.29.7", @@ -405,7 +405,7 @@ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "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=="], diff --git a/js/package.json b/js/package.json index 3e77373..f95484c 100644 --- a/js/package.json +++ b/js/package.json @@ -84,6 +84,6 @@ }, "dependencies": { "@xterm/headless": "^6.0.0", - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.14" } }