Doc captures (screenshots & GIFs)
Screenshots and GIFs of the chat TUI are generated, not hand-taken, so they stay current as the TUI evolves. One command regenerates every asset; the diff of docs/assets/ tells reviewers what changed.
How it works
Two pieces:
- VHS drives a real PTY and renders a declarative
.tapescript (typed keystrokes + sleeps) into a GIF, MP4, or PNG. - Fake LLM mode — when
BOTHOLOMEW_FAKE_LLM=1is set, every Anthropic client in the codebase is swapped for a scripted stub that streams fixture-defined replies (seesrc/worker/fake-llm.ts). This makes captures hermetic: no API key required, no network, and every run produces the same output.
Install once
brew install vhs ttyd ffmpeg(Linux: apt install ttyd ffmpeg plus VHS from its releases page.)
Regenerate all assets
bun run captureThe script creates an ephemeral project directory under $TMPDIR, runs botholomew init in it, then runs VHS once per tape in docs/tapes/ — serially, since VHS contends for the tty. Output GIFs land in docs/assets/. Commit those changes alongside the TUI change that prompted them.
Run a single tape:
bun run capture chat-happy-pathAdding a new capture
Write a fixture under
docs/tapes/fixtures/<name>.json:json{ "turns": [ { "match": "optional regex against the user's message", "text": "The reply to stream back.", "chunkSize": 5, "delayMs": 30 } ] }Turns without a
matchare consumed in order. AddtoolCallsif the capture needs to show tool use. An optional top-levelenvobject is merged into the VHS process env — handy for enabling capture-only hooks likeBOTHOLOMEW_CAPTURE_TAB_CYCLE(see Capture-only hooks below). Fixtures are optional: a tape that doesn't invokebotholomew chat(e.g. a CLI demo) can skip the fixture file entirely.Write a tape at
docs/tapes/<name>.tape:tapeSource docs/tapes/_common.tape Output docs/assets/<name>.gif Sleep 1s Type "botholomew chat" Sleep 600ms Enter Sleep 4s Type `whats on my schedule today` Sleep 600ms Enter Sleep 10sNote:
Type "..."(double-quoted) for the shell command,Type `...`(backticked) for anything typed into the TUI — see the limitations section above.The fixture file must share the tape's base name.
_common.tapepins terminal dimensions, theme, font, and typing speed — source it from every tape for a consistent look.Run
bun run capture <name>and review the output indocs/assets/.Embed the GIF from the relevant doc with
.
Why this approach
- Deterministic. Fake replies + pinned VHS settings mean byte-stable GIFs (modulo VHS upgrades).
git diff docs/assets/is meaningful. - Hermetic. No API key needed, so CI can regenerate captures on merge.
- Decoupled. The TUI itself is unchanged — the fake swap lives at the worker LLM boundary (
src/worker/llm-client.ts), so the same stub can be reused for deterministic agent-loop tests.
Known VHS/ttyd limitations
A few real sharp edges surfaced while building this; they're all worth knowing before you write a new tape.
Always use backticks for
Typecontent, not double-quotes. VHS's tape parser drops characters from double-quoted strings when they're piped through ttyd into an Ink raw-mode TUI — you'll see only some of what you typed, or nothing at all. The correct form is:tapeType `whats on my schedule today`Double-quoted
Type "..."is fine at the shell level (before the TUI launches), but use backticks for anything typed into the TUI input bar.Sleep Nis seconds.Sleep 500is 8 minutes and 20 seconds. Always suffix:Sleep 500ms,Sleep 2s.Non-text keystrokes (
Tab,Escape) don't reliably reach Ink. VHS'sTab/Escapecommands send escape sequences that Ink's legacy parser under ttyd doesn't recognize.Enterworks (it's just\r). If you need to drive tab navigation in a capture, use-p "<prompt>"to auto-submit an initial message, or add a CLI flag that lets the capture land on a specific tab.Under
BOTHOLOMEW_FAKE_LLM=1the chat command forces Ink's kitty-keyboard mode to"disabled"(seesrc/commands/chat.ts), because ttyd can't negotiate the Kitty Keyboard protocol. Without that, even plain-text typing is dropped. Don't remove that guard without re-runningbun run capture.Hide…Showhides keystrokes from the recording. If you want viewers to see the command being typed out, just don't useHide— start the tape with the shell prompt visible and let the typing animation play.
Capture-only hooks
The TUI has one env-var-gated affordance that exists purely for captures, because VHS can't keystroke its way through the tab bar:
BOTHOLOMEW_CAPTURE_TAB_CYCLE=<dwell-ms>(default2500) — when set,src/tui/App.tsxschedules timers that walkactiveTabthrough 2 → 3 → 4 → 5 → 6 → 7 → 1 with the given dwell between tabs. The hook is a no-op unless the env var is defined, so it doesn't affect normal use.docs/tapes/full-tour.tapeenables this via its fixture'senvblock.
Seeded capture data (one task, one high-priority task, one schedule, one context file) is added to every capture's ephemeral project directory by scripts/capture.ts, so Tasks / Schedules / Context panels have visible rows from the first frame.
Keybinding reference (for the real TUI — not for tapes)
Tabcycles tabs;Shift+Tabis not wired up.1–7jump to a tab only when not on the Chat tab (on Chat those keys are input).Escapereturns to Chat from any other tab./opens the slash-command popup; type to filter;Escapedismisses.Ctrl+Cexits the TUI.