termcut 1.0.1 → 1.0.3

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 own shell opens — prompt, config, aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`): it reopens your shell (`shell: "user"`), waits for your prompt (`promptPattern`, detected from the recording) and replays the commands as `run()` calls. `--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, fish abbreviations 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.1",
3
+ "version": "1.0.3",
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,8 @@ 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 { detectPromptPattern } from "./promptguess";
10
+ import { throughShell, userShell } from "./usershell";
9
11
  import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
10
12
  import { diffCasts, type DiffResult } from "./diff";
11
13
  import { diagnoseCast, formatDoctorReport, type DoctorReport } from "./doctor";
@@ -70,6 +72,8 @@ Options (override the script's config):
70
72
  --gap <dur> concat: still time between parts
71
73
  --preset <name> readme | x | youtube | square
72
74
  --browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
75
+ --clean rec: a clean shell with a plain > prompt instead of your own (also: run -- command bare)
76
+ --raw rec: run -- command as a bare binary, not through your shell (no aliases/functions)
73
77
  --at <seconds> diff: compare the screen at this time instead of the end
74
78
  --images <dir> diff: also write a.png / b.png
75
79
  --cast <path> where to read/write the .cast
@@ -130,6 +134,8 @@ const { values, positionals } = parseArgs({
130
134
  cast: { type: "string" },
131
135
  "record-only": { type: "boolean" },
132
136
  "no-script": { type: "boolean" },
137
+ raw: { type: "boolean" },
138
+ clean: { type: "boolean" },
133
139
  force: { type: "boolean" },
134
140
  setup: { type: "boolean" },
135
141
  open: { type: "boolean" },
@@ -470,12 +476,29 @@ async function main(): Promise<void> {
470
476
  const overrides = overridesFromFlags();
471
477
  const rawOutputs = overrides.output ?? ["rec.mp4"];
472
478
  const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
473
- const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
474
- const command = rest.length > 0 ? rest : undefined;
479
+ // The shell tcut was typed into is the session (your prompt, config, aliases) and what `-- cmd` runs
480
+ // through. --clean / --raw opt out: a plain shell with a `>` prompt, or the bare binary.
481
+ const shell = values.clean || values.raw ? null : userShell();
482
+ const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast, ...(rest.length === 0 && shell && { shell: "user" }) });
483
+ let command: string[] | undefined;
484
+ let portable: string[] | undefined;
485
+ if (rest.length > 0) {
486
+ if (shell) {
487
+ const through = throughShell(rest, shell);
488
+ command = through.argv;
489
+ portable = through.portable;
490
+ log(dim(` via ${shell.name} — your aliases and functions apply (--raw runs the binary directly)`));
491
+ } else {
492
+ command = rest;
493
+ }
494
+ } else if (shell) {
495
+ log(dim(` your ${shell.name}, with its config (--clean for a plain shell with a > prompt)`));
496
+ }
475
497
  // Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
476
498
  const sized = overrides.width !== undefined || overrides.height !== undefined;
477
499
  const recording = await recordLive(config, {
478
500
  command,
501
+ describe: rest.length > 0 ? rest.join(" ") : shell ? `your ${shell.name}` : undefined,
479
502
  log,
480
503
  cols: overrides.cols ?? (sized ? config.cols : undefined),
481
504
  rows: overrides.rows ?? (sized ? config.rows : undefined),
@@ -488,7 +511,9 @@ async function main(): Promise<void> {
488
511
  ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
489
512
  if (!values["no-script"]) {
490
513
  const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
491
- await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
514
+ // A session in the user's own shell replays with run(): wait for whatever their prompt ends with.
515
+ const promptPattern = !command && config.shell === "user" ? ((await detectPromptPattern(recording, config.core)) ?? undefined) : undefined;
516
+ await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command: portable ?? command, castPath: config.cast, promptPattern }));
492
517
  ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
493
518
  }
494
519
  if (values["record-only"]) {
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { USER_PROMPT_PATTERN } from "./promptguess";
2
3
  import { toMs } from "./duration";
3
4
  import { applyPreset } from "./presets";
4
5
  import { resolveTheme } from "./themes";
@@ -83,7 +84,7 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
83
84
  cast: config.cast ?? castDefault,
84
85
  shell: config.shell ?? "bash",
85
86
  prompt,
86
- promptPattern: (config.promptPattern ?? defaultPromptPattern(prompt)).source,
87
+ promptPattern: (config.promptPattern ?? (config.shell === "user" ? new RegExp(USER_PROMPT_PATTERN) : defaultPromptPattern(prompt))).source,
87
88
  cwd: config.cwd ?? process.cwd(),
88
89
  env: config.env ?? {},
89
90
  cols: cols ?? 80,
package/src/live.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { startBrowserCapture } from "./browser";
2
2
  import { MARKER } from "./cast";
3
3
  import { shellSetup } from "./recorder";
4
+ import { CURSOR_QUERY, hasCursorReport, stripTerminalReplies } from "./replies";
4
5
  import type { CastEvent, Recording, ResolvedConfig } from "./types";
5
6
 
6
7
  /** What live recording needs from a keystroke source: `process.stdin`, or any readable stream (a PassThrough in tests). */
@@ -24,6 +25,8 @@ export interface LiveOptions {
24
25
  /** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
25
26
  stdin?: LiveStdin | null;
26
27
  log?: (message: string) => void;
28
+ /** How to name the command in status lines (defaults to the argv). */
29
+ describe?: string;
27
30
  }
28
31
 
29
32
  /**
@@ -40,10 +43,11 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
40
43
  const events: CastEvent[] = [];
41
44
  const startedAt = performance.now();
42
45
 
43
- const stamp = (): number => {
44
- const t = (performance.now() - startedAt) / 1000;
45
- return config.quantize ? Math.ceil(t * config.fps - 1e-6) / config.fps : Number(t.toFixed(6));
46
- };
46
+ const stampAt = (seconds: number): number => (config.quantize ? Math.ceil(seconds * config.fps - 1e-6) / config.fps : Number(seconds.toFixed(6)));
47
+ const elapsed = (): number => (performance.now() - startedAt) / 1000;
48
+ const stamp = (): number => stampAt(elapsed());
49
+ // The program asked where the cursor is: the next position report on stdin is the terminal's answer, not a key.
50
+ let cursorQueried = false;
47
51
  const push = (type: CastEvent[1], data: string): void => {
48
52
  events.push([stamp(), type, data]);
49
53
  };
@@ -72,6 +76,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
72
76
  data(_terminal, chunk) {
73
77
  const text = decoder.decode(chunk, { stream: true });
74
78
  if (!text) return;
79
+ if (text.includes(CURSOR_QUERY)) cursorQueried = true;
75
80
  stdout.write(chunk);
76
81
  push("o", text);
77
82
  },
@@ -88,7 +93,12 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
88
93
  const onData = (chunk: Buffer | string): void => {
89
94
  if (exited || terminal.closed) return;
90
95
  terminal.write(chunk);
91
- push("i", chunk instanceof Uint8Array ? chunk.toString("utf8") : chunk);
96
+ // Everything goes to the program, but only keystrokes are recorded: the terminal's answers to its queries
97
+ // would otherwise show up as key chips and be typed back on replay.
98
+ const text = chunk instanceof Uint8Array ? chunk.toString("utf8") : chunk;
99
+ const keys = stripTerminalReplies(text, { cursorQueried });
100
+ if (cursorQueried && hasCursorReport(text)) cursorQueried = false;
101
+ if (keys) push("i", keys);
92
102
  };
93
103
  const onResize = (): void => {
94
104
  const c = process.stdout.columns ?? cols;
@@ -104,11 +114,13 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
104
114
  stdin.on("data", onData);
105
115
  }
106
116
  process.on("SIGWINCH", onResize);
107
- log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
117
+ log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
108
118
 
109
119
  try {
110
120
  await Promise.race([exitedPromise, proc.exited]);
111
- push("m", MARKER.end);
121
+ // Hold the final screen for `endPause`, like a scripted recording does — otherwise a command that exits in
122
+ // 20 ms (`tcut rec -- ls`) becomes a three-frame video. The timeline is virtual: stamp it, don't sleep.
123
+ events.push([stampAt(elapsed() + config.endPause / 1000), "m", MARKER.end]);
112
124
  } finally {
113
125
  await browser?.stop();
114
126
  const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it
@@ -0,0 +1,34 @@
1
+ import { loadCore } from "./screen";
2
+ import type { Recording } from "./types";
3
+
4
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+
6
+ /** Prompts end in a symbol: what a generated script waits for when the user's own shell is recorded. */
7
+ export const USER_PROMPT_PATTERN = "[❯>$%#»➜λ]\\s*$";
8
+
9
+ /**
10
+ * What the user's prompt ends with, read from the recording: the text up to the cursor just before the first
11
+ * keystroke is the prompt; its final symbol (`❯`, `$`, `%`, …) is what `run()` should wait for. Replaying through
12
+ * the headless core handles right-hand prompts and cursor movement that a plain text scan would misread.
13
+ */
14
+ export async function detectPromptPattern(rec: Recording, core: "ghostty" | "lite" = "ghostty"): Promise<string | null> {
15
+ const firstInput = rec.events.findIndex((e) => e[1] === "i");
16
+ const before = (firstInput < 0 ? rec.events : rec.events.slice(0, firstInput)).filter((e) => e[1] === "o");
17
+ if (before.length === 0) return null;
18
+ const term = await loadCore(core);
19
+ term.init(rec.header.width, rec.header.height);
20
+ for (const e of before) term.writeString(e[2]);
21
+ const { row, col } = term.getCursor();
22
+ let line = "";
23
+ for (let x = 0; x < col; x++) {
24
+ const cell = term.getCell(row, x);
25
+ if (cell.width === 0) continue;
26
+ line += cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
27
+ }
28
+ const trimmed = line.trimEnd();
29
+ if (!trimmed) return null;
30
+ const ender = [...trimmed].at(-1)!;
31
+ // A letter or digit is not a prompt symbol (a bare path, say); fall back to the generic pattern.
32
+ if (/[\p{L}\p{N}]/u.test(ender)) return null;
33
+ return `${escapeRegExp(ender)}\\s*$`;
34
+ }
package/src/recorder.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { MarkdownRenderer } from "@wterm/markdown";
2
+ import { userShell } from "./usershell";
2
3
  import { startBrowserCapture, type BrowserCapture } from "./browser";
3
4
  import { MARKER } from "./cast";
4
5
  import { toMs } from "./duration";
@@ -45,6 +46,13 @@ export function shellSetup(config: ResolvedConfig): ShellSetup {
45
46
  const { shell, prompt } = config;
46
47
  if (Array.isArray(shell)) return { cmd: shell, env: {} };
47
48
  switch (shell) {
49
+ case "user": {
50
+ // The user's own shell, interactive + login, so its config, prompt and aliases apply. No known shell
51
+ // (Windows, or tcut launched from something that is not bash/zsh/fish) → the clean bash below.
52
+ const own = userShell();
53
+ if (own) return { cmd: [own.path, "-il"], env: {} };
54
+ return shellSetup({ ...config, shell: "bash" });
55
+ }
48
56
  case "bash":
49
57
  return {
50
58
  cmd: ["bash", "--norc", "--noprofile"],
package/src/replies.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Answers a terminal sends to a program's queries — device attributes, kitty keyboard flags, colours via OSC,
2
+ // XTGETTCAP via DCS, cursor position reports. In a live recording they arrive on stdin together with the
3
+ // user's keystrokes, but they are not keystrokes: the key overlay must not show them and a replay must not
4
+ // type them (tcut answers the queries itself then).
5
+
6
+ const ESC = String.fromCharCode(0x1b);
7
+ const BEL = String.fromCharCode(0x07);
8
+
9
+ const REPLIES = new RegExp(
10
+ [
11
+ `${ESC}\\[\\?[\\d;]*[cu]`, // primary DA, kitty keyboard flags
12
+ `${ESC}\\[>[\\d;]*c`, // secondary DA
13
+ `${ESC}\\][\\d;]*[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, // OSC (colour queries)
14
+ `${ESC}P[^${ESC}]*${ESC}\\\\`, // DCS (XTGETTCAP)
15
+ ].join("|"),
16
+ "g",
17
+ );
18
+
19
+ /** Cursor position report `ESC [ row ; col R` — the same bytes as a modified F3 key (`ESC [ 1 ; 2-8 R`). */
20
+ const CPR = new RegExp(`${ESC}\\[\\d+;\\d+R`, "g");
21
+ /** A CPR whose parameters cannot be a modifier-encoded F3: row other than 1, or a "modifier" outside 2–8. */
22
+ const UNAMBIGUOUS_CPR = new RegExp(`${ESC}\\[(?!1;[2-8]R)\\d+;\\d+R`, "g");
23
+
24
+ /** The program asked for the cursor position; the next CPR-shaped input is an answer, not a key. */
25
+ export const CURSOR_QUERY = `${ESC}[6n`;
26
+
27
+ export interface StripOptions {
28
+ /** The program asked `ESC[6n`, so any CPR in this chunk is a reply; without it, only unambiguous reports are removed. */
29
+ cursorQueried?: boolean;
30
+ }
31
+
32
+ export function stripTerminalReplies(input: string, opts: StripOptions = {}): string {
33
+ return input.replace(REPLIES, "").replace(opts.cursorQueried ? CPR : UNAMBIGUOUS_CPR, "");
34
+ }
35
+
36
+ /** Does this input chunk carry a cursor position report (or the look-alike modified F3)? */
37
+ export const hasCursorReport = (input: string): boolean => new RegExp(CPR.source).test(input);
package/src/scriptgen.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { stripTerminalReplies } from "./replies";
2
3
  import { keySequence } from "./keys";
3
4
  import type { Recording } from "./types";
4
5
 
@@ -9,6 +10,8 @@ export interface ScriptGenOptions {
9
10
  cleanShell: boolean;
10
11
  /** The command that was recorded in `-- command` mode (becomes `shell: [...]`). */
11
12
  command?: string[];
13
+ /** Regex source `run()` should wait for — detected from the user's prompt when their own shell was recorded. */
14
+ promptPattern?: string;
12
15
  /** Gaps between keystrokes longer than this become `sleep()` calls. Default 400 ms. */
13
16
  pauseThresholdMs?: number;
14
17
  /** Where the cast lives, for the header comment. */
@@ -128,6 +131,7 @@ function formatMs(ms: number): string {
128
131
 
129
132
  const q = (s: string) => JSON.stringify(s);
130
133
 
134
+
131
135
  /** Turn the `i` (input) events of a recording into a list of script operations. */
132
136
  export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
133
137
  const threshold = opts.pauseThresholdMs ?? 400;
@@ -163,7 +167,7 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
163
167
  }
164
168
  lastTime = time;
165
169
 
166
- for (const token of tokenize(data)) {
170
+ for (const token of tokenize(stripTerminalReplies(data))) {
167
171
  if (token.length > 1 && token[0]! >= " ") {
168
172
  pendingText += token;
169
173
  continue;
@@ -240,6 +244,7 @@ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
240
244
  const config: string[] = [`output: ${JSON.stringify(opts.output)}`];
241
245
  if (opts.command) config.push(`shell: ${JSON.stringify(opts.command)}`);
242
246
  else if (cfg && cfg.shell !== "bash") config.push(`shell: ${JSON.stringify(cfg.shell)}`);
247
+ if (opts.promptPattern) config.push(`promptPattern: /${opts.promptPattern.replace(/\//g, "\\/")}/`);
243
248
  config.push(`cols: ${rec.header.width}`, `rows: ${rec.header.height}`);
244
249
  if (cfg) {
245
250
  if (cfg.theme?.name) config.push(`theme: ${q(cfg.theme.name)}`);
@@ -251,8 +256,10 @@ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
251
256
  const body = ops.length ? ops.map((op) => ` ${opToLine(op)}`).join("\n") : " // (no input was recorded)";
252
257
  const castNote = opts.castPath ? ` The exact recording is in ${path.basename(opts.castPath)}.` : "";
253
258
  const modeNote = opts.command
254
- ? "It runs the same command and replays your keys; waits are the pauses you took, so adjust them if the program is slower elsewhere."
255
- : "Typed commands became run(), which waits for the prompt instead of guessing.";
259
+ ? "It opens the same program and replays your keys; waits are the pauses you took, so adjust them if it is slower elsewhere."
260
+ : cfg?.shell === "user"
261
+ ? "It opens your own shell; typed commands became run(), which waits for your prompt (promptPattern) instead of guessing."
262
+ : "Typed commands became run(), which waits for the prompt instead of guessing.";
256
263
 
257
264
  return `import { defineVideo } from "tcut";
258
265
 
package/src/types.ts CHANGED
@@ -29,7 +29,8 @@ export interface Theme {
29
29
  */
30
30
  export type ThemeName = "catppuccin-mocha" | "dracula" | "github-dark" | "tokyo-night" | "one-dark" | (string & {});
31
31
 
32
- export type ShellName = "bash" | "zsh" | "fish" | "sh";
32
+ /** `user` is the shell tcut was launched from (interactive login, with its config); the others are clean shells with the configured prompt. */
33
+ export type ShellName = "bash" | "zsh" | "fish" | "sh" | "user";
33
34
  /** Terminal emulator core: libghostty (full VT, answers queries) or wterm's lite Zig core (faster, fewer features). */
34
35
  export type CoreName = "ghostty" | "lite";
35
36
  export type WindowBar = "none" | "colorful" | "colorfulRight" | "rings" | "ringsRight";
@@ -0,0 +1,203 @@
1
+ import path from "node:path";
2
+
3
+ // `tcut rec` should record what the user sees in their own terminal: their shell, with its prompt, aliases,
4
+ // functions, abbreviations and colour settings. Running the bare binary or a clean shell gives none of that
5
+ // ("Executable not found" for an alias, plain output for a coloured one).
6
+
7
+ export interface UserShell {
8
+ path: string;
9
+ /** bash | zsh | fish — the shells whose `-ic <command>` / `-il` forms load the interactive config. */
10
+ name: string;
11
+ }
12
+
13
+ const INTERACTIVE_SHELLS = new Set(["bash", "zsh", "fish"]);
14
+
15
+ interface ParentProcess {
16
+ /** Executable as reported by `ps -o comm=` (login shells show a leading dash: "-fish"). */
17
+ command: string;
18
+ /** Full command line as reported by `ps -o args=`. */
19
+ args: string;
20
+ }
21
+
22
+ /** The process that launched tcut (macOS/Linux `ps`). Null when that cannot be read. */
23
+ function parentProcess(): ParentProcess | null {
24
+ const field = (name: string): string => Bun.spawnSync(["ps", "-o", `${name}=`, "-p", String(process.ppid)], { env: process.env }).stdout.toString().trim();
25
+ try {
26
+ const command = field("comm");
27
+ if (!command) return null;
28
+ return { command, args: field("args") };
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Is this shell invocation the user's interactive session, rather than a script runner? A shell running a
36
+ * script (`bash run.sh`) or a one-liner (`bash -c …`) is not the shell the user lives in — its rc files and
37
+ * aliases are the wrong ones — so only flag-only invocations (`fish`, `-zsh`, `/bin/bash -il`) count.
38
+ */
39
+ export function isInteractiveInvocation(comm: string, args: string): boolean {
40
+ if (comm.startsWith("-")) return true; // login shell
41
+ const words = args.split(/\s+/).filter(Boolean);
42
+ const rest = words[0] && !words[0].startsWith("-") ? words.slice(1) : words; // drop argv[0] if present
43
+ for (const w of rest) {
44
+ if (w === "-c" || !w.startsWith("-")) return false;
45
+ }
46
+ return true;
47
+ }
48
+
49
+ /**
50
+ * The shell the user typed tcut into: the parent process when it is an interactive bash/zsh/fish, else $SHELL.
51
+ * Null on Windows or when neither is a known shell.
52
+ */
53
+ export function userShell(): UserShell | null {
54
+ if (process.platform === "win32") return null;
55
+ const candidates: string[] = [];
56
+ const parent = parentProcess();
57
+ if (parent) {
58
+ const comm = parent.command.replace(/^-/, "");
59
+ if (INTERACTIVE_SHELLS.has(path.basename(comm)) && isInteractiveInvocation(parent.command, parent.args)) candidates.push(comm);
60
+ }
61
+ if (process.env.SHELL) candidates.push(process.env.SHELL);
62
+ for (const candidate of candidates) {
63
+ const name = path.basename(candidate);
64
+ if (!INTERACTIVE_SHELLS.has(name)) continue;
65
+ const resolved = candidate.includes("/") ? candidate : Bun.which(candidate);
66
+ if (resolved) return { path: resolved, name };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** One argument, single-quoted for the given shell. POSIX shells take everything literally; fish reads `\'` and `\\`. */
72
+ export function quoteArg(arg: string, shellName: string): string {
73
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg;
74
+ const inner = shellName === "fish" ? arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : arg.replace(/'/g, `'\\''`);
75
+ return `'${inner}'`;
76
+ }
77
+
78
+ export function shellQuote(args: string[], shellName: string): string {
79
+ return args.map((a) => quoteArg(a, shellName)).join(" ");
80
+ }
81
+
82
+ export interface FishAbbreviation {
83
+ name: string;
84
+ expansion: string;
85
+ }
86
+
87
+ /** Fish's own quoting, as `abbr --show` prints it: bare, '…' (escapes \' \\) or "…" (escapes \" \\ \$). */
88
+ function fishTokens(line: string): string[] {
89
+ const out: string[] = [];
90
+ let cur = "";
91
+ let quote: "'" | '"' | null = null;
92
+ let inWord = false;
93
+ for (let i = 0; i < line.length; i++) {
94
+ const ch = line[i]!;
95
+ if (quote) {
96
+ if (ch === "\\" && i + 1 < line.length) {
97
+ const next = line[i + 1]!;
98
+ const escapable = quote === "'" ? "\\'" : '\\"$';
99
+ if (escapable.includes(next)) {
100
+ cur += next;
101
+ i++;
102
+ continue;
103
+ }
104
+ }
105
+ if (ch === quote) quote = null;
106
+ else cur += ch;
107
+ continue;
108
+ }
109
+ if (ch === "'" || ch === '"') {
110
+ quote = ch;
111
+ inWord = true;
112
+ continue;
113
+ }
114
+ if (/\s/.test(ch)) {
115
+ if (inWord) out.push(cur);
116
+ cur = "";
117
+ inWord = false;
118
+ continue;
119
+ }
120
+ cur += ch;
121
+ inWord = true;
122
+ }
123
+ if (inWord) out.push(cur);
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * One `abbr --show` line → the abbreviation it declares, or null for the kinds that cannot be expanded up
129
+ * front (regex names, function-computed expansions, anywhere-position). `--set-cursor` markers are removed.
130
+ */
131
+ export function parseAbbrLine(line: string): FishAbbreviation | null {
132
+ const tokens = fishTokens(line.trim());
133
+ if (tokens[0] !== "abbr") return null;
134
+ let cursorMarker: string | null = null;
135
+ let i = 1;
136
+ for (; i < tokens.length; i++) {
137
+ const t = tokens[i]!;
138
+ if (t === "--") {
139
+ i++;
140
+ break;
141
+ }
142
+ if (t === "-a" || t === "--add" || t === "-g" || t === "--global" || t === "-U" || t === "--universal") continue;
143
+ if (t === "--regex" || t === "--function" || t === "-f" || t === "--command") return null;
144
+ if (t === "--position") {
145
+ if (tokens[i + 1] !== "command") return null;
146
+ i++;
147
+ continue;
148
+ }
149
+ if (t.startsWith("--position=")) {
150
+ if (t !== "--position=command") return null;
151
+ continue;
152
+ }
153
+ if (t === "--set-cursor") {
154
+ cursorMarker = "%";
155
+ continue;
156
+ }
157
+ if (t.startsWith("--set-cursor=")) {
158
+ cursorMarker = t.slice("--set-cursor=".length) || "%";
159
+ continue;
160
+ }
161
+ if (t.startsWith("--regex=") || t.startsWith("--function=") || t.startsWith("--command=")) return null;
162
+ if (t.startsWith("-")) continue; // unknown flag: ignore
163
+ break; // name without a `--` separator (older fish)
164
+ }
165
+ const name = tokens[i];
166
+ const expansion = tokens[i + 1];
167
+ if (!name || expansion === undefined) return null;
168
+ return { name, expansion: cursorMarker ? expansion.split(cursorMarker).join("") : expansion };
169
+ }
170
+
171
+ /**
172
+ * fish abbreviations expand only at the prompt, never in `fish -c`; expand a leading one ourselves. The list is
173
+ * read from an interactive fish, because the stock config.fish declares them inside `if status is-interactive`.
174
+ */
175
+ export function fishAbbreviation(shell: UserShell, word: string): string | null {
176
+ if (shell.name !== "fish") return null;
177
+ try {
178
+ // env is passed explicitly: Bun.spawnSync otherwise uses the environment as it was at startup.
179
+ const r = Bun.spawnSync([shell.path, "-ic", "abbr --show"], { stdin: "ignore", env: process.env });
180
+ for (const line of r.stdout.toString().split("\n")) {
181
+ const abbr = parseAbbrLine(line);
182
+ if (abbr && abbr.name === word) return abbr.expansion;
183
+ }
184
+ } catch {
185
+ /* no abbreviations available */
186
+ }
187
+ return null;
188
+ }
189
+
190
+ export interface ShellCommand {
191
+ /** What to spawn. */
192
+ argv: string[];
193
+ /** The same command with the shell by name, for scripts that should run on other machines. */
194
+ portable: string[];
195
+ }
196
+
197
+ /** `args` the way the user's shell runs them when typed: interactive, so aliases, functions and abbreviations apply. */
198
+ export function throughShell(args: string[], shell: UserShell): ShellCommand {
199
+ const [first, ...rest] = args;
200
+ const expanded = first ? fishAbbreviation(shell, first) : null;
201
+ const line = expanded ? `${expanded} ${shellQuote(rest, shell.name)}`.trim() : shellQuote(args, shell.name);
202
+ return { argv: [shell.path, "-ic", line], portable: [shell.name, "-ic", line] };
203
+ }