Skip to content

Tape reference

This is the canonical reference for Betamax tape files. Use it with Tape files, Outputs, and Examples when authoring tapes. The Feature field guide shows rendered examples of each feature.

Betamax parses a tape before it starts a shell. Syntax errors, unknown commands, invalid regexes, unknown settings, type mismatches, unsupported output extensions, and startup-command ordering errors fail before the PTY is spawned.

  • A tape is line oriented.
  • Blank lines are ignored.
  • Lines whose trimmed form starts with # are comments.
  • Tokens follow shell-like quoting and escaping. An unquoted /regex/ after a wait or Set WaitPattern is the exception: it is kept exactly as written. See Regex patterns.
  • One physical line may contain more than one command, such as Type "echo hi" Enter.
  • A word with quotes or backslash escapes is always text, never a command: "Enter" is the text Enter. Quoted text where a command should start is an error.
  • Startup commands belong before runtime commands.

Startup commands are Output, Require, Set, and Env. Runtime commands include typing, keys, waits, sleeps, captions, screenshots, state checkpoints, Hide, Show, Copy, and Paste.

The header is the leading run of Output, Require, Set, and Env commands; the first other command starts the runtime section. Put startup commands in the header. Betamax accepts them later for VHS compatibility, with the same effect as VHS:

Command after runtime commands Effect Warning
Set TypingSpeed Applies to later Type commands No
Set Shell Applies to the whole tape Yes
Any other Set Ignored Yes
Env Applies to the whole tape from shell start Yes
Require Checked before the shell starts Yes
Output Applies to the whole tape No

Warnings go to stderr as warning: <tape>:<line>: <message>. betamax run --quiet hides them. betamax validate prints them and still succeeds; betamax validate --strict fails when any tape has warnings.

Command Behavior Notes
Output <path>.gif Write an animated GIF Encoded in process
Output <path>.webp Write a lossless animated WebP Encoded in process
Output <path>.png Write a final-frame PNG Encoded in process
Output <path>.json Write final terminal state See State JSON
Output <path>.txt Write VHS-compatible text snapshots Also .ascii; see Text outputs
Output <path>.mp4 Write MP4 video Requires ffmpeg on PATH
Output <path>.webm Write WebM video Requires ffmpeg on PATH
Output <dir> Write numbered PNG frames into a directory Extensionless path only
Screenshot <path>.png Write a checkpoint screenshot at this command Encoded in process
Screenshot <path>.webp Write a lossless WebP checkpoint screenshot Encoded in process
State <path>.json Write checkpoint terminal state Only .json is supported

Betamax reports a missing ffmpeg as a runtime error only when a video output is requested. Primary Output paths are grouped by output kind when written, not returned in source order. Checkpoint Screenshot and State commands are side effects at their position in the tape. Caption <text> renders presentation text onto visual outputs, but does not create files.

Defaults below describe Default, which currently selects Framed. See Presets for alternatives.

Setting Value Default Notes
Shell string bash Shell argv is split with shell-like rules.
Theme theme name or inline JSON string Aardvark Ink Ghostty or VHS name, any case.
FontFamily string JetBrains Mono Bundled; see Fonts.
FontSize number 22 Pixels.
LetterSpacing number 0 Additional pixels between cells.
LineHeight number 1 Multiplier applied to font size.
Width number 1200 Final output width in pixels.
Height number 720 Final output height in pixels.
Columns number from Width Terminal columns; sizes the output width.
Rows number from Height Terminal rows; sizes the output height.
Padding number 0 Inner padding before terminal cells.
Framerate number 50 Capture cadence in frames per second.
TypingSpeed duration or number 50ms Typing delay; number values are seconds.
PlaybackSpeed number 1.0 Changes output playback, not PTY timing.
LoopOffset percentage or duration 0 Percent of frames, like VHS; or 2s.
CursorBlink bool true Simulates a half-second blink cadence.
WebpQuality number from 0 to 100, or Lossless Lossless Lossy animated WebP; see WebP.
KeyboardOverlay Off, Keys, Input, or All Off Shows recent input chips in media.
KeyboardOverlayLocation CaptionRow, TopLeft, TopRight, etc. CaptionRow Places keyboard overlay chips.
WaitTimeout duration or number 15s Number values are seconds.
WaitPattern regex string >$ Used by Wait without explicit pattern.
Margin number, or string of 1–4 numbers 32 Pixels; string sets sides as in CSS.
MarginFill string theme gradient Color, gradient, image, or transparent.
WindowBar Rings, RingsRight, Colorful, or etc. Colorful Left dots; any non-empty value enables bar.
WindowBarSize number 30 Bar height in pixels when enabled.
WindowBarColor #rrggbb string theme surface Invalid colors fall back to theme bg.
WindowTitle string empty / disabled Initial centered window bar title.
WindowTitleFromTerminal bool true Let OSC 0/2 update the window title.
WindowTitleColor auto or #rrggbb string auto Invalid colors use automatic contrast.
BorderRadius number 11 Rounded-corner mask radius in pixels.
WindowBorder number 1 Shell outline width; zero disables.
WindowBorderColor string auto Auto contrast or explicit #rrggbb.
WindowBorderOpacity number 0.15 Outline opacity, clamped to 0–1.
Preset Default, Framed, Bare, Inset, or Default-0.1 Default Baseline for appearance settings.
Shadow bool true Outer window shadow.
ShadowBlur number 20 CSS blur radius in pixels.
ShadowOffset number 6 Vertical shadow offset in pixels.
ShadowOpacity number 0.55 Shadow opacity, clamped to 0–1.

Unknown settings and type mismatches are errors. For example, Set Wdith 900 and Set Width "wide" fail before the shell starts instead of silently falling back to defaults.

