name: tui-demo-gif description: >- Record a terminal UI (TUI or CLI) as a polished demo GIF with vhs — it drives the real binary in a headless terminal from a scripted .tape — then verify the frames with ffmpeg. Use when the user wants to record or demo a TUI/CLI as a GIF or screen recording, make a terminal demo clip (the AgentRoom dashboard has a worked example), or publish such a clip to Linear or a README. Not for GUI apps or for capturing a live herdr pane.
tui-demo-gif
Make a clean, reproducible GIF of a terminal program by scripting it with vhs
(charmbracelet/vhs). vhs runs the actual binary in its own headless terminal and
renders the real output — same program, scripted keystrokes, no screen-record
permission, no physical-pane capture. This is almost always better than recording a
live pane.
When to use this
- "record/demo the TUI", "make a GIF of the CLI", "screen-record the dashboard", "capture a terminal demo", "show this running in a clip".
- Publishing a terminal demo to Linear / a doc / a README.
- Specifically: demoing the AgentRoom dashboard TUI (
agent-room tui).
Do NOT screen-record a live herdr sibling pane for this — see gotchas.
Prerequisites
vhs(brew install vhs— pullsttyd+ uses system Chrome) andffmpeg. Check withcommand -v vhs ffmpeg.
Core recipe
- Know the program's keys. Read its source or
--helpfor navigation/hotkeys so the tape is accurate. (AgentRoom TUI:Ctrl+G/Ctrl+Lcycle views,Esc= view picker,Ctrl+Cquit; per-view header hotkeysc o w a m e l s ?.) - Stand up predictable state — isolated and side-effect-free. A demo of an empty
app is boring and a half-set-up app looks broken. Seed realistic data into an
isolated instance: use the app's own isolation flag (e.g.
--home <tmpdir>/--profile, orAGENTROOM_HOME+ a throwaway daemon —scripts/seed-agentroom-demo.sh) so the demo touches no real state, connectors, or outbound services. When a live backend is credential-gated, costly, or nondeterministic (model APIs, chat gateways), don't trigger it — tour read-only surfaces instead (command palette,/status,/help, settings, onboarding). See the chat-agent variant for the live-model path. - Write a
.tape(start fromassets/demo.tape). Put env/setup in aHide…Showblock so it isn't in the GIF. Keystrokes:Type "…",Enter,Sleep 2s,Ctrl+G,Escape,Down,Ctrl+C. TuneSleeps so each screen is readable, andSleeplong enough after launch for the app to become interactive before the first keystroke (see gotchas). - Render —
vhs demo.tape, withdangerouslyDisableSandbox: trueon that Bash call: the sandbox blocks vhs's ttyd↔headless-Chrome handshake (could not open ttyd: ERR_CONNECTION_REFUSED) even though loopback curl works sandboxed. - Verify visually — you can't trust a tape blind. Extract frames and Read them:
scripts/render-and-verify.sh demo.tapebuilds a contact sheet + key frames, or manuallyffmpeg -ss <t> -i out.gif -frames:v 1 frame.png. Terminal text is too small to read at native size when you Read the PNG back — crop the region of interest and upscale with nearest-neighbor so it's legible:ffmpeg -ss <t> -i out.gif -frames:v 1 -vf "crop=W:H:X:Y,scale=iw*2:ih*2:flags=neighbor" frame.png. Fix Sleep timings andDown/nav counts, re-render, re-check.
Gotchas (learned the hard way)
- Transient
ERR_CONNECTION_REFUSEDeven with the sandbox off. The first render after a cold start can still fail withcould not open ttyd: ... ERR_CONNECTION_REFUSEDfrom a Chrome↔ttyd startup race — just re-run the samevhscall once and it succeeds. (Confirm ttyd + Chrome exist first:command -v ttyd,ls "/Applications/Google Chrome.app".) Screenshotis unreliable — paths must be quoted and it often writes nothing. Don't depend on it; extract frames from the rendered GIF with ffmpeg instead.- Quote
Output/Screenshotpaths. Unquoted/tmp/...paths with hyphens mis-parse ("Expected path after …"). - Never demo via a live herdr pane. Creating a new pane/tab in a live AgentRoom herdr session triggers auto-adoption (launches an agent + fires an enrollment system-message). vhs's own terminal avoids this entirely.
- Size/length: ~1300×760, FontSize 14, Framerate 15, ~30s → ~1 MB GIF. Drop framerate/dimensions or trim Sleeps if it's too big.
- Set
TypingSpeed(~28ms) so typed commands aren't sluggish; shorten long launch commands by puttingexport PATH=…/bin:$PATHin theHideblock. - Let the app finish starting before the first keystroke. Keys sent while a TUI is
still initializing get dropped, so the GIF shows nothing happening.
Sleepuntil it's fully interactive before the first keypress; heavier apps need more. Symptom in frames: an empty input box where your typed command should be. (A pre-warmed backend attaches in seconds; a cold first compile can take a minute or more — pre-warm it.)
AgentRoom demo (worked example)
The reusable pipeline that produced the first dashboard-TUI demo:
scripts/seed-agentroom-demo.sh— inits an isolatedAGENTROOM_HOME, starts a throwaway daemon on a free port (NOT the real:4317), and seeds agents (varied live states), a coordination message thread, reports, Linear tracker events, and MCP servers via the daemon HTTP API. Prints theAGENTROOM_DAEMONURL.- Point
assets/demo.tape's launch line at that daemon (agent-room tui --daemon http://127.0.0.1:<port> --no-auto-start) and render (sandbox disabled). - Verify frames, iterate.
- Stop the throwaway daemon:
agent-room daemon stop --port <port> --pid-file <home>/daemon.pid(scope to port/pid; never broad-pkill).
Chat-agent variant (live model)
To demo the Chat view with the dashboard agent actually responding (see assets/chat-demo.tape):
- Enable the agent: it reads creds from
$AGENTROOM_HOME/auth.json. The throwaway home has none, so symlink the user's login in:ln -sf ~/.agentroom/auth.json $AGENTROOM_HOME/auth.json(uses their/login, e.g.openai-codex; no secret copied). Header then showsagent: <id>@<provider>instead ofdisabled. Remove the symlink afterward. - Latency is real — ~20–30s/turn at
effort=medium. Type/effort lowfirst and use generousSleeps (20–26s/turn) so responses aren't cut off. Responses are nondeterministic, so verify each render's frames. - Prompt directive, side-effect-free: ask for read + in-room writes only —
list_messages/summarize,post_message,post_agent_report. Do NOT promptlaunch_runtime_agent,send_runtime_agent_input, orcall_mcp_tool(those have real external side effects, e.g. spawning panes or mutating Linear). - Payoff shot: after the agent posts,
Ctrl+Gto the Messages/Feed view so the GIF shows its action landing in the room. Confirm via the daemon API that message/feed counts went up.
Apps that auto-discover a running backend
Some TUIs find their backend through a repo-relative descriptor file rather than a
port, so an alternate port alone still attaches to the live instance. Isolate with a
throwaway repo copy (rsync -a excluding node_modules .git .env.local and state
dirs, then symlink node_modules), point the app's repo/home env vars at the copy,
give it a credential-free env (provider/model only), unset any HERDR_* vars in the
tape's Hide block, pre-warm the backend once outside the tape so the face attaches
in seconds, and tour only read-only screens (/help, /status, pickers backed out
with Escape). Kill only the pid you started. The clankie TUI
(~/dev/clankie/apps/tui) is the current instance of this shape; read its env names
from source before recording.
Publish to Linear
Linear's MCP has no project-update primitive — use a project document
(save_document project:) and/or the project description (save_project).
Embed the GIF:
prepare_attachment_upload(needs any issue to anchor; the returnedassetUrlis embeddable workspace-wide even withoutcreate_attachment_from_upload).curl -X PUT --data-binary @out.giftouploadRequest.urlwith the signed headers verbatim (content-type,x-goog-content-length-range: N,N, …; 60s expiry). Run this withdangerouslyDisableSandbox: true.- Put
in the doc/description markdown. Linear ingests it and rewrites the URL with?signature=…(confirms it rendered).
save_document rejects unicode emoji in icon (use a shortcode or omit).
Files
assets/demo.tape— starter vhs tape (dashboard tour; generalize as needed).assets/chat-demo.tape— chat-agent variant tape (live model driving room tools).scripts/seed-agentroom-demo.sh— stand up + seed an isolated AgentRoom room.scripts/render-and-verify.sh— render a tape, then build a contact sheet + frames.
