termcut 0.2.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,176 +1,53 @@
1
1
  # tcut
2
2
 
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
-
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)
7
- [![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.
8
4
 
9
5
  ![tcut demo](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/demo.gif)
10
6
 
11
- Website: **[tcut.amanv.dev](https://tcut.amanv.dev)**
12
-
13
7
  ## Install
14
8
 
15
9
  ```sh
16
- bun add -g termcut # installs the `tcut` command (needs Bun ≥ 1.4)
17
- bunx termcut --help # or run it without installing
10
+ bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
18
11
  ```
19
12
 
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
- ```
13
+ Standalone binaries: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
25
14
 
26
- For `.mp4` / `.gif` / `.webm` you also need `ffmpeg` (`brew install ffmpeg`, `apt install ffmpeg`). SVG, HTML and PNG need nothing else.
27
-
28
- ## Record a session
29
-
30
- ### Live — just do it, tcut records it
31
-
32
- ```sh
33
- tcut rec -o demo.gif
34
- ```
15
+ ## Use
35
16
 
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.
17
+ Record what you do:
38
18
 
39
19
  ```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
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
42
22
  ```
43
23
 
44
- ### Scripted — write it once, re-record forever
24
+ Or script it:
45
25
 
46
26
  ```ts
47
27
  // demo.video.ts
48
28
  import { defineVideo } from "tcut";
49
29
 
50
- export default defineVideo(
51
- { output: ["demo.mp4", "demo.gif"], theme: "catppuccin-mocha", cols: 80, rows: 20 },
52
- async (t) => {
53
- await t.run("bun --version"); // types it, presses Enter, waits for the prompt
54
- await t.run("ls -la");
55
- await t.expect(/package\.json/); // assert on the screen — the demo is also a test
56
- await t.sleep("1.5s");
57
- },
58
- );
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
+ });
59
35
  ```
60
36
 
61
37
  ```sh
62
- tcut demo.video.ts # record + render
63
- tcut init demo # scaffold a script to start from
38
+ tcut demo.video.ts
64
39
  ```
65
40
 
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.
68
-
69
- ## Render again, differently
70
-
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:
41
+ Re-render any recording without re-running it:
72
42
 
73
43
  ```sh
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
44
+ tcut render demo.cast --theme dracula -o demo.svg
77
45
  ```
78
46
 
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 |
87
-
88
- ## Examples
89
-
90
- | | |
91
- |---|---|
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) | |
95
-
96
- More in [`packages/tcut/examples/`](packages/tcut/examples).
97
-
98
- ## Use scripts as tests
99
-
100
- ```sh
101
- tcut test examples/
102
- ```
103
-
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.
106
-
107
- ## CLI
108
-
109
- ```
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
124
- ```
125
-
126
- ## Script reference
127
-
128
- `defineVideo(config, async (t) => { … })`
129
-
130
- **Config** (all optional except `output`)
131
-
132
- | | default | |
133
- |---|---|---|
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 |
142
- | `cursor` | `{ blink: true, period: 1000 }` | |
143
- | `playbackSpeed` · `waitTimeout` · `endPause` | 1 · `"15s"` · `"1s"` | |
144
- | `cache` · `quantize` · `core` | true · false · `"ghostty"` | skip re-recording when unchanged · frame-grid timestamps · emulator |
145
-
146
- **`t`**
147
-
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`
153
-
154
- Durations accept `500`, `"500ms"`, `"1.5s"`, `"2m"`.
155
-
156
- ## Requirements
157
-
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) |
166
-
167
- Verified on macOS. Linux and Windows binaries are cross-compiled and not yet exercised in CI.
168
-
169
- ## How it works, briefly
47
+ ## More
170
48
 
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).
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)
175
52
 
176
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md). MIT.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.2.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",
@@ -33,9 +33,10 @@
33
33
  ".": "./src/index.ts"
34
34
  },
35
35
  "bin": {
36
- "tcut": "./src/cli.ts"
36
+ "tcut": "./bin/tcut.mjs"
37
37
  },