Betamax embeds JetBrains Mono 2.304, so the default FontFamily renders the same whether or not JetBrains Mono is installed; an installed copy is ignored. Other families come from installed fonts. When the requested family is not installed, Betamax logs FontFamily "<name>" is not installed; using "JetBrains Mono" and renders with the bundled font.

Glyphs that JetBrains Mono lacks, such as CJK, emoji, and Nerd Font icons, still come from installed fallback fonts. For matching output, install the same fallback fonts in CI as locally, for example sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji on Ubuntu.

The terminal grid is derived after all settings are applied. Margin, window-bar decoration, and any presentation row needed for captions or KeyboardOverlayLocation CaptionRow are subtracted from width and height first, then padding, font size, letter spacing, and line height determine the PTY columns and rows. Positive terminal dimensions fit at least one row and column. Settings that leave no terminal canvas are errors; increase dimensions or select Bare for a small image.

Font size, margins, and frame decoration affect the space available to the application. When testing modal placement, centering, wrapping, or split panes, start with the default FontSize and Margin. Increase Width or Height if the application needs more space.

Set Columns and Set Rows ask for an exact terminal grid, like VHS. Betamax grows the output to fit it: the cells, then padding, margin, window bar, and any caption row, rounded up to an even pixel count for video encoders. Font size, letter spacing, and line height change the output size, not the grid.

Set Columns 80
Set Rows 24

Each axis is sized one way. Set only Rows and the width still comes from Width, the preset, or the default. Setting Width and Columns, or Height and Rows, is an error. A preset’s size is a default, so cells replace it without an error. Both values are whole numbers in 1..=65535 and, like other settings, belong in the header. Each captured run prints the resolved grid and output size, for example terminal 80x24 cells, output 1104x622 px.

Set Columns and Set Rows choose the starting grid and size the output around it. Resize changes the grid mid-tape and keeps the output size fixed: a smaller grid leaves empty canvas, and a larger one is clipped. Use Set to size the recording, and Resize to test how an application reacts when its terminal changes size.

Set KeyboardOverlay Input draws input labels on generated PNG, GIF, video, screenshot, and frame-sequence media. Labels appear when input is queued and linger briefly after the input is typed, so review GIFs show the action near the terminal change it caused. The overlay is presentation-only: it does not change PTY input bytes, waits, state JSON, or final output dimensions. KeyboardOverlayLocation CaptionRow reserves a bottom presentation row before deriving the terminal grid so chips do not cover terminal content. Corner locations such as BottomRight draw chips inside the terminal canvas with a small inset from the terminal edge. Caption-row overlays are inset by Padding when the tape has no margin, or by half of BorderRadius from the terminal frame edge when it has one; see Caption for details and workarounds.

Keyboard overlay modes are:

  • Off: no overlay.
  • Keys: explicit key commands only, such as Ctrl+P, Down, Enter, and Escape.
  • Input: key commands plus short typed input that reads like user intent.
  • All: every visible input event, including long Type commands summarized by character count.

For built-in shell names such as bash, zsh, and fish, Betamax starts a clean recording shell with startup files and history disabled where possible. Those shells use the VHS-style colored > prompt by default, so captures do not inherit prompts such as bash-5.3$ from the host environment.

Without Set Shell, tapes run in bash found on PATH, as in VHS, not the host user’s $SHELL. If bash isn’t installed, the run fails before the shell starts; set an installed shell such as Set Shell "sh". Set Shell doesn’t expand environment variables, so name the shell (Set Shell "zsh") rather than writing "$SHELL". On macOS without Homebrew bash, this is the system bash 3.2.

Before the first command, Betamax waits for the shell to be ready for typing: for its line editor to take over the terminal (leave canonical mode), so typed text does not echo ahead of the prompt. Built-in shells are ready within tens of milliseconds. A program that never takes over, such as dash or a custom program reading whole lines, starts the tape after 250 ms. Betamax does not wait for startup output to stop; output after the shell is ready is recorded as it arrives. To start from a particular screen, begin the tape with Wait+Screen.

An unconfigured tape uses Default, which currently selects Framed. Set Preset Default selects the current default explicitly; Set Preset Framed selects that named look directly. Future default selection can change without redefining the named Framed preset. Preset names are case-insensitive. When repeated, the last Set Preset selects the baseline. Ordinary Set values override it regardless of their position relative to Set Preset; repeated ordinary settings retain their existing last-value-wins behavior.

Set FontSize 26
Set Preset Framed
Set Theme "GitHub Light Default"
Set Shadow false

This uses the framed baseline, 26px text, a light theme, and no shadow. Explicit colors survive preset and theme ordering. Set WindowBar "", Set WindowBorder 0, and Set Shadow false disable those effects. To remove all decoration together, select Bare.

Setting Framed Bare Inset Default-0.1
Width × Height 1200×720 1200×720 1200×720 1200×600
Theme Aardvark Ink Aardvark Ink Aardvark Ink Aardvark Blue
FontSize / LetterSpacing / LineHeight 22 / 0 / 1 22 / 0 / 1 22 / 0 / 1 22 / 1 / 1
FontFamily JetBrains Mono JetBrains Mono JetBrains Mono JetBrains Mono
Padding 0 0 0 60
Margin 32 0 16 0
MarginFill theme gradient theme background theme surface theme background
WindowBar / WindowBarSize Colorful / 30 disabled / 30 disabled / 30 disabled / 30
WindowBarColor theme surface theme background theme surface theme background
BorderRadius / WindowBorder 11 / 1 0 / 0 11 / 1 0 / 0
WindowBorderColor / Opacity auto / 0.15 auto / 0.15 auto / 0.15 auto / 0.15
Shadow / Blur / Offset / Opacity true / 20 / 6 / 0.55 false / 28 / 9 / 0.55 false / 28 / 9 / 0.55 false / 28 / 9 / 0.55
WindowTitleFromTerminal true true true false

