termcut 1.0.0 → 1.0.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,38 +1,45 @@
1
1
  # tcut
2
2
 
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.
3
+ Terminal videos, written in TypeScript. Record a session live or script it, then render it to MP4, GIF, WebM, SVG or HTML — the same recording gives the same pixels every time.
4
4
 
5
5
  ![tcut demo](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/demo.gif)
6
6
 
7
+ [tcut.amanv.dev](https://tcut.amanv.dev) · [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) · [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) · [llms.txt](https://tcut.amanv.dev/llms.txt)
8
+
7
9
  ## Install
8
10
 
9
11
  ```sh
10
- bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
12
+ bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
11
13
  ```
12
14
 
13
- Standalone binaries for macOS, Linux and Windows (all tested in CI): [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't. Linux and Windows render pixels through Chrome/Chromium.
15
+ Or a standalone binary for macOS, Linux or Windows from [Releases](https://github.com/AmanVarshney01/tcut/releases) all three are tested in CI.
16
+
17
+ - MP4, GIF, WebM, WebP need `ffmpeg` on the PATH. SVG, HTML and text outputs need nothing.
18
+ - Linux and Windows render pixels through Chrome or Chromium (`BUN_CHROME_PATH` to point at one).
14
19
 
15
- ## Use
20
+ ## Record
16
21
 
17
- Record what you do:
22
+ **Live.** Your shell opens — your prompt, config and aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`). `--clean` opens a plain shell with a `>` prompt instead.
18
23
 
19
24
  ```sh
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
25
+ tcut rec -o demo.gif
26
+ tcut rec -o demo.mp4 -- npm create vite # one command, ends when it exits
22
27
  ```
23
28
 
24
- You get `demo.gif`, the exact recording (`demo.cast`) and an editable script (`demo.video.ts`) of what you typed.
29
+ `-- command` runs through that same shell, so `tcut rec -- ls` records what *your* `ls` shows aliases, functions and colours included. `--raw` runs the binary directly.
25
30
 
26
- Or script it:
31
+ **Scripted.** Plain TypeScript, so loops, helpers and imports work, and the script lives in the repo next to the code it shows.
27
32
 
28
33
  ```ts
29
34
  // demo.video.ts
30
35
  import { defineVideo } from "tcut";
31
36
 
32
- export default defineVideo({ output: "demo.gif" }, async (t) => {
33
- await t.run("bun --version"); // type, Enter, wait for the prompt
34
- await t.expect(/1\.\d+/); // assert on the screen
35
- await t.sleep("1s");
37
+ export default defineVideo({ output: ["demo.mp4", "demo.gif"] }, async (t) => {
38
+ await t.run("bun --version"); // types it, presses Enter, waits for the prompt
39
+ await t.run("ls -la");
40
+ await t.expect(/package\.json/); // asserts on the screen — the demo is a test
41
+ await t.snapshot("files.png"); // a still of this exact moment
42
+ await t.sleep("1.5s");
36
43
  });
37
44
  ```
38
45
 
@@ -40,57 +47,107 @@ export default defineVideo({ output: "demo.gif" }, async (t) => {
40
47
  tcut demo.video.ts
41
48
  ```
42
49
 
43
- Record a real browser next to (or over) the terminal, for dev-server demos:
50
+ What a script can do, one line each:
44
51
 
45
- ```ts
46
- defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {
47
- await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");
48
- await t.browser.goto("http://localhost:5173");
49
- await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx"); // HMR updates the page
50
- await t.focus("browser");
51
- });
52
- ```
53
-
54
- Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `title: "auto"` follows the title the program sets, `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `t.snapshot("hero.png")` (or `.svg`) saves a still of that exact moment on every render, `preset: "x"` sizes it for X.
52
+ | | |
53
+ |---|---|
54
+ | `run(cmd)` | waits for your prompt to come back, not for a timer |
55
+ | `wait(/re/)` · `expect(/re/)` | observe or assert the screen — including lines that already scrolled away (`{ scope: "scrollback" }`) |
56
+ | `type` · `enter` · arrows · `ctrl("c")` · `key("f5")` | keys, sent the way the running program asked for them |
57
+ | `hide(fn)` | runs setup off-camera; the state stays |
58
+ | `snapshot("x.png" \| "x.svg")` | a pixel or vector still of that exact moment, written on every render |
59
+ | `chapter(name)` | mp4 chapters, and cut points for `--chapters` / `--split-chapters` |
60
+ | `print(markdown)` · `title(text)` | captions rendered into the terminal, nothing typed |
61
+ | `zoom({ rows, cols })` | magnifies a region; `keys: true` shows what was pressed |
62
+ | `timelapse(fn, { speed })` | fast-forwards an install or a build, not just the silence |
63
+ | `browser` | a real browser window beside or over the terminal (below) |
55
64
 
56
- Faithful to the terminal: arrows switch to the form vim/less asked for, `t.paste()` uses bracketed paste (no autoindent stair-steps), synchronized-output repaints never show torn frames, and OSC 8 hyperlinks — including Markdown links in `t.print()` captions — stay clickable in SVG and HTML output.
65
+ The full surface is in the [reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md).
57
66
 
58
- Test and inspect: `tcut diff a.cast b.cast` catches output changes in CI, `t.expect(/…/, { scope: "scrollback" })` checks output that scrolled away (`-o demo.log` writes the whole transcript), and `tcut doctor demo.cast` tells you what a recording used — and what can't be rendered (inline images).
67
+ ## Render again
59
68
 
60
- Cut and join without re-recording — on the cast, so every format works:
69
+ Recording and rendering are separate. A recording is an asciicast; frames are computed on a virtual clock. So a new theme, size or format never re-runs a shell and cuts, joins and chapter splits happen on the recording, which is why they work for SVG as well as MP4.
61
70
 
62
71
  ```sh
63
- tcut render demo.cast --from 2s --to 10s -o clip.gif # a window of the video
64
- tcut render demo.cast --split-chapters -o demo.mp4 # one file per t.chapter()
72
+ tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg -o demo.html
73
+ tcut render demo.cast --width 1280 --height 720 --speed 1.5 -o demo.mp4
74
+ tcut render demo.cast --from 2s --to 10s --shadow --watermark "@you" -o clip.gif
75
+ tcut render demo.cast --split-chapters --margin-fill transparent -o demo.webm
65
76
  tcut concat intro.cast demo.cast --gap 500ms -o launch.mp4
77
+ tcut themes # ~600 themes, Ghostty's collection
66
78
  ```
67
79
 
68
- Re-render any recording without re-running it ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
80
+ Outputs by extension: `.mp4` `.gif` `.webm` `.webp` · `.svg` (animated, real text) · `.html` (single-file player) · `.png` `.jpg` (final frame) · `.txt` (final screen) · `.log` (full transcript) · `dir/` (PNG frames).
81
+
82
+ ## Faithful to the terminal
83
+
84
+ The emulator is Ghostty's core, so what tcut sees is what your terminal would show — and what it records is what the program actually received.
85
+
86
+ - Arrow keys and pastes arrive exactly as the running program asked: application cursor mode, bracketed paste.
87
+ - Links printed with OSC 8 — including Markdown links in `print()` captions — stay clickable in SVG and HTML.
88
+ - Frames are never torn: synchronized-output blocks are captured whole.
89
+ - Symbols the font lacks (progress blocks, Nerd Font icons) stay on their cell, so status bars never drift.
90
+ - `tcut doctor demo.cast` explains what a recording used, and what cannot be rendered (inline images).
91
+
92
+ ## Test it
69
93
 
70
94
  ```sh
71
- tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg
95
+ tcut test demo.video.ts # runs the script with no delays — just the assertions
96
+ tcut diff a.cast b.cast # catches output changes between two recordings
72
97
  ```
73
98
 
74
- Share it:
99
+ `expect()` makes a demo a test. `tcut test` runs it fast, and exits non-zero when the screen does not match — so the same script that renders your README video can guard it in CI.
100
+
101
+ ## A browser next to the terminal
102
+
103
+ For dev-server demos: the page is recorded on the same clock and composited beside or over the terminal.
104
+
105
+ ```ts
106
+ defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {
107
+ await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");
108
+ await t.browser.goto("http://localhost:5173");
109
+ await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx"); // HMR updates the page
110
+ await t.focus("browser");
111
+ });
112
+ ```
113
+
114
+ ## Share it
75
115
 
76
116
  ```sh
77
- tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
117
+ tcut publish --setup # once: your own S3-compatible bucket (RustFS, MinIO, R2, S3)
78
118
  tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
79
119
  ```
80
120
 
81
- ## Agents
121
+ There is no hosted service; you bring the bucket.
122
+
123
+ ## For agents
82
124
 
83
125
  ```sh
84
- npx skills add AmanVarshney01/tcut # tcut + tcut-remotion skills for Claude Code, Cursor, etc.
126
+ npx skills add AmanVarshney01/tcut # skills for Claude Code, Cursor, Codex, …
85
127
  ```
86
128
 
87
- Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into launch videos with [Remotion](https://remotion.dev)). Plus [llms.txt](https://tcut.amanv.dev/llms.txt) and `--json` everywhere.
129
+ Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into a launch video with [Remotion](https://remotion.dev)). Every command has `--json`, exit codes and no prompts; [llms.txt](https://tcut.amanv.dev/llms.txt) is the condensed guide.
130
+
131
+ ## Compared with VHS
132
+
133
+ [VHS](https://github.com/charmbracelet/vhs) is the reference point and the inspiration. Where tcut differs:
134
+
135
+ - **Scripts are TypeScript** — loops, imports, shared scenes, autocomplete — instead of a `.tape` DSL.
136
+ - **Waits on the screen** — `run()` returns when your prompt is back; VHS sleeps for a guessed duration.
137
+ - **Rendering never re-runs the shell** — a new theme, size or format is computed from the recording. VHS screenshots Chrome live, so output depends on machine speed.
138
+ - **Demos are tests** — `expect()` asserts on the screen; `tcut test` runs them in CI.
139
+ - **Same emulator as your terminal** — Ghostty's core in WASM, its themes, plus SVG and HTML outputs that need no ffmpeg or browser.
140
+
141
+ ## How it works
142
+
143
+ 1. **Record.** `Bun.Terminal` runs your shell in a PTY. Every byte is timestamped into a `.cast`.
144
+ 2. **Watch.** The same bytes feed a headless [Ghostty](https://ghostty.org) (via [wterm](https://github.com/vercel-labs/wterm)). That is how `run()` knows the prompt is back and `expect()` sees what you see.
145
+ 3. **Render.** The cast replays into the same terminal inside `Bun.WebView`, one frame per tick, straight to ffmpeg. SVG and HTML are built from the terminal grid, no browser involved.
88
146
 
89
147
  ## More
90
148
 
91
- - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
149
+ - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code and Codex
92
150
  - [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
93
- - [llms.txt](https://tcut.amanv.dev/llms.txt) — the same, condensed for coding agents (`--json` gives machine-readable results)
94
151
  - [tcut.amanv.dev](https://tcut.amanv.dev)
95
152
 
96
153
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "1.0.0",
3
+ "version": "1.0.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",
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { readCast, writeCast } from "./cast";
6
6
  import { applyOverrides, resolveConfig } from "./config";
7
7
  import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
+ import { throughShell, userShell } from "./usershell";
9
10
  import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
10
11
  import { diffCasts, type DiffResult } from "./diff";
11
12
  import { diagnoseCast, formatDoctorReport, type DoctorReport } from "./doctor";
@@ -70,6 +71,8 @@ Options (override the script's config):
70
71
  --gap <dur> concat: still time between parts
71
72
  --preset <name> readme | x | youtube | square
72
73
  --browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
74
+ --raw rec: run the command directly instead of through your shell (no aliases/functions)
75
+ --clean rec: open tcut's clean shell (plain > prompt, no personal config) instead of yours
73
76
  --at <seconds> diff: compare the screen at this time instead of the end
74
77
  --images <dir> diff: also write a.png / b.png
75
78
  --cast <path> where to read/write the .cast
@@ -130,6 +133,8 @@ const { values, positionals } = parseArgs({
130
133
  cast: { type: "string" },
131
134
  "record-only": { type: "boolean" },
132
135
  "no-script": { type: "boolean" },
136
+ raw: { type: "boolean" },
137
+ clean: { type: "boolean" },
133
138
  force: { type: "boolean" },
134
139
  setup: { type: "boolean" },
135
140
  open: { type: "boolean" },
@@ -471,11 +476,26 @@ async function main(): Promise<void> {
471
476
  const rawOutputs = overrides.output ?? ["rec.mp4"];
472
477
  const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
473
478
  const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
474
- const command = rest.length > 0 ? rest : undefined;
479
+ // The shell tcut was typed into: `-- cmd` runs through it (aliases, functions, colours) unless --raw;
480
+ // with no command it IS the session — your prompt and config — unless --clean or an explicit --shell.
481
+ const clean = Boolean(values.clean) || overrides.shell !== undefined;
482
+ const shell = (rest.length > 0 ? !values.raw : !clean) ? userShell() : null;
483
+ let command: string[] | undefined;
484
+ let interactive = false;
485
+ if (rest.length > 0) {
486
+ command = shell ? throughShell(rest, shell) : rest;
487
+ if (shell) log(dim(` via ${shell.name} — your aliases and functions apply (--raw to run the binary directly)`));
488
+ } else if (shell) {
489
+ command = [shell.path, "-il"];
490
+ interactive = true;
491
+ log(dim(` your ${shell.name}, with its config (--clean for a plain shell with a > prompt)`));
492
+ }
475
493
  // Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
476
494
  const sized = overrides.width !== undefined || overrides.height !== undefined;
477
495
  const recording = await recordLive(config, {
478
496
  command,
497
+ interactive,
498
+ describe: rest.length > 0 ? rest.join(" ") : shell ? `your ${shell.name}` : undefined,
479
499
  log,
480
500
  cols: overrides.cols ?? (sized ? config.cols : undefined),
481
501
  rows: overrides.rows ?? (sized ? config.rows : undefined),
@@ -1,4 +1,5 @@
1
1
  import { mkdir } from "node:fs/promises";
2
+ import { PIN_CSS } from "../renderer/pin";
2
3
  import path from "node:path";
3
4
  import { barHeight, embedImage, shadowCss, watermarkCss } from "../renderer/page";
4
5
  import { pageAssets } from "../renderer/bundle";
@@ -62,7 +63,8 @@ ${watermarkCss(config)}
62
63
  #bar { height: ${barHeight(config)}px; margin-top: -${Math.min(config.padding, 12)}px; display: flex; align-items: center; justify-content: space-between; font: 13px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; }
63
64
  #bar .dots { display: flex; gap: 8px; } #bar .dot { width: 12px; height: 12px; border-radius: 50%; box-sizing: border-box; display: inline-block; }
64
65
  #bar .title { flex: 1; text-align: center; opacity: .7; } #bar.right .title { text-align: left; }
65
- #term.wterm { ${vars}; --term-font-family: ${font.family}; --term-font-size: ${font.size}px; --term-line-height: ${font.lineHeight}; --term-row-height: ${Math.ceil(font.size * font.lineHeight)}px; letter-spacing: ${font.letterSpacing}px; padding: 0; border-radius: 0; box-shadow: none; background: transparent; cursor: pointer; }
66
+ #term.wterm { ${vars}; --term-font-family: ${font.family}; --term-font-size: ${font.size}px; --term-line-height: ${font.lineHeight}; --term-row-height: ${Math.ceil(font.size * font.lineHeight)}px; letter-spacing: ${font.letterSpacing}px; --vt-letter-spacing: ${font.letterSpacing}px; padding: 0; border-radius: 0; box-shadow: none; background: transparent; cursor: pointer; }
67
+ ${PIN_CSS}
66
68
  #controls { display: flex; gap: 12px; align-items: center; margin-top: 12px; font: 12px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; opacity: .85; }
67
69
  #controls button { background: transparent; color: inherit; border: 1px solid currentColor; border-radius: 6px; width: 34px; height: 26px; cursor: pointer; }
68
70
  #controls input[type=range] { flex: 1; accent-color: ${theme.cursor ?? theme.foreground}; }
package/src/export/svg.ts CHANGED
@@ -108,20 +108,28 @@ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): str
108
108
  // Text runs with identical style (and link); OSC 8 links become real <a> elements
109
109
  const spans: string[] = [];
110
110
  x = 0;
111
- let run: { x: number; text: string; style: string; link: string | null } | null = null;
111
+ let run: { x: number; text: string; style: string; link: string | null; cols: number[]; single: boolean } | null = null;
112
112
  const flushRun = () => {
113
113
  if (!run || !run.text.trim()) return;
114
- const span = `<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`;
114
+ const text = run.text.replace(/\s+$/, "");
115
+ // Fallback-font glyphs are not one cell wide; give every glyph its own x when the run has any non-ASCII
116
+ // and each cell is a single code point (an x list addresses code points, so clusters would misalign).
117
+ const pinned = run.single && /[^\x20-\x7e]/.test(text);
118
+ const xAttr = pinned ? run.cols.slice(0, [...text].length).map((c) => num(c * g.cellW)).join(" ") : num(run.x * g.cellW);
119
+ const span = `<tspan x="${xAttr}"${run.style}>${esc(text)}</tspan>`;
115
120
  spans.push(run.link ? `<a href="${esc(run.link)}">${span}</a>` : span);
116
121
  };
117
122
  for (const cell of cells) {
118
123
  const blank = cell.text === " " && !(cell.flags & (FLAG.underline | FLAG.strike));
119
124
  const style = blank ? "" : styleAttrs(cell, theme.foreground);
125
+ const single = [...cell.text].length === 1;
120
126
  if (run && ((run.style === style && run.link === cell.link) || (blank && run.text.length > 0 && run.link === null))) {
121
127
  run.text += cell.text;
128
+ run.cols.push(x);
129
+ run.single &&= single;
122
130
  } else {
123
131
  flushRun();
124
- run = blank ? null : { x, text: cell.text, style, link: cell.link };
132
+ run = blank ? null : { x, text: cell.text, style, link: cell.link, cols: [x], single };
125
133
  }
126
134
  x += cell.width;
127
135
  }
package/src/live.ts CHANGED
@@ -24,6 +24,10 @@ export interface LiveOptions {
24
24
  /** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
25
25
  stdin?: LiveStdin | null;
26
26
  log?: (message: string) => void;
27
+ /** How to name the command in status lines (defaults to the argv). */
28
+ describe?: string;
29
+ /** The command is a shell the user types into (ends on `exit`), not a program that runs and exits. */
30
+ interactive?: boolean;
27
31
  }
28
32
 
29
33
  /**
@@ -104,11 +108,13 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
104
108
  stdin.on("data", onData);
105
109
  }
106
110
  process.on("SIGWINCH", onResize);
107
- log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
111
+ log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command && !opts.interactive ? "ends when the command exits" : "type exit to stop"}`);
108
112
 
109
113
  try {
110
114
  await Promise.race([exitedPromise, proc.exited]);
111
- push("m", MARKER.end);
115
+ // Hold the final screen for `endPause`, like a scripted recording does — otherwise a command that exits in
116
+ // 20 ms (`tcut rec -- ls`) becomes a three-frame video. The timeline is virtual: stamp it, don't sleep.
117
+ events.push([Number((stamp() + config.endPause / 1000).toFixed(6)), "m", MARKER.end]);
112
118
  } finally {
113
119
  await browser?.stop();
114
120
  const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it