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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
7 changes: 7 additions & 0 deletions js/.changeset/fuzzy-pianos-record.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions js/bun.lock

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

29 changes: 29 additions & 0 deletions js/package-lock.json

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

4 changes: 4 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,9 @@
"prettier --write",
"prettier --check"
]
},
"dependencies": {
"@xterm/headless": "^6.0.0",
"node-pty": "^1.2.0-beta.14"
}
}
8 changes: 8 additions & 0 deletions js/src/$.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -458,5 +463,8 @@ export {
getAnsiConfig,
processOutput,
forceCleanupAll,
captureTerminal,
readAsciicast,
unrollTerminalFrames,
};
export default $tagged;
185 changes: 185 additions & 0 deletions js/src/terminal-artifacts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const EVENT_TYPES = {
i: 'input',
o: 'output',
r: 'resize',
};

const escapeXml = (value) =>
String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');

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) =>
`<text x="12" y="${24 + index * 18}">${escapeXml(line || ' ')}</text>`
)
.join('');

const svgShell = ({ width, height, body }) =>
[
'<?xml version="1.0" encoding="UTF-8"?>',
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
'<rect width="100%" height="100%" rx="6" fill="#111827"/>',
'<g fill="#e5e7eb" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">',
body,
'</g></svg>',
].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 [
'<g>',
`<animate attributeName="opacity" calcMode="discrete" values="${values}" keyTimes="${keyTimes}" dur="${duration}s" repeatCount="indefinite"/>`,
svgText(frame.screen),
'</g>',
].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)),
]);
};
Loading