All presets start with an empty static title and automatic title contrast. Timing, shell, cursor, keyboard overlays, captions, and output format are independent of preset.

Framed provides a window bar, rounded shell, subtle outline, gradient, and shadow for README media. Bare provides undecorated terminal content. Inset provides a compact rounded panel on a solid backdrop, without a bar or shadow. Run examples/presets-{default,framed,bare,inset,default-0.1}.tape to render the same terminal scene with each baseline.

The framed backdrop is a gradient in the theme background’s own hue, darker than the window. Its top is slightly darker than the background with a little more chroma; its bottom is darker still, at 70% of the background’s OKLCH lightness for dark themes and 0.10 below it for light themes. Neutral backgrounds stay neutral. The bar and inset backdrop use the background mixed with 25% black for dark themes and 4% black for light themes. These defaults follow the final selected theme. An explicit MarginFill or WindowBarColor overrides the derived surface.

At a displayed width of 800px, the 1200px source’s 22px text appears approximately 16px tall. Choose a smaller font or larger canvas for denser applications; output scaling is independent.

For tapes migrating from Betamax 0.1 to the planned 0.2 defaults, add Set Preset Default-0.1 in the preamble. This restores the pre-preset 0.1 sizing, typography, theme, and disabled effects. It does not promise pixel-identical VHS rendering. Bare retains the new typography and sizing instead. Framed, Bare, and Inset are visual baselines whose values may evolve. Default-0.1 retains the pre-preset 0.1 default settings; renderer fixes may still change pixels. Set individual values when exact settings matter.

Set WindowBorder 1 enables a subtle inset outline around the terminal and window bar together. It follows the continuous corners of BorderRadius, without a divider between the bar and terminal. The Framed and Inset width is one pixel; Bare and Default-0.1 disable the outline. Fractional widths truncate to integer pixels. Negative or non-finite widths and non-finite opacity values are errors. Opacity is clamped to 0–1; zero leaves rendering unchanged.

WindowBorderColor "auto" (the default) selects white or black for contrast with the final theme background, using linear-sRGB relative luminance with a 0.179 threshold. Explicit #rrggbb colors survive subsequent theme changes. Invalid color strings are errors; auto resets an override.

The shell expands outward into the margin until its border and rounded corners surround the complete terminal cell rectangle. They never paint over or clip cells, including with Padding 0. If the margin cannot hold the shell, startup reports an error: increase Margin or reduce BorderRadius or WindowBorder. Output dimensions, cell placement, PTY rows/columns, raw frames, and state JSON do not change when border width or radius changes. Captions and keyboard overlays draw afterward. Rounded edges blend against the backdrop at the same coordinates, including a shadow when available. Output remains opaque.

MarginFill accepts a single color, Betamax’s linear(...) gradient syntax, or an image. A color is #rrggbb, #rrggbbaa with alpha last as in CSS, or transparent:

Set Margin 44
Set MarginFill "linear(#232640, #0f101a)"
Set MarginFill "linear(to: bottom-right, #232640, #0f101a)"
Set MarginFill "linear(to: left, #232640, #0f101a)"
Set MarginFill "linear(angle: 135deg, #232640, #0f101a)"
Set MarginFill "linear(angle: 135deg, #4d5b9e 0%, #232640 42%, #0f101a 100%)"

Margin takes one number for every side, or a string in CSS shorthand order: one value sets all four sides, two set top/bottom then left/right, three set top, left/right, then bottom, and four set top, right, bottom, left. Values are whole pixels separated by spaces; anything else is an error. Columns and Rows add each side once, so the grid size is unchanged:

Set Margin "40 200 120 40"

linear(...) takes an optional direction followed by at least two comma-separated color stops. Without a direction, it runs from top to bottom. A to: direction points from the first stop to the last, and accepts top, top-right, right, bottom-right, bottom, bottom-left, left, or top-left. angle: <number>deg is measured clockwise from upward: 90deg is left to right and 135deg is top-left to bottom-right. A stop is a color, optionally followed by <position>%. When positions are omitted, they are distributed evenly; when one stop has a position, every stop must have one, in strictly ascending order.

This is Betamax syntax, not a CSS parser: it accepts hex colors, transparent, to: directions, angles, and linear stops only. Colors interpolate in sRGB, rounding each channel with a small, fixed ordered dither to soften banding in GIFs. The gradient spans the entire output, including rounded window corners and the caption row. It does not change the terminal’s background or grid.

With no explicit MarginFill, the backdrop follows the selected preset and final theme. An explicit color or gradient is preserved regardless of Set Theme ordering. Set MarginFill "" disables the margin. Invalid colors and malformed gradients are errors that name the expected syntax. betamax validate checks tape syntax, so it does not reject malformed fill strings. Radial gradients are not supported. See examples/margin-gradient.tape for a complete example.

Set MarginFill transparent leaves the backdrop clear so the output sits on whatever page shows it. Alpha colors tint the page or fade a gradient to clear:

Set Preset Framed
Set MarginFill transparent
Set MarginFill "#0f101a80"
Set MarginFill "linear(to: bottom, #4d5b9e, transparent)"

Stops with alpha blend premultiplied, so a fade to transparent keeps its hue as it clears rather than darkening. With transparent, the margin, caption row, and the area outside rounded corners are fully transparent. The shadow becomes translucent black, so it darkens any page. The window, bar, border, and terminal stay opaque. Captions and keyboard labels keep their theme colors, so check them against the page they sit on.

