creative/tui-demo-gif/SKILL.md

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 — pulls ttyd + uses system Chrome) and ffmpeg. Check with command -v vhs ffmpeg.

Core recipe

  1. Know the program's keys. Read its source or --help for navigation/hotkeys so the tape is accurate. (AgentRoom TUI: Ctrl+G/Ctrl+L cycle views, Esc = view picker, Ctrl+C quit; per-view header hotkeys c o w a m e l s ?.)
  2. 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, or AGENTROOM_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.
  3. Write a .tape (start from assets/demo.tape). Put env/setup in a HideShow block so it isn't in the GIF. Keystrokes: Type "…", Enter, Sleep 2s, Ctrl+G, Escape, Down, Ctrl+C. Tune Sleeps so each screen is readable, and Sleep long enough after launch for the app to become interactive before the first keystroke (see gotchas).
  4. Rendervhs demo.tape, with dangerouslyDisableSandbox: true on 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.
  5. Verify visually — you can't trust a tape blind. Extract frames and Read them: scripts/render-and-verify.sh demo.tape builds a contact sheet + key frames, or manually ffmpeg -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 and Down/nav counts, re-render, re-check.

Gotchas (learned the hard way)

  • Transient ERR_CONNECTION_REFUSED even with the sandbox off. The first render after a cold start can still fail with could not open ttyd: ... ERR_CONNECTION_REFUSED from a Chrome↔ttyd startup race — just re-run the same vhs call once and it succeeds. (Confirm ttyd + Chrome exist first: command -v ttyd, ls "/Applications/Google Chrome.app".)
  • Screenshot is 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/Screenshot paths. 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 putting export PATH=…/bin:$PATH in the Hide block.
  • 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. Sleep until 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:

  1. scripts/seed-agentroom-demo.sh — inits an isolated AGENTROOM_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 the AGENTROOM_DAEMON URL.
  2. 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).
  3. Verify frames, iterate.
  4. 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 shows agent: <id>@<provider> instead of disabled. Remove the symlink afterward.
  • Latency is real — ~20–30s/turn at effort=medium. Type /effort low first and use generous Sleeps (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 prompt launch_runtime_agent, send_runtime_agent_input, or call_mcp_tool (those have real external side effects, e.g. spawning panes or mutating Linear).
  • Payoff shot: after the agent posts, Ctrl+G to 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:

  1. prepare_attachment_upload (needs any issue to anchor; the returned assetUrl is embeddable workspace-wide even without create_attachment_from_upload).
  2. curl -X PUT --data-binary @out.gif to uploadRequest.url with the signed headers verbatim (content-type, x-goog-content-length-range: N,N, …; 60s expiry). Run this with dangerouslyDisableSandbox: true.
  3. Put ![alt](https://raw.githubusercontent.com/Volpestyle/skills/main/creative/tui-demo-gif/assetUrl) 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.