termcut 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,186 +1,176 @@
1
1
  # tcut
2
2
 
3
- [![CI](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml/badge.svg)](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
3
+ **Turn a terminal session into a video.** Record it live or script it in TypeScript; render it to MP4, GIF, WebM, SVG, HTML or PNG — identical every time.
4
+
4
5
  [![npm](https://img.shields.io/npm/v/termcut)](https://www.npmjs.com/package/termcut)
6
+ [![CI](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml/badge.svg)](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
5
7
  [![license](https://img.shields.io/github/license/AmanVarshney01/tcut)](LICENSE)
6
8
 
7
- Script terminal sessions in **TypeScript**, render them to **reproducible** MP4 / GIF / WebM / SVG / HTML / PNG.
9
+ ![tcut demo](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/demo.gif)
10
+
11
+ Website: **[tcut.amanv.dev](https://tcut.amanv.dev)**
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ bun add -g termcut # installs the `tcut` command (needs Bun ≥ 1.4)
17
+ bunx termcut --help # or run it without installing
18
+ ```
19
+
20
+ No Bun? Grab a standalone binary from [Releases](https://github.com/AmanVarshney01/tcut/releases) (macOS, Linux, Windows):
21
+
22
+ ```sh
23
+ curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.2.0-darwin-arm64 -o tcut && chmod +x tcut
24
+ ```
25
+
26
+ For `.mp4` / `.gif` / `.webm` you also need `ffmpeg` (`brew install ffmpeg`, `apt install ffmpeg`). SVG, HTML and PNG need nothing else.
8
27
 
9
- Built on Bun 1.4 — `Bun.Terminal` for the PTY, `Bun.WebView` for pixels, `Bun.build` for the renderer,
10
- `Bun.Image` for stills, `bun build --compile` for a single binary — plus [wterm](https://github.com/vercel-labs/wterm)'s
11
- libghostty WASM core as the terminal emulator. Inspired by [VHS](https://github.com/charmbracelet/vhs); scripts are
12
- code, and recording is separate from rendering.
28
+ ## Record a session
13
29
 
14
- ![tcut demo](docs/demo.gif)
30
+ ### Live — just do it, tcut records it
31
+
32
+ ```sh
33
+ tcut rec -o demo.gif
34
+ ```
15
35
 
16
- <sub>Made by tcut from [`examples/readme.ts`](examples/readme.ts). Same cast as SVG: [docs/demo.svg](docs/demo.svg).</sub>
36
+ A clean shell opens in your terminal. Type whatever you want to show; when you `exit`, tcut renders `demo.gif`.
37
+ Everything is captured — output, timing, colours, arrow keys in TUIs, window resizes.
38
+
39
+ ```sh
40
+ tcut rec -o demo.mp4 -- bun create better-t-stack # record just one command (you still drive it)
41
+ tcut rec -o demo.svg -o demo.gif # several formats from one session
42
+ ```
43
+
44
+ ### Scripted — write it once, re-record forever
17
45
 
18
46
  ```ts
19
47
  // demo.video.ts
20
48
  import { defineVideo } from "tcut";
21
49
 
22
50
  export default defineVideo(
23
- {
24
- output: ["out/demo.mp4", "out/demo.gif", "out/demo.svg"],
25
- theme: "catppuccin-mocha",
26
- cols: 80, rows: 20,
27
- typingSpeed: "40ms", typingJitter: 0.4, // jitter is seeded → reproducible
28
- windowBar: "colorful", title: "tcut", margin: 32, borderRadius: 12,
29
- },
51
+ { output: ["demo.mp4", "demo.gif"], theme: "catppuccin-mocha", cols: 80, rows: 20 },
30
52
  async (t) => {
31
- await t.hide(() => t.run("cd /tmp && mkdir -p demo && cd demo")); // happens, but is cut from the video
32
-
33
- await t.run("echo 'Hello 👋'"); // type + Enter + wait for the prompt to come back
53
+ await t.run("bun --version"); // types it, presses Enter, waits for the prompt
34
54
  await t.run("ls -la");
35
- await t.expect(/total \d+/); // assertion — the script doubles as an integration test
36
- await t.screenshot("out/ls.png");
55
+ await t.expect(/package\.json/); // assert on the screen — the demo is also a test
37
56
  await t.sleep("1.5s");
38
57
  },
39
58
  );
40
59
  ```
41
60
 
42
- ## Install
43
-
44
- **With Bun (recommended)** — Bun ≥ 1.4. The npm package is **`termcut`** (npm's typosquat filter blocks the
45
- 4-letter name); the command it installs is `tcut`:
46
-
47
61
  ```sh
48
- bun add -g termcut # then: tcut demo.video.ts
49
- bunx termcut init demo # or run it without installing
62
+ tcut demo.video.ts # record + render
63
+ tcut init demo # scaffold a script to start from
50
64
  ```
51
65
 
52
- **Standalone binary** no Bun required; download from
53
- [Releases](https://github.com/AmanVarshney01/tcut/releases) (`tcut-<version>-<platform>`, checksums in `SHA256SUMS`):
66
+ Scripts are plain TypeScript: loops, helpers, imports, whatever you need. All output in the video comes from the
67
+ real programs; the script only provides the key presses a person would make.
54
68
 
55
- ```sh
56
- curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.1.0-darwin-arm64 -o tcut
57
- chmod +x tcut && ./tcut init demo
58
- ```
69
+ ## Render again, differently
59
70
 
60
- Scripts `import { defineVideo } from "tcut"` the CLI (binary or global install) resolves that import itself,
61
- so no `node_modules` is needed next to your script. If you add `termcut` to a project, `import … from "termcut"`
62
- also works and gives your editor the types.
63
-
64
- **From source**
71
+ Recording and rendering are separate. Every recording is saved as a standard [asciicast](https://docs.asciinema.org/manual/asciicast/v2/) (`demo.cast`) and frames are computed on a virtual clock, so the same cast renders to the same pixels on any machine — and you can re-render without re-running anything:
65
72
 
66
73
  ```sh
67
- git clone https://github.com/AmanVarshney01/tcut && cd tcut && bun install
68
- bun src/cli.ts examples/demo.ts
74
+ tcut render demo.cast --theme dracula -o demo.gif # new theme
75
+ tcut render demo.cast -o demo.svg -o demo.html # animated SVG for a README, single-file HTML player
76
+ tcut render demo.cast --font-size 24 --speed 1.5 -o demo.mp4
69
77
  ```
70
78
 
71
- ## Requirements
79
+ | Output | Needs | |
80
+ |---|---|---|
81
+ | `.mp4` `.webm` | ffmpeg | H.264 / VP9 |
82
+ | `.gif` `.webp` | ffmpeg | animated, palette-optimised |
83
+ | `.svg` | nothing | animated vector — crisp at any size, ~20 KB, renders on GitHub |
84
+ | `.html` | nothing | self-contained player with play / pause / loop |
85
+ | `.png` `.jpg` | — | the final frame (`t.screenshot()` for any moment) |
86
+ | `frames/` | — | one PNG per frame |
72
87
 
73
- | To… | You need |
88
+ ## Examples
89
+
90
+ | | |
74
91
  |---|---|
75
- | run tcut | **Bun 1.4** (`bun add -g termcut`), or the standalone binary (Bun is embedded) |
76
- | record / `tcut test` | a shell — `bash` (default), `zsh`, `fish` or `sh` plus whatever CLI tools your script runs. No browser, no ffmpeg |
77
- | render `.svg` / `.html` | nothing else pure Bun |
78
- | render `.png` / `.jpg` / `frames/` | a WebView: **macOS → nothing** (system WebKit). **Linux / Windows → Chrome, Chromium, Edge or Brave** installed (Windows ships Edge) |
79
- | render `.mp4` / `.gif` / `.webm` | the WebView above **+ ffmpeg** — `brew install ffmpeg` · `apt install ffmpeg` · `winget install ffmpeg` |
80
- | render `.webp` | an ffmpeg with libwebp — Homebrew: `brew install ffmpeg-full` (tcut finds it automatically; see below) |
81
- | your chosen font | `font.family` must be installed (default stack: JetBrains Mono → Menlo → monospace). SVG output uses the *viewer's* fonts |
82
-
83
- Notes
84
- - tcut looks for ffmpeg in this order: `$TCUT_FFMPEG`, `ffmpeg` on PATH, Homebrew's keg-only `ffmpeg-full`. It picks the first one that has the encoder a given output needs.
85
- - Verified on macOS (Apple Silicon, CI on GitHub's macOS runners). Linux and Windows binaries are cross-compiled but not yet exercised in CI; recording should work everywhere, rendering depends on the WebView backend above.
86
- - Rendering throughput is ~30 output frames/s; idle stretches are free (unchanged frames are reused). A 60 fps, 10 s clip renders in a few seconds.
87
-
88
- ## Why not VHS?
89
-
90
- | | VHS | tcut |
91
- |---|---|---|
92
- | Script format | `.tape` DSL | TypeScript: loops, imports, shared scenes, assertions |
93
- | Wait for output | `Wait` regex on raw bytes | `wait()` / `expect()` / `run()` read the **rendered screen** (headless Ghostty) |
94
- | Determinism | live screenshots, machine-speed dependent | record once to `.cast`, render on a virtual clock → identical frames anywhere |
95
- | Re-theme | re-run everything | `tcut render demo.cast --theme dracula` — no shell is spawned |
96
- | Outputs | mp4 / gif / webm / png frames | + **animated SVG**, **single-file HTML player**, PNG/JPG stills — SVG/HTML need no ffmpeg or browser |
97
- | As tests | — | `tcut test` runs scripts in fast mode, exit code reflects `expect()` |
98
- | Stack | ttyd + Chrome + ffmpeg | Bun + wterm (JS/WASM) + ffmpeg (only for video containers) |
92
+ | **Driving an interactive TUI** answers `bun create better-t-stack` with arrow keys, picks options by reading the screen. [`better-t-stack.ts`](packages/tcut/examples/better-t-stack.ts) | ![better-t-stack](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/examples/better-t-stack.gif) |
93
+ | **Recording AI agents** — `claude -p` explains a file, `codex exec` edits it. [`ai-agents.ts`](packages/tcut/examples/ai-agents.ts) | ![claude and codex](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/examples/ai-agents.gif) |
94
+ | **README media** the GIF at the top of this page. [`readme.ts`](packages/tcut/examples/readme.ts) | |
99
95
 
100
- ## How it works
96
+ More in [`packages/tcut/examples/`](packages/tcut/examples).
101
97
 
102
- ```
103
- script.ts ─▶ record (Bun.Terminal PTY) ─▶ demo.cast ─▶ render (virtual clock) ─▶ mp4 / gif / webm / png
104
- clean shell; every chunk asciicast v2 ├─ Bun.WebView + @wterm/dom → ffmpeg
105
- also feeds a headless ├─ headless grid → animated SVG
106
- Ghostty core for wait/expect └─ cast + lite core → self-contained HTML
98
+ ## Use scripts as tests
99
+
100
+ ```sh
101
+ tcut test examples/
107
102
  ```
108
103
 
109
- - **Record** drives a clean shell (no rc files, fixed prompt, `TERM=xterm-256color`) in a PTY. Output is
110
- timestamped into an asciicast v2 file *and* parsed by a headless Ghostty terminal, so `run()` knows when the
111
- prompt is back and `expect()` sees what a human would see. Terminal queries (e.g. from vim) are answered.
112
- - **Render** replays the cast: frame *N* is the screen at *N / fps*. Hidden sections are cut, playback speed is
113
- applied, cursor blink is driven by the render clock. Unchanged frames are reused, so idle time is free.
114
- - **Cache**: re-running an unchanged script reuses the cast (`--force` to re-record). `quantize: true` snaps
115
- timestamps to the frame grid for byte-stable casts.
104
+ Runs every script in fast mode (no typing delay, no sleeps), renders nothing, and exits non-zero if any
105
+ `expect()` fails so the demo in your README is also the integration test for your CLI.
116
106
 
117
107
  ## CLI
118
108
 
119
109
  ```
120
- tcut <script.ts> [options] record + render
121
- tcut record <script.ts> record only (writes the .cast)
122
- tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
123
- tcut test <path...> run scripts in fast mode as tests (no video)
124
- tcut init [name] [--template t] scaffold a script: basic | tour | test
125
- tcut themes list built-in themes
126
-
127
- -o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/ for PNG frames (repeatable)
128
- --theme <name> catppuccin-mocha | dracula | github-dark | tokyo-night | one-dark
129
- --font <family> --font-size <px> --line-height <x> --letter-spacing <px>
130
- --fps <n> --speed <x>
131
- --padding <px> --margin <px> --margin-fill <color> --radius <px>
132
- --window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
133
- --core <ghostty|lite> --cast <path> --record-only --force -q, --quiet
110
+ tcut <script.ts> record + render
111
+ tcut rec [-- command…] record a live session, then render
112
+ tcut record <script.ts> record only (.cast)
113
+ tcut render <file.cast> render a cast (tcut's or asciinema's)
114
+ tcut test <paths…> run scripts as tests
115
+ tcut init [name] [--template basic|tour|test]
116
+ tcut themes
117
+
118
+ -o, --output <path> repeatable: .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/
119
+ --theme <name> catppuccin-mocha · dracula · github-dark · tokyo-night · one-dark
120
+ --font <family> --font-size <px> --line-height <x> --letter-spacing <px>
121
+ --fps <n> --speed <x> --padding <px> --margin <px> --margin-fill <color> --radius <px>
122
+ --window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
123
+ --core <ghostty|lite> --cast <path> --record-only --force -q
134
124
  ```
135
125
 
136
- ## Script API
126
+ ## Script reference
137
127
 
138
128
  `defineVideo(config, async (t) => { … })`
139
129
 
140
- | Config | Default | |
130
+ **Config** (all optional except `output`)
131
+
132
+ | | default | |
141
133
  |---|---|---|
142
- | `output` | — | string or string[]; extension selects the format |
143
- | `shell` | `"bash"` | `"bash" \| "zsh" \| "fish" \| "sh"` or a full `string[]` command |
144
- | `prompt` / `promptPattern` | `"> "` | prompt for the clean shell; regex used by `run()` / `wait()` |
145
- | `cols` / `rows` / `fps` | 80 / 24 / 60 | |
146
- | `typingSpeed` / `typingJitter` / `seed` | `"50ms"` / 0 / 1 | |
147
- | `playbackSpeed` | 1 | applied at render time |
148
- | `waitTimeout` / `endPause` | `"15s"` / `"1s"` | |
149
- | `quantize` / `cache` / `core` | false / true / `"ghostty"` | `core: "lite"` = wterm's Zig core (faster, no query replies) |
150
- | `font` | JetBrains Mono 20px, lh 1.2 | `{ family, size, lineHeight, letterSpacing }` |
151
- | `theme` | `"catppuccin-mocha"` | name or a full `Theme` object |
134
+ | `output` | — | string or array; extension picks the format |
135
+ | `shell` | `"bash"` | `bash` · `zsh` · `fish` · `sh` · or a `string[]` command |
136
+ | `prompt` | `"> "` | prompt of the clean shell; `run()` waits for it |
137
+ | `cols` · `rows` · `fps` | 80 · 24 · 60 | |
138
+ | `typingSpeed` · `typingJitter` · `seed` | `"50ms"` · 0 · 1 | jitter is seeded, so it's reproducible |
139
+ | `theme` | `"catppuccin-mocha"` | a name or a full theme object |
140
+ | `font` | JetBrains Mono 20 px | `{ family, size, lineHeight, letterSpacing }` |
141
+ | `windowBar` · `title` · `padding` · `margin` · `marginFill` · `borderRadius` | `"none"` · `""` · 24 · 0 · bg · 0 | window chrome |
152
142
  | `cursor` | `{ blink: true, period: 1000 }` | |
153
- | `padding` / `margin` / `marginFill` / `borderRadius` / `windowBar` / `title` | 24 / 0 / bg / 0 / `"none"` / `""` | window chrome |
154
- | `cast` | next to the first output | where the recording is saved |
143
+ | `playbackSpeed` · `waitTimeout` · `endPause` | 1 · `"15s"` · `"1s"` | |
144
+ | `cache` · `quantize` · `core` | true · false · `"ghostty"` | skip re-recording when unchanged · frame-grid timestamps · emulator |
155
145
 
156
- `t` (a `TerminalSession`):
146
+ **`t`**
157
147
 
158
- - Input: `type(text, {speed})`, `run(cmd, {wait, timeout})`, `paste(text)`, `enter() tab() backspace() delete()
159
- escape() space() up() down() left() right() home() end() pageUp() pageDown()` (repeat count), `ctrl("c")`,
160
- `alt("b")`, `key("f5")`, `raw(bytes)`
161
- - Timing: `sleep("500ms")`, `wait(/re/, { scope: "line" | "screen", timeout })`
162
- - Assertions: `expect(/re/, { scope })` throws `ExpectationError` with a screen dump
163
- - Structure: `hide(async () => …)`, `screenshot(path)`, `marker(name)`, `resize(cols, rows)`, `clear()`
164
- - Introspection: `screen()`, `line()`, `cursor()`, `cols`, `rows`, `config`
148
+ - Type: `run(cmd)` · `type(text)` · `paste(text)` · `enter()` `tab()` `backspace()` `escape()` `space()` `up()` `down()` `left()` `right()` `home()` `end()` `pageUp()` `pageDown()` (all take a count) · `ctrl("c")` · `alt("b")` · `key("f5")` · `raw(bytes)`
149
+ - Wait: `sleep("500ms")` · `wait(/re/, { scope: "line" | "screen" })` default waits for the prompt
150
+ - Assert: `expect(/re/)` — throws with a screen dump
151
+ - Shape the video: `hide(async () => …)` cuts a section · `screenshot("x.png")` · `marker("name")` · `resize(cols, rows)` · `clear()`
152
+ - Look: `screen()` · `line()` · `cursor()` · `cols` · `rows`
165
153
 
166
- Durations accept milliseconds or `"500ms" | "1.5s" | "2m"`.
154
+ Durations accept `500`, `"500ms"`, `"1.5s"`, `"2m"`.
167
155
 
168
- Programmatic use: `const v = defineVideo(...); await v.record(); await v.render(undefined, { overrides: { theme: "dracula" } })`,
169
- `renderCast(file, { output })`, `buildSvg(rec, config)`, `runScriptTests(paths)`.
156
+ ## Requirements
170
157
 
171
- ## Development
158
+ | To… | You need |
159
+ |---|---|
160
+ | run tcut | Bun ≥ 1.4, or the standalone binary |
161
+ | record (`rec`, scripts, `test`) | a shell — nothing else |
162
+ | render `.svg` / `.html` | nothing else |
163
+ | render `.png` / `frames/` | macOS: nothing (built-in WebKit) · Linux / Windows: Chrome, Chromium, Edge or Brave |
164
+ | render `.mp4` / `.gif` / `.webm` | the above + ffmpeg |
165
+ | render `.webp` | ffmpeg with libwebp (`brew install ffmpeg-full`; found automatically) |
172
166
 
173
- ```sh
174
- bun test # recorder, renderer, exporters, CLI (spawns real shells + WebView)
175
- bun run typecheck
176
- bun src/cli.ts examples/demo.ts
177
- bun run build # dist/tcut single binary with embedded renderer assets
178
- bun run build:all # cross-compile all platforms into dist/ + SHA256SUMS
179
- ```
167
+ Verified on macOS. Linux and Windows binaries are cross-compiled and not yet exercised in CI.
168
+
169
+ ## How it works, briefly
180
170
 
181
- Releasing: bump `version` in `package.json`, commit, `git tag v<version> && git push --tags`. The release
182
- workflow builds the binaries, publishes the GitHub Release and, when an `NPM_TOKEN` secret is configured,
183
- publishes to npm.
171
+ `Bun.Terminal` runs your shell in a PTY. Output is timestamped into the cast and also fed to a headless
172
+ [Ghostty](https://ghostty.org) terminal (via [wterm](https://github.com/vercel-labs/wterm)), which is how `run()` knows the prompt is back and
173
+ `expect()` sees what you see. Rendering replays the cast into that terminal inside `Bun.WebView` one frame at a time and hands the
174
+ frames to ffmpeg; SVG and HTML are built straight from the terminal grid. Inspired by [VHS](https://github.com/charmbracelet/vhs).
184
175
 
185
- Specs and tasks are tracked with [OpenSpec](https://github.com/Fission-AI/OpenSpec) under `openspec/`; the roadmap
186
- and measurements are in `PLAN.md`.
176
+ Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md). MIT.
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
7
7
  "homepage": "https://github.com/AmanVarshney01/tcut#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/AmanVarshney01/tcut.git"
10
+ "url": "git+https://github.com/AmanVarshney01/tcut.git",
11
+ "directory": "packages/tcut"
11
12
  },
12
13
  "bugs": {
13
14
  "url": "https://github.com/AmanVarshney01/tcut/issues"
package/src/cli.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env bun
2
+ import { mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { parseArgs } from "node:util";
5
+ import { writeCast } from "./cast";
6
+ import { resolveConfig } from "./config";
4
7
  import * as api from "./index";
8
+ import { recordLive } from "./live";
9
+ import { renderOutputs } from "./render";
5
10
  import { runScriptTests } from "./testing";
6
11
  import { themeNames } from "./themes";
7
12
  import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
@@ -23,6 +28,7 @@ const HELP = `tcut — script terminal sessions in TypeScript, render them to vi
23
28
 
24
29
  Usage:
25
30
  tcut <script.ts> [options] record + render
31
+ tcut rec [options] [-- command…] record a LIVE session you drive yourself (no script), then render
26
32
  tcut record <script.ts> [options] record only (writes the .cast)
27
33
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
28
34
  tcut test <path...> run scripts in fast mode as tests (no video)
@@ -276,6 +282,22 @@ async function main(): Promise<void> {
276
282
  console.log(`created ${file}\n\nrun it with:\n ${template === "test" ? `tcut test ${file}` : `tcut ${file}`}`);
277
283
  return;
278
284
  }
285
+ case "rec": {
286
+ // Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
287
+ const overrides = overridesFromFlags();
288
+ const outputs = overrides.output ?? ["rec.mp4"];
289
+ const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
290
+ const command = rest.length > 0 ? rest : undefined;
291
+ const recording = await recordLive(config, { command, log });
292
+ await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
293
+ await writeCast(config.cast, recording);
294
+ log(`\n✔ wrote ${config.cast} (${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s)`);
295
+ if (values["record-only"]) return;
296
+ const result = await renderOutputs(recording, config, progressReporter());
297
+ await reportOutputs(result.outputs, result.screenshots);
298
+ log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
299
+ return;
300
+ }
279
301
  case "record": {
280
302
  if (!rest[0]) fail("record needs a script file");
281
303
  const video = await loadVideo(rest[0]);
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { defineVideo, Video, renderCast, isVideo } from "./video";
2
2
  export type { RunOptions as VideoRunOptions, RunResult, VideoRecordOptions } from "./video";
3
3
  export { renderOutputs } from "./render";
4
+ export { recordLive } from "./live";
5
+ export type { LiveOptions } from "./live";
4
6
  export { buildSvg } from "./export/svg";
5
7
  export { buildHtml } from "./export/html";
6
8
  export { replayFrames } from "./export/frames";
package/src/live.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { MARKER } from "./cast";
2
+ import { shellSetup } from "./recorder";
3
+ import type { CastEvent, Recording, ResolvedConfig } from "./types";
4
+
5
+ export interface LiveOptions {
6
+ /** Run this command instead of the configured clean shell. */
7
+ command?: string[];
8
+ /** Terminal size; defaults to the size of the terminal tcut is running in, then the config. */
9
+ cols?: number;
10
+ rows?: number;
11
+ /** Where to mirror the session (default: this process's stdout). */
12
+ stdout?: { write(data: Uint8Array | string): unknown };
13
+ /** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
14
+ stdin?: NodeJS.ReadStream | null;
15
+ log?: (message: string) => void;
16
+ }
17
+
18
+ /**
19
+ * Record a *live* session: the user (or a pipe) drives the PTY, tcut mirrors it to the terminal and captures
20
+ * every byte with timestamps. No script, no waits — whatever happened is what gets rendered.
21
+ */
22
+ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {}): Promise<Recording> {
23
+ const log = opts.log ?? (() => {});
24
+ const stdin = opts.stdin === undefined ? process.stdin : opts.stdin;
25
+ const stdout = opts.stdout ?? { write: (d: Uint8Array | string) => process.stdout.write(d) };
26
+ const cols = opts.cols ?? process.stdout.columns ?? config.cols;
27
+ const rows = opts.rows ?? process.stdout.rows ?? config.rows;
28
+ const decoder = new TextDecoder("utf-8");
29
+ const events: CastEvent[] = [];
30
+ const startedAt = performance.now();
31
+
32
+ const stamp = (): number => {
33
+ const t = (performance.now() - startedAt) / 1000;
34
+ return config.quantize ? Math.ceil(t * config.fps - 1e-6) / config.fps : Number(t.toFixed(6));
35
+ };
36
+ const push = (type: CastEvent[1], data: string): void => {
37
+ events.push([stamp(), type, data]);
38
+ };
39
+
40
+ const setup = opts.command ? { cmd: opts.command, env: {} } : shellSetup(config);
41
+ const env: Record<string, string> = {
42
+ ...process.env,
43
+ TERM: "xterm-256color",
44
+ COLORTERM: "truecolor",
45
+ ...setup.env,
46
+ ...config.env,
47
+ };
48
+
49
+ let exited = false;
50
+ let resolveExit!: () => void;
51
+ const exitedPromise = new Promise<void>((r) => (resolveExit = r));
52
+
53
+ const proc = Bun.spawn(setup.cmd, {
54
+ cwd: config.cwd,
55
+ env,
56
+ terminal: {
57
+ cols,
58
+ rows,
59
+ name: "xterm-256color",
60
+ data(_terminal, chunk) {
61
+ const text = decoder.decode(chunk, { stream: true });
62
+ if (!text) return;
63
+ stdout.write(chunk);
64
+ push("o", text);
65
+ },
66
+ exit() {
67
+ exited = true;
68
+ resolveExit();
69
+ },
70
+ },
71
+ });
72
+ const terminal = proc.terminal;
73
+ if (!terminal) throw new Error("Bun.spawn did not return a terminal. Is this Bun >= 1.4?");
74
+
75
+ const isTTY = Boolean(stdin && (stdin as NodeJS.ReadStream).isTTY);
76
+ const onData = (chunk: Buffer | string): void => {
77
+ if (exited || terminal.closed) return;
78
+ terminal.write(chunk);
79
+ push("i", typeof chunk === "string" ? chunk : chunk.toString("utf8"));
80
+ };
81
+ const onResize = (): void => {
82
+ const c = process.stdout.columns ?? cols;
83
+ const r = process.stdout.rows ?? rows;
84
+ if (exited || terminal.closed) return;
85
+ terminal.resize(c, r);
86
+ push("r", `${c}x${r}`);
87
+ };
88
+
89
+ if (stdin) {
90
+ if (isTTY) stdin.setRawMode(true);
91
+ stdin.resume();
92
+ stdin.on("data", onData);
93
+ }
94
+ process.on("SIGWINCH", onResize);
95
+ log(`recording ${setup.cmd.join(" ")} at ${cols}x${rows} — exit the shell to stop`);
96
+
97
+ try {
98
+ await Promise.race([exitedPromise, proc.exited]);
99
+ push("m", MARKER.end);
100
+ } finally {
101
+ process.off("SIGWINCH", onResize);
102
+ if (stdin) {
103
+ stdin.off("data", onData);
104
+ if (isTTY) stdin.setRawMode(false);
105
+ stdin.pause();
106
+ }
107
+ try {
108
+ terminal.close();
109
+ } catch {
110
+ /* already closed */
111
+ }
112
+ if (!exited) proc.kill();
113
+ await proc.exited.catch(() => undefined);
114
+ }
115
+
116
+ const duration = events.length > 0 ? events[events.length - 1]![0] : 0;
117
+ return {
118
+ header: {
119
+ version: 2,
120
+ width: cols,
121
+ height: rows,
122
+ timestamp: Math.floor(Date.now() / 1000),
123
+ duration,
124
+ title: config.title || undefined,
125
+ env: { TERM: "xterm-256color", SHELL: setup.cmd[0]! },
126
+ bunVideo: { ...config, cols, rows },
127
+ },
128
+ events,
129
+ };
130
+ }
package/src/recorder.ts CHANGED
@@ -42,12 +42,13 @@ function mulberry32(seed: number): () => number {
42
42
  };
43
43
  }
44
44
 
45
- interface ShellSetup {
45
+ export interface ShellSetup {
46
46
  cmd: string[];
47
47
  env: Record<string, string>;
48
48
  }
49
49
 
50
- function shellSetup(config: ResolvedConfig): ShellSetup {
50
+ /** Command + environment for a clean, rc-free shell with the configured prompt. */
51
+ export function shellSetup(config: ResolvedConfig): ShellSetup {
51
52
  const { shell, prompt } = config;
52
53
  if (Array.isArray(shell)) return { cmd: shell, env: {} };
53
54
  switch (shell) {
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Aman Varshney
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.