Output Transparency
PNG, Screenshot Full alpha
PNG sequence, WebP Full alpha
WebM Full alpha (VP9 yuva420p)
GIF One bit: partly transparent pixels, including the shadow, are dropped
MP4 Rejected before the shell starts; H.264 has no alpha

Use PNG or WebP to keep soft shadows and antialiased corners. See examples/margin-transparent.tape.

MarginFill also takes a picture. A bare path works as in VHS and stretches the image to the output size. image(<path>) keeps the aspect ratio and takes an optional fit:

Set MarginFill "backdrop.png"
Set MarginFill "image(backdrop.png)"
Set MarginFill "image(backdrop.png, contain)"
Fit Behavior
cover Default. Scale to fill the output; crop the overflow equally from both sides.
contain Scale to fit inside the output, centered; the theme background fills the gap.
tile Repeat at native size from the top-left corner.
stretch Scale each axis to the output size, as a bare path does.

Any value that is not empty, a # color, linear(...), or transparent is an image path. Relative paths resolve against the working directory first, as in VHS, then against the tape’s directory, so a tape and its picture can move together. ~/ expands to the home directory. PNG, JPEG, and WebP files are supported, detected from their contents; each side may be up to 16384 pixels. The image is decoded and resampled with Lanczos3 once per run.

Unlike invalid colors, image problems stop the run before the shell starts: a missing file names every place it looked, an undecodable file reports the decoder error, and a malformed image(...) lists the accepted forms. betamax validate does not open images.

The image covers the same area as a gradient, including the caption row and the area outside rounded corners. The shadow, captions, and keyboard chips draw over it unchanged, so prefer calm, low-detail pictures behind text. A picture with alpha keeps it and follows the transparency rules above. GIF output shares one 256-color palette between the picture and the terminal, so detailed photos band; use MP4, WebM, or WebP for those. See examples/margin-image.tape.

Shadow draws a black outer shadow behind the rounded terminal window. ShadowBlur is the CSS blur radius, so its standard deviation is half the value; Framed uses 20 (sigma 10); the other styles retain the inactive value 28 (sigma 14). ShadowOffset is a signed vertical displacement, and ShadowOpacity is clamped to 0..1.

Set Margin 44
Set Shadow true
Set ShadowBlur 28
Set ShadowOffset 9
Set ShadowOpacity 0.55

The shadow is clipped to the output frame. Margin 0 or MarginFill "" makes it a no-op, as does zero opacity. Allow a margin of at least about ShadowBlur + abs(ShadowOffset) to avoid obvious clipping. Also allow room for shell expansion around cells; use Margin 44 for extra room. Increase the margin further if the faint tail is still visible at the edge. The shadow shows best on a gradient or on a backdrop lighter than near-black.

BorderRadius uses continuous corners: the curve eases into the straight window edges instead of joining them with a circular arc. The same outline shapes the window mask, inset border, and shadow. The radius clamps to fit small windows; zero keeps square corners. Corner coverage and antialiased circular window buttons are prepared once per run, without resizing terminal pixels or the output.

Set WindowBarColor "#0f101a" fills the window bar with a #rrggbb color. The default is the preset-derived theme surface. An explicit color takes precedence regardless of whether Set Theme appears before or after it. Invalid colors, including an empty string, fall back to the active theme background. The setting has no effect when WindowBar is empty.

Only the bar changes; terminal colors stay in the theme. With BorderRadius, the window’s top corners are rounded and the boundary between the bar and terminal remains straight.

Set WindowBar RingsRight
Set WindowBarColor "#0f101a"
Set Theme '{"name":"Night","background":"#151723"}'
Set BorderRadius 11
Set WindowBar Colorful
Set WindowTitle "jk — log"
Set WindowTitleColor auto

WindowTitle defaults to an empty string and is ignored when WindowBar is empty. The single-line title is centered across the whole bar, with equal space reserved at both ends to avoid buttons in left and Right modes, with a gap of two button radii. Long titles truncate with …; extremely narrow bars clip the ellipsis or omit the title when no space remains. Line breaks become spaces.

The title uses FontFamily at 40% of WindowBarSize (12 pixels for the default 30-pixel bar). WindowTitleColor defaults to auto; omitted, empty, or invalid values also use automatic color. Automatic color starts with 65% theme foreground and 35% effective window-bar color, mixing sRGB channels. It increases the foreground share in one-percent steps until the text color reaches at least 4.5:1 contrast against the bar. If the theme foreground cannot reach that contrast, it chooses black or white, whichever contrasts more. This uses an explicit WindowBarColor when configured. The ratio describes the text color before glyph antialiasing.

An explicit #rrggbb title color bypasses automatic contrast adjustment and survives Set Theme in either order, even when its contrast is low. Titles affect decorated media only; they do not change terminal state.

Set WindowTitleFromTerminal true lets the recorded program change the bar with OSC 0 or OSC 2. The default is true (false with Preset Default-0.1). Set it to false to keep a static title. Custom capture backends follow app titles by default only when they report title-event support; an explicit true still requires that capability. For example, the app can write printf '\033]2;title set by app\007'. BEL and ST terminators are supported by Ghostty.

Set WindowBar Colorful
Set WindowTitle "title set by tape"
Set WindowTitleFromTerminal true
Type "printf '\033]2;title set by app\007'"
Enter
Sleep 1s
WindowTitle "title reset by tape"
Sleep 1s

The latest assignment wins: app titles replace the initial tape title, WindowTitle "text" replaces an app title, and a later OSC can replace it again. Shell startup and prompt OSC titles count too. An empty title explicitly clears the bar; it does not restore the initial value. Repeated identical assignments do not create a transition. The title remains hidden with no bar.

