tcut

Terminal videos,
written in TypeScript

Record a session live, or script it. Render it to MP4, GIF, WebM, SVG or HTML. Same recording, same pixels, every time.

bun add -g termcut

Bun ≥ 1.4. ffmpeg for MP4/GIF; SVG and HTML need nothing else. Or a standalone binary.

75 s tour. Every clip is a tcut recording, composed in Remotion — a build artifact, not a screen grab.

Write it

A script is plain TypeScript that lives next to the code it shows. Scroll: each step adds a line, and the frame on the right is what that line actually produced — rendered by tcut, as text you can select.

One file is the whole video

defineVideo takes the config and a script. Everything the video needs — size, theme, outputs — lives here, next to the code it demonstrates.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
},
);
the terminal after this step — real text, select it
>

run() waits for your prompt

It types the command, presses Enter, and returns when the prompt is back — not after a guessed sleep. Fast machine or slow CI, the video looks the same.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
await t.run("bun --version");
},
);
the terminal after this step — real text, select it
> bun --version1.4.0>

expect() makes it a test

Assert on the rendered screen, including lines that already scrolled away. tcut test runs the same script with no delays and fails the build when the screen does not match.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
await t.run("bun --version");
await t.run("ls");
await t.expect(/package\.json/);
},
);
the terminal after this step — real text, select it
> bun --version1.4.0> lsbun.lock node_modules tsconfig.jsonindex.ts package.json>

snapshot() is a still of this moment

A PNG or SVG of the exact frame, written on every render — README screenshots that can never go stale, because the script that renders the video also renders them.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
await t.run("bun --version");
await t.run("ls");
await t.expect(/package\.json/);
await t.snapshot("files.svg");
},
);
the terminal after this step — real text, select it
> bun --version1.4.0> lsbun.lock node_modules tsconfig.jsonindex.ts package.json>

timelapse() compresses the boring part

An install plays 8× faster — output included, not just the silence. chapter() marks it as an mp4 chapter and a cut point for --split-chapters.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
await t.run("bun --version");
await t.run("ls");
await t.expect(/package\.json/);
await t.snapshot("files.svg");
await t.chapter("Install");
await t.timelapse(() => t.run("bun add zod"), { speed: 8 });
},
);
the terminal after this step — real text, select it
1.4.0> lsbun.lock node_modules tsconfig.jsonindex.ts package.json> bun add zodbun addv1.4.0 (34cbb9a40)installedzod@4.4.31package installed[453.00ms]>

print() is a caption, not a command

Markdown, rendered into the terminal without typing anything — headings, bold, links that stay clickable in SVG and HTML.

import { defineVideo } from "tcut";
export default defineVideo(
{ output: ["demo.mp4", "demo.gif"] },
async (t) => {
await t.run("bun --version");
await t.run("ls");
await t.expect(/package\.json/);
await t.snapshot("files.svg");
await t.chapter("Install");
await t.timelapse(() => t.run("bun add zod"), { speed: 8 });
await t.print("## Done\n\nRender it: `tcut demo.video.ts`");
},
);
the terminal after this step — real text, select it
installedzod@4.4.31package installed[453.00ms]DoneRender it:tcut demo.video.ts>

Then tcut demo.video.ts. Every method, one line each:

run()
waits for your prompt to come back, not for a timer
expect()
asserts on the screen, including lines that already scrolled away
hide()
runs setup off-camera; the state stays
snapshot()
a PNG or SVG still of that exact moment, on every render
chapter()
mp4 chapters, and cut points for --chapters / --split-chapters
print()
Markdown captions rendered into the terminal, nothing typed
zoom()
magnifies a region; keys: true shows what was pressed
timelapse()
fast-forwards an install or a build, not just the silence
browser
a real browser window beside or over the terminal
tcut test
runs every script as a test: no delays, just the assertions

The full surface is in the reference.

Or just record

Your own shell opens — prompt, config, aliases, in your terminal's colours and font. Type, exit. You get the recording and a script of what you typed, with run() calls that wait for your prompt. -- command runs through your shell too, so tcut rec -- ls is your ls.

tcut rec -o demo.gif
tcut rec -o demo.mp4 -- npm create vite

