Tired of re-recording my README demo GIF by hand, I turned the whole demo environment into code A developer created a scripted demo environment for the open-source tool pgincident using Charm's VHS tool, eliminating the need to manually re-record GIFs for README files. The approach solves problems of inconsistency and staleness by encoding terminal operations in a .tape file that can be replayed exactly. The developer argues that for stateful TUI demos like pgincident, which shows real-time Postgres incident data, scripted recordings are essential to reliably demonstrate complex scenarios. The demo GIF at the top of an OSS README. That little clip that shows you what a tool does in five seconds, before a single line of prose does. That one. I used to re-record mine by hand. Every single time. Fire up the screen recorder, tidy up the terminal, type the command, stop at a good moment, export to GIF. Three minutes if it goes well. It usually doesn't. I fat-finger one character in the command. I hit that final q a beat too early and the recording ends before the screen I wanted to show even appears. Again. This time I notice, after exporting, that the terminal font size is different from last time. Again. That's how "just one GIF" would casually swallow 30 minutes. The biggest problem with recording by hand is that the picture changes every time you redo it. Font, size, color theme, shell prompt, window width. All of it depends on the state of the machine you recorded on, at that moment. My prompt is set up to show the current git branch, and once, after a re-record, the README GIF alone had some unrelated repo name baked into it. The quieter problem is staleness. You tweak the UI a little. You add a line to the header. You change a keybinding. Every time, the GIF gets a bit more out of date. But re-recording is a pain, so it stays stale. The very top of the README goes on proudly showing a screen that no longer exists in the current version. The first thing a visitor sees is the thing that gets updated least. Anyone who's shipped a CLI tool as OSS has probably felt this one. pgincident https://github.com/shinagawa-web/pgincident , a tool I maintain, is a TUI for the first response to a Postgres incident. It's the full-screen-in-the-terminal kind. A TUI demo is hard in a way a single CLI command isn't. If there's no "state" on the screen, the demo means nothing. For an ls GIF, you just run it in a directory with a few files and it looks fine. But what pgincident wants to show you is a screen of "what's happening in this Postgres right now." A long query is running. A lock wait queue is forming. There's a session that opened a transaction and walked away. Only when all of that is on the screen at once does the viewer go "ah, so that's what this tool is." Connect to a clean, quiet Postgres where nothing is happening, and all you record is a screen of empty rows that says nothing. That's not a demo. But manufacturing a real long query, a real lock, and a real idle-in-transaction all at once, conveniently, just for the demo — doing that by hand every time isn't realistic. To create a lock artificially you open two sessions, deliberately make one wait, keep that state alive while you start recording in a third terminal, and... just the choreography is a headache. It's a pain to do once, and the thought of reproducing it every time I change the UI means the GIF stays stale even longer. .tape file — writing terminal operations down This is where vhs https://github.com/charmbracelet/vhs comes in. It's a Charm tool, and in one line: you write a script of terminal operations, and it runs them exactly as written and exports a GIF or MP4. The script goes in a text file with a .tape extension. Type types characters, Enter presses enter, Sleep waits. Set pins down the appearance. This isn't a full reference article, so it's faster to just look at the real thing. Here's pgincident's demo.tape . pgincident demo Run via: ./scripts/record-demo.sh Output docs/demo.gif Set FontSize 14 Set Width 1200 Set Height 700 Set Theme "Dracula" Set Shell "bash" Start pgincident with the dev config Type "./pgincident --config dev/pgincident-dev.toml" Sleep 500ms Enter Overview screen — let it render and refresh once Sleep 4s Navigate to Dashboard screen Type "o" Sleep 4s Move cursor down to a Long-running query row and open SQL detail Type "j" Sleep 300ms Enter Sleep 3s Close detail and quit Type "q" Sleep 500ms You can pretty much read what it does. Font size, window width, height, color theme — all pinned explicitly by the Set lines up top. None of my machine's settings leak in. Type "o" moves to the Dashboard screen, Type "j" moves the cursor down a row, Enter opens the SQL detail — the gaze of a README reader, turned straight into a script. Even this alone almost entirely kills the "slightly different picture every time" problem. Same script, same picture. No re-shoots for typos. If I mistype something, I fix one character in the .tape and run it again. .tape alone is only half of it So far the operations have become code. But remember the TUI difficulty from earlier. The "state" that shows up on screen still isn't set up anywhere. A script that launches ./pgincident is useless if the Postgres it connects to is empty — all you record is an empty screen. The script only knows how to operate . It doesn't know what should be on the screen . So I wrapped the .tape in one more script from the outside. That's scripts/record-demo.sh . js /usr/bin/env bash set -euo pipefail cd "$ dirname "$0" /.." echo "== Building pgincident..." go build -o pgincident ./cmd/pgincident echo "== Starting Postgres..." docker compose up -d postgres echo "== Waiting for Postgres to be ready..." until docker compose exec -T postgres pg isready -U postgres -q; do sleep 1 done echo "== Starting load generator..." docker compose exec -T postgres psql -U postgres < dev/loadgen setup.sql go run ./cmd/pgincident-loadgen & LOADGEN PID=$ trap 'kill $LOADGEN PID 2 /dev/null || true' EXIT echo "== Waiting 5s for load to build up..." sleep 5 echo "== Recording demo..." vhs demo.tape echo "== Done - docs/demo.gif" Reading top to bottom, here's what it does. First it go build s pgincident. It starts Postgres with Docker. It waits until pg isready says the connection is up. It pipes in the load-generation SQL and runs the load generator in the background. It waits 5 seconds for the load to build up. Only then does it call vhs demo.tape. When the recording finishes, the trap kills the load generator and cleans up.So: the .tape records the operations , and this script sets up the state that shows on screen . Build, Postgres startup, load generation, recording, cleanup — all folded into a single script, so the whole demo environment is reproducible . This is the part I most wanted to say. Unless you widen "record the demo" into "assemble the demo environment and record it," you can't fully turn a stateful TUI demo into code. You type ./scripts/record-demo.sh . After that, with a real long query, a real lock, and a real idle-in-transaction lined up on screen, one GIF gets generated. The manual choreography that used to give me a headache became a single command. Inside record-demo.sh , the line that looks the most unremarkable and does the most work is go run ./cmd/pgincident-loadgen . That's the part that artificially stokes the "real incident" you see on screen. The prep is dev/loadgen setup.sql . It's an unremarkable script that just creates two small tables for the load to hit. CREATE TABLE IF NOT EXISTS loadgen accounts id bigint PRIMARY KEY, balance numeric 12,2 NOT NULL DEFAULT 0, touched at timestamptz NOT NULL DEFAULT now ; -- 10k rows gives cache hit ratio room to move INSERT INTO loadgen accounts id, balance SELECT i, random 10000 ::numeric 12,2 FROM generate series 1, 10000 AS g i ON CONFLICT DO NOTHING; -- pgincident dev only has pg monitor privileges. Grant DML so the simulator can run GRANT SELECT, UPDATE ON loadgen accounts TO pgincident dev; Here's where it gets interesting. The load generator itself Go deliberately produces, one by one, the three categories that pgincident's Dashboard shows. pg sleep baked in. It runs short ones that finish in a few seconds and long ones that squat for 5–8 minutes, with staggered start times. WITH paused AS SELECT pg sleep $1 SELECT a.id, a.balance, b.id, b.balance FROM paused, loadgen accounts a JOIN loadgen accounts b ON b.id = a.id WHERE a.id = $2 AND b.id = $3 FOR UPDATE and holds it, while another goes for the same row and gets stuck waiting. BEGIN , one query, and then just sitting there without committing or rolling back. pgincident's threshold is 30 seconds, so it sleeps long enough to cross it.And here's the biggest trick for the sake of the recording: each worker is written to start on a staggered schedule . The idle ones so that "at least one is always visibly past the 30-second threshold," the long queries so that "two cycles half-overlap." The point is that the load itself is designed so that whenever vhs demo.tape runs, the Dashboard's three categories are all filled in at once, just right . "Turning the whole demo environment into code," taken to its conclusion, includes this. Writing operations in a .tape will never produce this "broken just right" state. The README just embeds this GIF as the top image. pgincident demo docs/demo.gif Honestly, I didn't invent anything dramatic. Neither vhs nor a shell script is a novel technology. Even so, the day-to-day feel clearly changed. Anyone who runs it gets the same look and the same composition. Font, size, theme, the way load is applied — it's all in the script. It won't match down to which query lands in the top row on any given run, but the look and composition of the picture don't depend on my machine's mood. When a future contributor wants to update the GIF, they can read the .tape and know what's on screen. It reviews in a diff and lives in git. A GIF is binary, so you can't read the diff of its contents, but .tape and record-demo.sh are just text. "Bumped a Sleep from 3s to 4s," "reordered the operations" — that shows up in the PR diff. Demo changes ride on the same rails as code changes. Change the UI, regenerate. Added a line to the header. Changed a keybinding. Before, it was "re-recording is a pain, so leave it." Now it's just typing ./scripts/record-demo.sh again. I won't claim staleness is gone — forget to run the regen and it stays stale. But the weight of the "it's a pain" excuse dropped from 30 minutes of manual work to a single command. Let me be honest about one thing too. Right now record-demo.sh is a local script I run by hand . I'm not auto-regenerating the GIF in CI. When I change the UI, I still have to notice "ah, gotta regenerate the GIF" myself and type it myself. Ideally there's room to get to where any UI-touching change makes CI re-bake the GIF and commit it. That's the next move. What I've got so far is that regeneration is one command. A few spots that actually took some doing. Tuning Sleep takes the most work. A TUI refreshes the screen periodically. Right after launch, the data isn't drawn yet. If Sleep is too short, you record an empty or half-drawn screen. The reason demo.tape waits a generous Sleep 4s after launch is to let the Overview screen refresh once and fill in. Too long, though, and the GIF drags. I went back and forth a few times here — "shorten it, record, didn't make it, lengthen it a bit." It's an adjustment a single CLI command never needs; it's a TUI-specific chore. How well the load is crafted decides how convincing the demo is. If the load the generator throws is too weak, not a single long query crosses the threshold and the screen stays sparse. Too strong and the screen fills up so much you can't tell what to look at. Creating the "broken just right" state is unglamorous but the single biggest thing for demo-ness. Don't mix in secrets. The script is text and goes into git. And of course the GIF bakes in exactly the characters you typed and the screen you showed. Be careful not to write a production DSN or password into a Type , or to record a screen connected to production. That pgincident's demo uses a dev config dev/pgincident-dev.toml and connects to a local Docker Postgres is partly for this reason. GIF size. Crank up Set Width / Set Height and the file naturally gets heavier. This image sits at the top of the README, so slow loading defeats the purpose. Width 1200 × height 700 was the sweet spot where the text isn't crushed and the file isn't too heavy. Looking back, what changed is the kind of work. From the manual labor of "recording" a demo — moving your hands, failing, re-shooting — to "writing and generating" one: turning the script and the environment into code, and baking it with a command. And for a stateful TUI demo, writing the operations in a .tape isn't enough. Only when you go all the way to setting up the "state" on screen — a real long query, a real lock — and turn the whole demo environment into code does it become reproducible. That, I think, is the key thing when you turn a TUI demo into code. If you're re-recording your own OSS README demo GIF by hand every time, start with a single .tape . And if the screen you want to show needs "state," try folding the setup of that state into the script too. By the way, what pgincident actually is as a tool, I wrote up in another article https://dev.to/ 402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb . It's the story of that first 30 seconds when Postgres slows down at 2 a.m.