38
38
  "files": [
39
+ "bin",
39
40
  "src",
40
41
  "scripts",
41
42
  "README.md",
package/src/cli.ts CHANGED
@@ -44,6 +44,7 @@ Options (override the script's config):
44
44
  --window-bar <type> none | colorful | colorfulRight | rings | ringsRight
45
45
  --title <text> --no-blink
46
46
  --core <name> ghostty | lite
47
+ --cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
47
48
  --cast <path> where to read/write the .cast
48
49
  --record-only stop after writing the cast
49
50
  --force ignore the cast cache and re-record
@@ -72,6 +73,8 @@ const { values, positionals } = parseArgs({
72
73
  title: { type: "string" },
73
74
  "no-blink": { type: "boolean" },
74
75
  core: { type: "string" },
76
+ cols: { type: "string" },
77
+ rows: { type: "string" },
75
78
  cast: { type: "string" },
76
79
  "record-only": { type: "boolean" },
77
80
  force: { type: "boolean" },
@@ -82,12 +85,19 @@ const { values, positionals } = parseArgs({
82
85
  });
83
86
 
84
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.
85
95
  const log = (msg: string) => {
86
- if (!quiet) console.error(msg);
96
+ if (!quiet) process.stdout.write(`${msg}\n`);
87
97
  };
88
98
 
89
99
  function fail(message: string): never {
90
- console.error(`error: ${message}`);
100
+ console.error(`${red("error:")} ${message}`);
91
101
  process.exit(1);
92
102
  }
93
103
 
@@ -123,6 +133,8 @@ function overridesFromFlags(): Partial<VideoConfig> {
123
133
  o.core = values.core as CoreName;
124
134
  }
125
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");
126
138
  return o;
127
139
  }
128
140
 
@@ -152,14 +164,14 @@ async function loadVideo(file: string): Promise<Video> {
152
164
  }
153
165
 
154
166
  function progressReporter(): (p: { frame: number; total: number }) => void {
155
- if (quiet || !process.stderr.isTTY) return () => {};
167
+ if (quiet || !process.stdout.isTTY) return () => {};
156
168
  let last = -1;
157
169
  return ({ frame, total }) => {
158
170
  const pct = Math.floor((frame / total) * 100);
159
171
  if (pct === last && frame !== total) return;
160
172
  last = pct;
161
- process.stderr.write(`\r rendering ${frame}/${total} frames (${pct}%)`);
162
- 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");
163
175
  };
164
176
  }
165
177
 
@@ -171,9 +183,11 @@ async function fileSize(file: string): Promise<string> {
171
183
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
172
184
  }
173
185
 
186
+ const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
187
+
174
188
  async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
175
- for (const out of outputs) log(`✔ wrote ${out} ${await fileSize(out)}`);
176
- 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}`);
177
191
  }
178
192
 
179
193
  const TEMPLATES: Record<string, (name: string) => string> = {
@@ -288,28 +302,30 @@ async function main(): Promise<void> {
288
302
  const outputs = overrides.output ?? ["rec.mp4"];
289
303
  const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
290
304
  const command = rest.length > 0 ? rest : undefined;
291
- const recording = await recordLive(config, { command, log });
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 });
292
307
  await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
293
308
  await writeCast(config.cast, recording);
294
- log(`\n✔ wrote ${config.cast} (${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s)`);
309
+ log("");
310
+ ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
295
311
  if (values["record-only"]) return;
296
312
  const result = await renderOutputs(recording, config, progressReporter());
297
313
  await reportOutputs(result.outputs, result.screenshots);
298
- log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
314
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
299
315
  return;
300
316
  }
301
317
  case "record": {
302
318
  if (!rest[0]) fail("record needs a script file");
303
319
  const video = await loadVideo(rest[0]);
304
320
  const rec = await video.record({ log, force: values.force });
305
- 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()}`);
306
322
  return;
307
323
  }
308
324
  case "render": {
309
325
  if (!rest[0]) fail("render needs a .cast file");
310
326
  const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
311
327
  await reportOutputs(result.outputs, result.screenshots);
312
- 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()}`));
313
329
  return;
314
330
  }
315
331
  case "test": {
@@ -321,9 +337,9 @@ async function main(): Promise<void> {
321
337
  default: {
322
338
  const video = await loadVideo(first!);
323
339
  const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
324
- log(`✔ ${result.cached ? "reused" : "wrote"} ${result.cast}`);
340
+ ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
325
341
  await reportOutputs(result.outputs, result.screenshots);
326
- 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()}`));
327
343
  }
328
344
  }
329
345
  }
package/src/live.ts CHANGED
@@ -92,7 +92,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
92
92
  stdin.on("data", onData);
93
93
  }
94
94
  process.on("SIGWINCH", onResize);
95
- log(`recording ${setup.cmd.join(" ")} at ${cols}x${rows} — exit the shell to stop`);
95
+ log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
96
96
 
97
97
  try {
98
98
  await Promise.race([exitedPromise, proc.exited]);