Every visible title transition retains its corresponding terminal frame, including several titles in one output burst. Titles participate in frame coalescing. Before encoding, a transition gets at least one output video frame and 10ms of hold time if its natural hold is shorter. Very fast bursts can therefore extend playback. Hidden updates change the current title without replaying hidden history on Show. Screenshots and final stills use the current title. Title patches are cached with a bounded 32-entry cache for the run.

State JSON always reports the app’s raw terminal title, even with following disabled. Tape display assignments do not change that state. Custom capture backends must implement title observation to support the opt-in; an unsupported backend returns an error instead of silently losing events.

Adds a primary output. The runner classifies it by extension after parsing and before starting the shell; unsupported extensions fail before shell startup.

  • .gif: animated GIF. After the first frame, each frame stores only the region that changed, and unchanged frames extend the previous frame’s hold.
  • .webp: animated WebP with millisecond frame holds. Lossless by default; after the first frame, each frame stores only the region that changed. Set WebpQuality 85 makes it lossy and several times smaller for recordings with images; see WebP encoding.
  • .png: final-frame PNG.
  • .mp4 and .webm: video written through ffmpeg.
  • .json: final terminal-state JSON.
  • .txt or .ascii: VHS-compatible plain-text snapshots of the screen, one after each command (see Text outputs).
  • no extension: directory of numbered PNG frames.

Runner::run_artifacts returns Output paths in deterministic output-kind order, not source order. Inline Screenshot and State commands are checkpoint outputs and are not in that list.

Output demo.txt and Output demo.ascii write the same format as VHS, so VHS golden files compare byte for byte. After each command that runs while the tape is shown, Betamax appends the visible screen: one line per terminal row, trailing whitespace trimmed, blank rows kept. A line of 80 ─ characters follows each snapshot. Styles and the cursor are dropped, wide characters appear once, and the alternate screen is written while an app has it active.

Hide and the commands after it add nothing until Show, which adds a snapshot. Set, Output, and Require never add one. Two choices differ from VHS: VHS also writes a snapshot for each header command after Output, and once output scrolls it dumps the oldest scrollback rows instead of the screen. Betamax skips the header and always writes the visible screen. Each Output gets its own file, where VHS keeps only the last text output.

Fails early if a program is not present on PATH. This is useful for tapes that call optional tools such as git, rg, cargo, or ffmpeg. The check runs before any PTY or output side effects, including for a Require placed after runtime commands, which also warns. It looks for a file with that name in each PATH directory; executable-bit failures are left to the process that later runs the program. A missing program leaves a failure bundle with the report.

Applies one setting from the settings table. Unknown settings and type mismatches are errors. Put Set commands before runtime commands because they affect shell startup, PTY size, capture timing, rendering, or output decoration. After runtime commands, Set TypingSpeed changes the delay for later Type commands, Set Shell still applies to the whole tape, and other settings are ignored with a warning. See Startup commands after runtime commands.

The shell starts in the directory where betamax was run, not the tape file’s directory.

Change the displayed window title immediately in tape order. Unlike the startup-only Set WindowTitle, this command can appear between runtime commands. It accepts one text argument, including an empty string to clear the bar; use quotes for spaces. It does not accept @duration: use Sleep for a deliberate hold. Pending PTY output is consumed before the assignment without waiting for quiet. If that queue exceeds the bounded drain budget, synchronize with the app first. See Following app titles for precedence and output timing.

Adds or overrides an environment variable for the spawned shell. Betamax applies its terminal defaults first, then tape Env values, so a tape can intentionally override variables such as TERM, COLORTERM, NO_COLOR, prompt variables, or application-specific flags. Put Env in the header. The shell is spawned before runtime execution, so an Env after runtime commands still applies from shell start, with a warning.

Types text into the PTY one Unicode scalar at a time. Type@25ms "hello" overrides the default typing speed for that command; otherwise the most recent Set TypingSpeed applies. Betamax drains output and captures frames after each typed character, so text appears progressively. Several strings are joined with one space, as in VHS: Type "echo" "hi" types echo hi and warns. Quoted strings that name a key or command are joined too, so Type "press" "q" types press q. Use Type for printable text and key commands for terminal control keys such as arrows, tab, delete, and interrupt.

Key commands send terminal key sequences to the PTY. They accept an optional @duration suffix, which sleeps after each key press, and an optional repeat count after the key:

Enter
Enter@250ms
Down 3
Shift+Tab
Ctrl+C
Alt+F
Ctrl+Alt+Shift+X
F5

Supported named keys are Escape, Backspace, Delete, Insert, Down, Enter, Space, Tab, Left, Right, Up, PageUp, PageDown, Home, End, and F1 through F25. Single-character key tokens are accepted for modified combinations such as Ctrl+C. Supported modifiers are Ctrl, Alt, and Shift, and they come before the key. A token with any other part, or with more than one key, is an unsupported command rather than a key press. Betamax routes key events through libghostty-vt’s key encoder, with fallbacks for common terminal control sequences.

Mouse coordinates are zero-based terminal cells: column 0, row 0 is the top-left cell. They exclude font padding, margins, window bars, and captions. The pointer starts at (0, 0).

MouseMove 42 8
MouseDown Left
MouseMove 46 8
MouseUp Left
Shift+MouseScroll Down 3

MouseMove <column> <row> moves the pointer; moving with a held button produces a drag. MouseDown <button> and MouseUp <button> accept Left, Middle, or Right. MouseScroll <direction> <count> sends a positive number of wheel ticks at the pointer, with Up, Down, Left, or Right describing wheel direction. These commands accept Ctrl+, Alt+, and Shift+ prefixes in any order. Modifiers apply to that action only.