Render again

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.

  • Change the theme

    607 themes, Ghostty's collection. Same recording, new colours.

  • Size and speed

    Pixel dimensions for the platform; playback speed on the render clock.

  • Cut, shadow, watermark

    A window of the recording, polished. Cuts happen on the cast, so this works for SVG as well as GIF.

  • Split by chapter, transparent

    One file per t.chapter(), with a real alpha channel.

  • Join recordings

    Same-size casts, a pause between them, one video.

  • Publish

    To your own S3-compatible bucket. There is no hosted service.

tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg -o demo.html
tcut render demo.cast --width 1280 --height 720 --speed 1.5 -o demo.mp4
tcut render demo.cast --from 2s --to 10s --shadow --watermark "@you" -o clip.gif
tcut render demo.cast --split-chapters --margin-fill transparent -o demo.webm
tcut concat intro.cast demo.cast --gap 500ms -o launch.mp4
tcut publish demo.gif

mp4 · gif · webm · webp · svg · html · png · txt · log

demo.cast, 27 KB, played by a real terminal emulator (Ghostty's core in WASM) in this page — tcut recording tcut. Pause it and select the text. The same recording as demo.svg (96 KB, 48 unique frames).

Faithful to the terminal

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.

  • Arrow keys and pastes arrive exactly as the running program asked: application cursor mode, bracketed paste.
  • Links printed with OSC 8 stay clickable in SVG and HTML.
  • Frames are never torn: synchronized-output blocks are captured whole.
  • Symbols the font lacks — progress blocks, Nerd Font icons — stay on their cell, so status bars never drift.
  • theme: "auto" and font: "auto" render with the colours and font of the terminal you record in — asked from the terminal itself — so the video looks like your terminal, not like a default. tcut rec does this by itself.
  • tcut doctor demo.cast explains what a recording used, and what would not survive a GIF.

Test it

expect() makes a demo a test. tcut test runs it with no delays and exits non-zero when the screen does not match, so the script that renders your README video can guard it in CI. tcut diff catches output changes between two recordings.

tcut test demo.video.ts
tcut diff a.cast b.cast

Or from code

Everything the CLI does is an exported function. Define a video in your own build script, record it, render it, read the results back — this site records its walkthrough frames that way. Bun only, like the CLI.

import { defineVideo, renderCast } from "termcut";
const video = defineVideo({ output: ["demo.mp4", "demo.gif"] }, async (t) => {
await t.run("bun --version");
await t.expect(/1\.\d+/);
});
const { outputs, screenshots } = await video.run({ log: console.log });
await renderCast("old.cast", { output: ["old.webm"], width: 1280, height: 720 });

A browser next to the terminal

For dev-server demos: the page is recorded on the same clock and composited beside or over the terminal. t.focus("browser") brings it to the front.

defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {
await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");
await t.browser.goto("http://localhost:5173");
await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx");
await t.focus("browser");
});

Examples

tcut waits on the rendered screen, so it can drive anything a person can: menus, prompts, agents that think for ten seconds. Scripts in examples/.

bun create better-t-stack answered with arrow keys
bun create better-t-stack, driven with arrow keys
Claude Code's interactive TUI editing a file
Claude Code, the interactive TUI, end to end
lazygit navigated with the keyboard
lazygit
Neovim editing App.tsx with a browser window overlaid showing the page update
nvim + a real browser window, Vite HMR

Compared with VHS

VHS is the reference point and the inspiration. A `.tape` is a fixed script with sleeps, recorded live in Chrome; a tcut script is TypeScript that watches the screen. The notes say where that matters:

import { defineVideo } from "tcut";
export default defineVideo({ output: "demo.gif" }, async (t) => {
await t.run("bun install");
returns when your prompt is back — VHS sleeps for a guessed duration
await t.expect(/installed/);
asserts on the rendered screen; `tcut test` runs it in CI
for (const file of ["a.ts", "b.ts"]) await t.run(`bun ${file}`);
plain TypeScript: loops, imports, shared scenes, autocomplete
});
  • 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.
  • Same emulator as your terminal — Ghostty's core in WASM, its themes, plus SVG and HTML outputs that need no ffmpeg or browser.

How it works

  1. Record. Bun.Terminal runs your shell in a PTY. Every byte is timestamped into a .cast.
  2. Watch. The same bytes feed a headless Ghostty (via wterm). That is how run() knows the prompt is back and expect() sees what you see.
  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.

Built for agents as much as people: no prompts, exit codes, --json, an llms.txt. npx skills add AmanVarshney01/tcut teaches any coding agent tcut, plus a tcut-remotion skill for cutting the footage into a launch video.