termcut 0.1.0 → 0.2.2

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,53 @@
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)
4
- [![npm](https://img.shields.io/npm/v/termcut)](https://www.npmjs.com/package/termcut)
5
- [![license](https://img.shields.io/github/license/AmanVarshney01/tcut)](LICENSE)
3
+ Turn a terminal session into a video. Record it live or script it in TypeScript; render to MP4, GIF, SVG, HTML — identical every time.
6
4
 
7
- Script terminal sessions in **TypeScript**, render them to **reproducible** MP4 / GIF / WebM / SVG / HTML / PNG.
8
-
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.
13
-
14
- ![tcut demo](docs/demo.gif)
15
-
16
- <sub>Made by tcut from [`examples/readme.ts`](examples/readme.ts). Same cast as SVG: [docs/demo.svg](docs/demo.svg).</sub>
17
-
18
- ```ts
19
- // demo.video.ts
20
- import { defineVideo } from "tcut";
21
-
22
- 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
- },
30
- 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
34
- 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");
37
- await t.sleep("1.5s");
38
- },
39
- );
40
- ```
5
+ ![tcut demo](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/demo.gif)
41
6
 
42
7
  ## Install
43
8
 
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
9
  ```sh
48
- bun add -g termcut # then: tcut demo.video.ts
49
- bunx termcut init demo # or run it without installing
10
+ bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
50
11
  ```
51
12
 
52
- **Standalone binary** no Bun required; download from
53
- [Releases](https://github.com/AmanVarshney01/tcut/releases) (`tcut-<version>-<platform>`, checksums in `SHA256SUMS`):
13
+ Standalone binaries: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
54
14
 
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
- ```
15
+ ## Use
59
16
 
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**
17
+ Record what you do:
65
18
 
66
19
  ```sh
67
- git clone https://github.com/AmanVarshney01/tcut && cd tcut && bun install
68
- bun src/cli.ts examples/demo.ts
69
- ```
70
-
71
- ## Requirements
72
-
73
- | To… | You need |
74
- |---|---|
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) |
99
-
100
- ## How it works
101
-
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
20
+ tcut rec -o demo.gif # opens a shell, records until you `exit`
21
+ tcut rec -o demo.mp4 -- npm create vite # or just one command
107
22
  ```
108
23
 
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.
24
+ Or script it:
116
25
 
117
- ## CLI
26
+ ```ts
27
+ // demo.video.ts
28
+ import { defineVideo } from "tcut";
118
29
 
30
+ export default defineVideo({ output: "demo.gif" }, async (t) => {
31
+ await t.run("bun --version"); // type, Enter, wait for the prompt
32
+ await t.expect(/1\.\d+/); // assert on the screen
33
+ await t.sleep("1s");
34
+ });
119
35
  ```
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
134
- ```
135
-
136
- ## Script API
137
-
138
- `defineVideo(config, async (t) => { … })`
139
36
 
140
- | Config | Default | |
141
- |---|---|---|
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 |
152
- | `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 |
155
-
156
- `t` (a `TerminalSession`):
157
-
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`
165
-
166
- Durations accept milliseconds or `"500ms" | "1.5s" | "2m"`.
167
-
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)`.
37
+ ```sh
38
+ tcut demo.video.ts
39
+ ```
170
40
 
171
- ## Development
41
+ Re-render any recording without re-running it:
172
42
 
173
43
  ```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
44
+ tcut render demo.cast --theme dracula -o demo.svg
179
45
  ```
180
46
 
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.
47
+ ## More
48
+
49
+ - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
50
+ - [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
51
+ - [tcut.amanv.dev](https://tcut.amanv.dev)
184
52
 
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`.
53
+ MIT
package/bin/tcut.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ // npm only accepts .js/.mjs bins; Bun imports the TypeScript CLI directly from here.
3
+ import "../src/cli.ts";
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
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"
@@ -32,9 +33,10 @@
32
33
  ".": "./src/index.ts"
33
34
  },
34
35
  "bin": {
35
- "tcut": "./src/cli.ts"
36
+ "tcut": "./bin/tcut.mjs"
36
37
  },
37
38
  "files": [
39
+ "bin",
38
40
  "src",
39
41
  "scripts",
40
42
  "README.md",
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)
@@ -38,6 +44,7 @@ Options (override the script's config):
38
44
  --window-bar <type> none | colorful | colorfulRight | rings | ringsRight
39
45
  --title <text> --no-blink
40
46
  --core <name> ghostty | lite
47
+ --cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
41
48
  --cast <path> where to read/write the .cast
42
49
  --record-only stop after writing the cast
43
50
  --force ignore the cast cache and re-record
@@ -66,6 +73,8 @@ const { values, positionals } = parseArgs({
66
73
  title: { type: "string" },
67
74
  "no-blink": { type: "boolean" },
68
75
  core: { type: "string" },
76
+ cols: { type: "string" },
77
+ rows: { type: "string" },
69
78
  cast: { type: "string" },
70
79
  "record-only": { type: "boolean" },
71
80
  force: { type: "boolean" },
@@ -76,12 +85,19 @@ const { values, positionals } = parseArgs({
76
85
  });
77
86
 
78
87
  const quiet = values.quiet === true;
88
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
89
+ const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
90
+ const green = paint("32");
91
+ const dim = paint("2");
92
+ const red = paint("31");
93
+
94
+ // Status goes to stdout (plain informational output); only real errors go to stderr.
79
95
  const log = (msg: string) => {
80
- if (!quiet) console.error(msg);
96
+ if (!quiet) process.stdout.write(`${msg}\n`);
81
97
  };
82
98
 
83
99
  function fail(message: string): never {
84
- console.error(`error: ${message}`);
100
+ console.error(`${red("error:")} ${message}`);
85
101
  process.exit(1);
86
102
  }
87
103
 
@@ -117,6 +133,8 @@ function overridesFromFlags(): Partial<VideoConfig> {
117
133
  o.core = values.core as CoreName;
118
134
  }
119
135
  if (values.cast) o.cast = values.cast;
136
+ if (values.cols !== undefined) o.cols = num("cols");
137
+ if (values.rows !== undefined) o.rows = num("rows");
120
138
  return o;
121
139
  }
122
140
 
@@ -146,14 +164,14 @@ async function loadVideo(file: string): Promise<Video> {
146
164
  }
147
165
 
148
166
  function progressReporter(): (p: { frame: number; total: number }) => void {
149
- if (quiet || !process.stderr.isTTY) return () => {};
167
+ if (quiet || !process.stdout.isTTY) return () => {};
150
168
  let last = -1;
151
169
  return ({ frame, total }) => {
152
170
  const pct = Math.floor((frame / total) * 100);
153
171
  if (pct === last && frame !== total) return;
154
172
  last = pct;
155
- process.stderr.write(`\r rendering ${frame}/${total} frames (${pct}%)`);
156
- if (frame === total) process.stderr.write("\n");
173
+ process.stdout.write(`\r${dim(` rendering ${frame}/${total} frames (${pct}%)`)}`);
174
+ if (frame === total) process.stdout.write("\n");
157
175
  };
158
176
  }
159
177
 
@@ -165,9 +183,11 @@ async function fileSize(file: string): Promise<string> {
165
183
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
166
184
  }
167
185
 
186
+ const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
187
+
168
188
  async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
169
- for (const out of outputs) log(`✔ wrote ${out} ${await fileSize(out)}`);
170
- for (const shot of screenshots) log(`✔ screenshot ${shot}`);
189
+ for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
190
+ for (const shot of screenshots) ok(`screenshot ${shot}`);
171
191
  }
172
192
 
173
193
  const TEMPLATES: Record<string, (name: string) => string> = {
@@ -276,18 +296,36 @@ async function main(): Promise<void> {
276
296
  console.log(`created ${file}\n\nrun it with:\n ${template === "test" ? `tcut test ${file}` : `tcut ${file}`}`);
277
297
  return;
278
298
  }
299
+ case "rec": {
300
+ // Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
301
+ const overrides = overridesFromFlags();
302
+ const outputs = overrides.output ?? ["rec.mp4"];
303
+ const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
304
+ const command = rest.length > 0 ? rest : undefined;
305
+ // Size: --cols/--rows if given, else the terminal tcut runs in.
306
+ const recording = await recordLive(config, { command, log, cols: overrides.cols, rows: overrides.rows });
307
+ await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
308
+ await writeCast(config.cast, recording);
309
+ log("");
310
+ ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
311
+ if (values["record-only"]) return;
312
+ const result = await renderOutputs(recording, config, progressReporter());
313
+ await reportOutputs(result.outputs, result.screenshots);
314
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
315
+ return;
316
+ }
279
317
  case "record": {
280
318
  if (!rest[0]) fail("record needs a script file");
281
319
  const video = await loadVideo(rest[0]);
282
320
  const rec = await video.record({ log, force: values.force });
283
- log(`✔ ${rec.cached ? "reused" : "wrote"} ${video.config.cast} (${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s) in ${elapsed()}`);
321
+ ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
284
322
  return;
285
323
  }
286
324
  case "render": {
287
325
  if (!rest[0]) fail("render needs a .cast file");
288
326
  const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
289
327
  await reportOutputs(result.outputs, result.screenshots);
290
- log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
328
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
291
329
  return;
292
330
  }
293
331
  case "test": {
@@ -299,9 +337,9 @@ async function main(): Promise<void> {
299
337
  default: {
300
338
  const video = await loadVideo(first!);
301
339
  const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
302
- log(`✔ ${result.cached ? "reused" : "wrote"} ${result.cast}`);
340
+ ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
303
341
  await reportOutputs(result.outputs, result.screenshots);
304
- if (!values["record-only"]) log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
342
+ if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
305
343
  }
306
344
  }
307
345
  }
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}×${rows} — ${opts.command ? "ends when the command exits" : "type exit 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.