The application controls mouse reporting. Betamax uses Ghostty’s encoder to respect disabled, press/release, button-motion, and all-motion reporting and the negotiated X10, UTF-8, SGR, urxvt, or SGR-pixel format. Legacy protocols can suppress coordinates they cannot represent. SGR-pixel reports use the center of the requested cell with the current cell metrics. Wheel commands emit press reports without changing held-button state. Multiple held buttons are supported; motion reports the most recently pressed button still held.

Out-of-bounds coordinates, pressing an already-held button, and releasing a button that is not held fail the tape. With reporting disabled, input produces no PTY bytes while pointer/button state still updates. Betamax does not emulate a terminal window’s local selection; use ScrollUp and ScrollDown to move the viewport through scrollback.

Use an output wait after each action to synchronize with application redraw. Mouse commands do not accept @duration; use Sleep separately for recording pacing. Mouse input requires a terminal backend even when the tape requests no media. Custom TerminalSession implementations can opt in by implementing mouse_input; its default reports unsupported input when a mouse command runs.

ScrollUp[@duration] [count] and ScrollDown[@duration] [count]

Section titled “ScrollUp[@duration] [count] and ScrollDown[@duration] [count]”

Move the terminal viewport through scrollback, one row at a time, without sending input to the program. count defaults to 1, and @duration sets the pause after each row; the frame interval applies when it is absent. These match VHS’s commands of the same name.

ScrollUp 10
ScrollDown@50ms 4

Scrolling stops at the oldest scrollback row and at the live screen. While the viewport is scrolled back, new output does not pull it down; Type, key commands, and Paste return it to the bottom before sending input, as Ghostty and VHS do. The alternate screen has no scrollback, so these commands do nothing there. Wait+Screen, AssertText, screenshots, and the viewport fields in state JSON all read the rows currently shown.

Use MouseScroll instead when the program should handle scrolling, such as a TUI with its own scroll region.

Resize the live terminal grid and PTY in cells. Both dimensions must be integers in 1..=65535. The child receives its normal resize notification; follow the command with an output wait to verify redraw rather than assuming a timing delay is sufficient.

Resize 50 16
Wait+Screen@5s "narrow layout"
State target/narrow.json

The media canvas, font metrics, decorations, and padding stay fixed for the entire tape. To size the recording in cells, use Set Columns and Set Rows instead. The live grid starts at the same top-left origin as the original grid. Shrinking leaves unused canvas space in the terminal background color; growing beyond the canvas clips the rendered grid. State JSON and subsequent mouse bounds use the live grid even for clipped cells. The pointer clamps to the last valid column and row after shrinking; held buttons remain held.

The terminal backend updates before the PTY notification, so child redraw goes into the new grid. Either update failing aborts the tape; commands do not continue with inconsistent dimensions. Optional kernel pixel dimensions use grid size times cell metrics, or zero (unspecified) if they exceed the PTY’s 16-bit pixel fields. The PTY control handle remains alive for the session and is released with its reader, writer, and child handles. A custom TerminalSession can implement resize; the default returns an unsupported-resize error when this command runs.

Pauses tape execution while continuing to drain PTY output and capture frames. Durations accept compact forms such as 500ms, 1s, and 2m, VHS-style split forms such as 500 ms and 1 s, and bare numbers such as 0.5 as seconds. Negative durations are errors.

In tests, use sleeps only when there is no prompt, status line, or other text to wait for. In demos, add sleeps after waits to give viewers time to read the output.

Waits for terminal text while continuing to drain output and capture frames. A bare Wait checks the current cursor line.

Waits are assertions and synchronization points. Put Wait, Wait+Line, or Wait+Screen before any sleep that only exists to make a GIF, video, or review artifact readable.

Pattern forms:

  • /regex/: regular expression, validated when the tape is parsed. See Regex patterns.
  • text: plain substring match. Quote text that names a command or key, such as "Enter" or "q". Unquoted, Wait Enter waits for the default pattern and then presses Enter.
  • omitted: use Set WaitPattern, which defaults to the VHS-style prompt regex >$.

Wait@5s sets the timeout for that command; otherwise Set WaitTimeout applies, defaulting to 15s. On timeout, the tape fails with the target and pattern in the error message.

On timeout, the tape fails with the target and pattern in the error. If the shell exits before the pattern matches, the wait fails at once with the exit status instead of running out the timeout. Either failure leaves a failure bundle.

Wait regexes use the Rust regex syntax, which has no lookaround or backreferences.

Write the regex unquoted. After Wait, Wait+Line, Wait+Screen, or Set WaitPattern, an unquoted /regex/ is kept exactly as written, as in VHS:

Wait+Screen /v[0-9]+\.[0-9]+/
Wait+Screen@10s /it's (ok|done)/
Wait+Screen /(?m)^ready *$/
  • Backslashes are kept, so \. matches a literal dot.
  • Spaces and quote characters are part of the pattern. The regex ends at the first / that ends a word and is not escaped, so /a/b/ is the pattern a/b. Write \/ for a slash followed by a space.
  • A quoted pattern such as "/v[0-9]+\.[0-9]+/" is a string first. Single quotes keep it unchanged. Double quotes keep \. but turn \\ into \ and \" into ".
  • Set WaitPattern treats its value as a regex with or without slashes, so >$ and />$/ are the same.

What the regex sees depends on the target:

  • Wait and Wait+Line match the cursor row with trailing blanks removed, so the default >$ matches a prompt that ends in > and a space.
  • Wait+Screen matches every visible row joined into one string. Each row is padded with spaces to the terminal width and ends in a newline. ^ and $ match only at the start and end of the whole screen, so the default >$ never matches and a bare Wait+Screen times out. To anchor to the end of a row, turn on multi-line mode and allow the padding, as in /(?m)^ready *$/. A row-anchored prompt pattern also matches earlier prompts still on screen.

