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
1 change: 1 addition & 0 deletions .gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# .gitkeep file auto-generated at 2026-08-07T19:41:37.524Z for PR creation at branch issue-187-3d458fd12c95 for issue https://github.com/link-foundation/command-stream/issues/187
5 changes: 5 additions & 0 deletions js/.changeset/terminal-session-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'command-stream': minor
---

Add `openTerminal()`, an incremental PTY session alongside `captureTerminal()`: the child stays alive with no implicit timeout, `waitFor()` reuses the existing readiness matcher (including `idleMilliseconds`), `send()` accepts the `interactions` vocabulary at any later point, and `close()`/`dispose()` return the usual frames, transcript, and asciicast. `captureTerminal()` is now implemented on top of it.
56 changes: 56 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,62 @@ the idle wait. The complete, runnable
[`tui-e2e.mjs`](examples/tui-e2e.mjs) example navigates a raw-mode menu and
asserts on its captured output.

#### Interactive sessions

`captureTerminal()` is a batch call: every interaction is known up front and the
child is killed after `timeoutMilliseconds` (30 s by default). When the input
arrives later and from elsewhere — an authorization code a human pastes back
minutes later, a chat-ops bridge, a test that interleaves assertions with input
— use `openTerminal()`, which keeps the same PTY open until you close it:

```javascript
import { openTerminal } from 'command-stream';

const session = await openTerminal({
file: 'my-cli',
args: ['login'],
cols: 80,
});

// Same readiness matcher as interactions, including idleMilliseconds.
await session.waitFor(/https:\/\/\S+/, { idleMilliseconds: 50 });
const url = session.transcript.match(/https:\/\/\S+/)[0];

// ... arbitrary time passes; nothing terminates the child ...

await session.send({ text: code, key: 'ENTER' });
await session.waitFor('Logged in');

const capture = await session.close(); // frames/transcript/asciicast as usual
```

`openTerminal()` accepts every `captureTerminal()` option, including
`interactions` for the parts that _are_ known up front, but its
`timeoutMilliseconds` has no default: a session runs until the child exits or
you close it, and passing an explicit value opts back into a deadline.
`captureTerminal()` is implemented on top of `openTerminal()`, so the two paths
cannot drift.

The session exposes:

- `waitFor(pattern, { idleMilliseconds, timeoutMilliseconds })` — resolves with
the current transcript once `pattern` (string or RegExp) has been seen and,
with `idleMilliseconds`, output has been quiet for that long. It rejects if
the wait times out or the child exits first.
- `send(interaction | interaction[])` — the `text`, `key`, and `resize`
vocabulary of `interactions`; an entry may also carry `after` /
`idleMilliseconds` to wait before it is applied.
- `output`, `transcript`, `frames`, `asciicast`, `running`, `exitStatus`,
and `exited` (a promise for the child's exit) for live inspection.
- `close({ signal, timeoutMilliseconds })` — signals the child (escalating to
`SIGKILL`), then returns the same result object `captureTerminal()` resolves
to and writes `artifactDirectory` artifacts. `dispose()` is the
never-throwing variant for cleanup paths, and `finished()` waits for a child
that exits on its own.

The runnable [`tui-session.mjs`](examples/tui-session.mjs) example walks through
a deferred-input login.

The artifact directory contains an unrolled `transcript.txt`, machine-readable
`frames.json`, an asciicast v2 `session.cast`, a final `snapshot.svg`, a
self-contained animated `recording.svg`, and `recording.gif`. Frames retain
Expand Down
44 changes: 44 additions & 0 deletions js/examples/tui-session.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Drive a TUI with input that only becomes available later: the session stays
// open between an authorization URL being printed and the code being typed.
import assert from 'node:assert/strict';

import { openTerminal } from 'command-stream';

const login = String.raw`
process.stdin.setRawMode(true);
let typed = '';
process.stdin.on('data', (data) => {
for (const character of data.toString()) {
if (character === '\r') {
process.stdout.write('\r\nLogged in with ' + typed + '\r\n');
setTimeout(() => process.exit(0), 20);
} else {
typed += character;
}
}
});
process.stdout.write('Open https://example.test/device?code=ABCD-1234\r\n');
`;

const session = await openTerminal({
file: process.execPath,
args: ['-e', login],
cols: 80,
rows: 10,
});

// Same readiness semantics as `interactions`, including idleMilliseconds.
await session.waitFor(/Open (https:\/\/\S+)/, { idleMilliseconds: 50 });
const url = session.transcript.match(/Open (https:\/\/\S+)/)[1];
console.log(`authorize at ${url}`);

// A human opens the URL and comes back with a code; no timeout kills the child.
await new Promise((resolve) => setTimeout(resolve, 250));

await session.send({ text: 'ABCD-1234', key: 'ENTER' });
await session.waitFor('Logged in with ABCD-1234');

const capture = await session.close();
assert.equal(capture.exitCode, 0);
assert.match(capture.transcript, /Logged in with ABCD-1234/);
console.log(capture.transcript);
2 changes: 2 additions & 0 deletions js/src/$.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from './$.ansi.mjs';
import {
captureTerminal,
openTerminal,
readAsciicast,
unrollTerminalFrames,
} from './terminal-capture.mjs';
Expand Down Expand Up @@ -464,6 +465,7 @@ export {
processOutput,
forceCleanupAll,
captureTerminal,
openTerminal,
readAsciicast,
unrollTerminalFrames,
};
Expand Down
Loading