Terminal determinism
Committed screenshots should only change when the app changes. For a terminal app, the kit pins everything about the terminal it controls; the rest (data, time, animation, language) is up to the app. This page lists both halves.
The result to aim for: same OS, same pixels. Two runs on one OS produce identical PNGs, and two recordings of a clip of an app with a frozen screen produce byte-identical files. Across operating systems expect small anti-aliasing differences, so run the captures on one OS if committed images must not churn.
What the kit does for you
Section titled “What the kit does for you”- A fixed size.
target.colsandtarget.rows(default 120 by 32) are never read from a window, so the app lays out the same wherever you run it. - A pinned environment. Colors on (
TERM=xterm-256color,COLORTERM=truecolor,FORCE_COLOR=3),TZ=UTC,LANGandLC_ALLset toen_US.UTF-8, and no CI or terminal program hints (CI,NO_COLOR,TERM_PROGRAM,WT_SESSIONand the like are removed). The full list is in Terminal apps. - It waits for a finished screen. It waits for the
readyandwaitFortext, then, before every shot, for the screen to stay unchanged for 100 ms, so a frame the terminal hands over in pieces is never shot half drawn. It compares screens, not output, so an app that redraws the same frame on a timer settles at once. A screen that never stops changing (a clock, a spinner) is shot after at most 1 s, with a warning. - A grace period after
ready(target.inputDelayMs, default 300 ms) before the first key, so keys are not lost while the app is still switching its terminal to raw mode. - A fixed look. A bundled font, a whole-pixel cell width, a fixed line height, a fixed theme and a hidden cursor (unless you ask for it).
- Missing glyphs cannot shift a row. A glyph the font lacks gets its own clipped cell. It still
comes from a system font, so it differs between operating systems: add
terminal.font.fallbackFile(a symbols or Nerd Font) if the app draws icons. - It renders the screen grid, not the bytes. Windows’ ConPTY rewrites an app’s output, but the grid, and so the PNG, comes out the same.
What the kit cannot do
Section titled “What the kit cannot do”The kit can only freeze what the app lets it freeze. A spinner, a clock in the header, “3 minutes
ago”, live data or a first-run tip all change between runs, and nothing outside the app can stop
them. The terminal equivalent of a web app’s ?showcase=1 is a few environment variables the app
reads, set through target.env.
What a TUI should offer
Section titled “What a TUI should offer”A fixture mode
Section titled “A fixture mode”Behind an env var (MYAPP_MOCK=1): stable sample data from the app itself, and no network.
export const loadServers = process.env.MYAPP_MOCK === '1' ? async () => (await import('./fixtures/servers.js')).servers : fetchServers;A frozen mode
Section titled “A frozen mode”Behind another flag, or the same one (MYAPP_SHOWCASE=1): spinners and progress bars stopped on
one frame, “now” pinned to a constant (clocks in headers, “3 minutes ago” labels), and no first-run
splash or update check.
const FROZEN = process.env.MYAPP_SHOWCASE === '1';
export const now = (): Date => (FROZEN ? new Date('2026-01-15T09:30:00Z') : new Date());export const spinnerFrame = (tick: number): number => (FROZEN ? 0 : tick % SPINNER.length);export const checkForUpdates = FROZEN ? async () => undefined : realCheckForUpdates;The kit already sets TZ=UTC, so a pinned instant also prints the same clock time on every machine.
A pinned locale
Section titled “A pinned locale”Dates and numbers formatted inside the app should use a locale the app picks, not the machine’s.
LANG only reaches apps on macOS and Linux; Windows ignores it.
const format = new Intl.DateTimeFormat(process.env.MYAPP_LANG ?? 'en', { dateStyle: 'medium' });A language switch
Section titled “A language switch”An env var that sets the UI language lets one config capture every language, with a function
env:
target: { mode: 'tty', command: ['node', 'dist/cli.js'], env: ({ lang }) => ({ MYAPP_MOCK: '1', MYAPP_SHOWCASE: '1', MYAPP_LANG: lang }),},langs: ['en', 'de', 'ja'],A layout that holds
Section titled “A layout that holds”The app should lay out cleanly at the configured cols and rows. Read the size from the terminal
(the kit’s pseudo terminal reports exactly cols by rows) rather than from COLUMNS and LINES,
which the kit removes.
Configure the capture to match
Section titled “Configure the capture to match”- Set
readyto text the finished first screen shows, not something drawn early. Withoutreadythe kit only waits for any text, which can catch the app halfway through its first screen. - Give every shot that changes screens a
waitFor. Without it the kit waits at most one second for the screen to change and then takes it, which is a race for anything slower. - Use
delayMsonly for a redraw that shows no text you could wait for. - If a screen depends on where earlier shots left off, and that is fragile, use
restart: trueto start that shot from a clean app.
For clips, the same rules keep recordings small as well as repeatable: see Clips.