Waits against the current cursor line. This is equivalent to bare Wait with an explicit target, and it is useful when the tape should make the target obvious.

Waits against all visible viewport text. Use this for command output, full-screen TUIs, status lines, or any situation where cursor position should not matter.

Assertions pass on the first frame where their condition holds, checking after each burst of output for up to WaitTimeout. They behave like a Wait that checks cells, styles, or state instead of text, so an assertion right after a keypress does not need a separate wait first. When the condition never holds, the error describes the last frame checked, and the run takes the full WaitTimeout to report it.

This is a deliberate choice. Betamax never waits for output to stop, because a program that animates continuously never stops, and waiting would fail or stall the tape. Checking a single frame instead would make every assertion race the program’s redraw, so each one would need its own wait. A frame that matched only briefly still counts: the terminal showed it.

AssertAbsent <text> follows the same rule: it passes on the first frame without the text, which suits text that an action removes. To check that text never appears, use AssertAbsent@<duration>: it must hold on every frame in that period, starting one frame after the previous command.

Wait+Screen@5s "ready"
AssertText "ready"
AssertCells 4 2 '["1", "5", "0", "0"]'
AssertStyle 4 2 4 '{"fg":"#7ecf91","bold":true}'
AssertAbsent@500ms "error"
AssertState baselines/ready.json
  • AssertText <text> requires a literal visible-screen substring.
  • AssertCells <column> <row> <JSON> matches an exact horizontal range. Supply one JSON string per physical cell, including spaces for blanks and wide-character continuations. For example, '["界", " ", "é"]' occupies three cells; the combining mark stays with e. Coordinates are zero-based and exclude all presentation decoration. Ranges outside the live grid fail.
  • AssertStyle <column> <row> <width> <JSON> checks every cell in the range. fg and bg, when supplied, match resolved #RRGGBB colors. Omitted colors are unconstrained. All attributes match exactly: omitted booleans mean false and omitted underline means none. Accepted attributes are those in State JSON; unknown fields, invalid colors, and underline names fail validation. The width must be positive.
  • AssertAbsent <text> passes on the first frame without the text. AssertAbsent@<duration> <text> instead observes the screen throughout that positive duration, checking after each PTY chunk and at the end. It fails immediately if the substring appears. This samples terminal state; text emitted and erased within one PTY chunk is not observable. The observation begins at the checkpoint frame, and does not make a claim about later output.
  • AssertState <baseline.json> compares the captured state with an existing explicit baseline. Dimensions, cursor, metadata, text, styles, and compact spans all participate. No fields are ignored, including title, working directory, scrollback, and style-table order. The JSON representation of omitted underline and explicit none is treated as equivalent. Capture baselines with State, inspect their text/styles and corresponding screenshot, and approve changes explicitly. Assertions never create or replace baselines automatically.

Failures name the tape line and the expected and actual text, cells, or styles, and leave a failure bundle with a screenshot and state JSON of the checked screen. No pixel golden comparison is performed. Custom backends can implement the optional viewport_cells method for cell/style assertions; text and baseline assertions use existing methods.

Stops appending frames to animated outputs. The PTY still runs, terminal state still updates, waits still work, and checkpoint outputs still see the current terminal. Use this for compilation, setup, or teardown that should not appear in the final GIF/video. Hide controls recording visibility, not execution isolation: hidden commands can still affect final state.

Resumes frame capture and immediately captures the current terminal. This reveals the prepared state without showing the hidden commands that produced it.

Sets a caption rendered onto later visual media frames. The text is one token, so quote captions that contain spaces. Use Caption "" to clear the active caption.

Captions are presentation metadata only. They do not write to the PTY, alter terminal state, affect wait matching, or change final output dimensions. Active captions appear on GIF, PNG, MP4, WebM, frame directory, and Screenshot outputs. State JSON does not include captions. When a tape contains a caption, Betamax reserves a bottom presentation row before deriving the terminal grid so captions do not cover terminal content.

Caption does not capture a frame or add time to animated output by itself. The new caption appears on the next visual frame Betamax renders, such as a frame captured during a later Sleep, Wait, typing, key press, Show, final-frame output, or Screenshot. Add Sleep after a caption-only change when an animation should dwell on the new caption without changing terminal content.

Betamax renders captions left-aligned below the terminal canvas. Their horizontal inset depends on whether the tape sets Margin:

  • Without a margin, the presentation row spans the full image width. Caption text starts Padding pixels from the left edge, in line with the first terminal column, and caption-row keyboard chips keep the same inset from the right edge. With Padding 0, the inset is half the caption font size so text never touches the image edge.
  • With a margin, the presentation row sits below the framed terminal. Captions and chips align with the terminal frame edge and ignore Padding. Both are inset by half of BorderRadius, so their edges feel aligned with rounded corners.

To place captions closer to the image edge than the terminal text, add a margin and reduce Padding by the same amount. For example, replacing Set Padding 20 with Set Margin 8 and Set Padding 12 keeps terminal text and the grid where they were and moves the caption to 8 pixels from the edge. The margin and presentation row share the MarginFill backdrop, so the terminal gains a thin frame in that color. Lowering Padding alone also moves the caption, but changes the terminal grid size.

Captions are single-line presentation text: if a caption does not fit beside right-aligned keyboard chips, Betamax truncates it with ... instead of wrapping. Caption glyphs are clipped to their reserved width as a final guard for font fallback and unusually wide characters. If the caption or keyboard overlay needs more room, increase the tape height or reduce presentation chrome such as margin and window bar size. Keyboard chips reserve caption space using their visible width.

