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 +21 -144
- package/bin/tcut.mjs +3 -0
- package/package.json +3 -2
- package/src/cli.ts +30 -14
- package/src/live.ts +1 -1
package/README.md
CHANGED
|
@@ -1,176 +1,53 @@
|
|
|
1
1
|
# tcut
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/termcut)
|
|
6
|
-
[](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
|
|
7
|
-
[](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
|

|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
41
|
-
tcut rec -o demo.
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
63
|
-
tcut init demo # scaffold a script to start from
|
|
38
|
+
tcut demo.video.ts
|
|
64
39
|
```
|
|
65
40
|
|
|
66
|
-
|
|
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.
|
|
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
|
-
|
|
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) |  |
|
|
93
|
-
| **Recording AI agents** — `claude -p` explains a file, `codex exec` edits it. [`ai-agents.ts`](packages/tcut/examples/ai-agents.ts) |  |
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
53
|
+
MIT
|
package/bin/tcut.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termcut",
|
|
3
|
-
"version": "0.2.
|
|
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": "./
|
|
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)
|
|
96
|
+
if (!quiet) process.stdout.write(`${msg}\n`);
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
function fail(message: string): never {
|
|
90
|
-
console.error(
|
|
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.
|
|
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.
|
|
162
|
-
if (frame === total) process.
|
|
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)
|
|
176
|
-
for (const shot of screenshots)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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]);
|