Writes a static screenshot at that point in the tape, using the same theme and frame decoration as primary visual outputs. Accepts .png and .webp, in any case. WebP screenshots are lossless and preserve transparency. To write a static final WebP, put Screenshot final.webp at the end of the tape; Output final.webp always writes an animation.

Screenshot and State hold for one frame at the Framerate (20 ms at the default 50), then capture the terminal as it is. Output that arrives within that frame is included; output still arriving is not waited for, so a program that animates continuously does not block the tape. The end of the tape works the same way: the final frame and Output state are captured one frame after the last command.

A frame caught mid-update is captured as it is: a partial redraw, or a half-sent image from a program that does not use synchronized output. A terminal shows those frames too, and a checkpoint that hid them would hide tearing bugs. To capture a particular screen, put Wait+Screen before the checkpoint.

Programs that use synchronized output (DEC private mode 2026) mark where each update starts and ends. A terminal keeps showing the previous frame until the update ends, and recordings do the same. A checkpoint that lands inside an update keeps reading until the update ends, because the previous screen is no longer available to capture. As in Ghostty, an update that never ends stops being held after one second.

MouseMove, MouseDown, MouseUp, MouseScroll, Resize, ScrollUp, and ScrollDown apply output that has already arrived (for example, a program that just enabled mouse reporting), then act without waiting. WindowTitle does the same, and so does Show when the window title follows the terminal, so a title change the program already sent is in place first.

Writes a terminal-state snapshot, with the same timing as Screenshot. The state includes viewport text, scrollback text, cursor metadata, a default style, compact style deltas, and styled text spans. See State JSON for the schema and snapshot-testing tradeoffs.

Copy stores text in Betamax’s tape-local clipboard. Paste writes that clipboard into the PTY; before any Copy, the clipboard is empty and Paste writes nothing. Neither touches the host operating system clipboard, which keeps tapes deterministic and safe for CI.

Source other.tape inlines the commands of another tape at that point, as VHS does. Use it to share setup across tapes:

Output demo.gif
Source shared/setup.tape
Type "make demo"
  • A relative path resolves against the directory of the tape that contains the Source line, then against the current directory. VHS resolves only against the current directory.
  • Output commands in the sourced tape are dropped with a warning, so they cannot replace the parent’s outputs. Every other command is kept.
  • Sourced Set, Env, and Require commands follow the usual ordering rule. Put Source before the first runtime command if the sourced tape configures anything.
  • Sourced tapes can use Source themselves; a cycle is an error. VHS rejects nested Source.
  • Errors inside a sourced tape name that tape and line, then the line that sourced it.
  • The path must end in .tape, and the file must exist and not be empty.
  • Tape::parse rejects Source because it has no file to resolve paths against. Rust callers use Tape::parse_file or Tape::parse_with_sources.

When a tape command fails, Betamax saves what you need to debug it without a rerun: a screenshot and state JSON of the terminal at that moment, plus a report of which command failed, at which tape line, after how long, and what it was waiting for. The error names the line and the bundle:

line 6: `Wait+Screen@1s "Ready"` failed after 1.04s (tape command 5 of 5): timed out waiting
for screen to match text `Ready`
failure bundle: target/betamax-failures/24623-1792732654799368000-command-5

Bundles go under betamax run --failure-dir <dir>, else BETAMAX_FAILURE_DIR, else target/betamax-failures in the working directory. Each failure gets a new <pid>-<timestamp>-command-<n> directory, so reruns and parallel tapes never overwrite each other. Nothing is written when a tape passes. Library callers choose the root with Runner::with_failure_dir.

File Contents
report.txt Failed command and line, timing, wait pattern, shell status
report.json The same report for scripts
actual.png Screenshot at the failure, decorated like the tape’s images
actual.json State JSON at the failure
cells.json Visible cells with styles, when the backend provides them
diff.txt The error message

report.txt also lists the recent commands, the visible screen, and the last 20 scrollback lines; actual.json holds all of the scrollback. A missing Require program fails before the shell starts, so its bundle has only the reports and diff.txt.

report.json has these fields:

  • schema: 1. It increases when a field changes meaning or is removed.
  • command: the failed command’s number (counted after Source expansion), location (line 6, or setup.tape line 2, sourced at demo.tape line 1), and text.
  • error: the error message.
  • elapsed_ms: how long the failed command ran, or null if the shell had not started.
  • run_elapsed_ms: time since the shell started, or null.
  • wait: for a failed wait, its target (screen or current line), pattern ({"text": …} or {"regex": …}), and timeout_ms, with WaitPattern and WaitTimeout defaults resolved. Otherwise null.
  • shell: {"status": "running"}, {"status": "exited", "code": 3}, {"status": "signaled", "signal": …}, or {"status": "not_started"}.
  • recent_commands: up to six commands ending with the failed one, shaped like command.
  • files: the files written, keyed by screenshot, state, cells, error, report_text, and report_json.
  • artifact_errors: evidence that could not be written. The rest of the bundle is still saved.

Betamax currently exposes these CLI commands:

Command Behavior
betamax new <path> Write a starter tape with an inline command summary
betamax run <path> Parse, execute, render, and write requested outputs
betamax run - Read a tape from standard input
betamax run -o out.gif demo.tape Append one CLI-provided output to the tape
betamax run --failure-dir <dir> Put failure bundles in <dir>
betamax validate <path-or-glob> Parse tapes without starting shells or writing outputs
betamax validate --strict <path> Also fail when a tape has warnings
betamax themes List theme names available to Set Theme
betamax themes --json Print theme names as JSON
betamax themes --markdown Print theme names as Markdown bullets

betamax run --publish is accepted so existing VHS scripts get a clear not-implemented error. See Differences from VHS for VHS commands Betamax omits. Theme lookup, aliases, and name matching are described in Themes and layout.