Terminal
How do I put a terminal demo in a deck, a README or a video, and find out when the tool it demonstrates stops saying what the demo says?
@getnarro/terminal is a terminal session as text. Not a video of one, not a screen
recording: a file that names the commands, the pauses and the moments worth naming, plus — once
you have run it — the bytes the commands actually printed.
That one decision is what everything else follows from. Text diffs, so CI can tell you the demo went stale. Text has no resolution, so the same file renders a GIF, an SVG, a still, a slide and a shot in a video. Text carries palette indices rather than colours, so one recording is re-themable. And a screen is a pure function of the frame number, which is what lets a video renderer’s parallel workers each draw frame 500 without having drawn the 499 before it.
npx narro-terminal render demo.term.json # a GIF
npx narro-terminal render demo.term.json --svg # vector, no browser needed
npx narro-terminal still demo.term.json # one frame, at the poster mark
npx narro-terminal check demo.term.json # what will be wrong before you render it
npx narro-terminal record demo.term.json # run it for real, and write down what happened
npx narro-terminal record demo.term.json --check # …and fail when the output changed
npx narro-terminal tape demo.tape # perform it: tape, playbook and capture in one go
The one rule
Rendering never executes anything. A playbook names commands; drawing one draws them being
typed. Only narro-terminal record runs them, it prints them and asks first, and it requires
--yes when nothing is attached to the terminal. (narro-terminal tape opens a shell too — for
you to type into, running what you type and nothing else.)
This is a security boundary, not an implementation detail. A deck arrives by download, from a
template, from a marketplace, from a model. If narro build spawned a shell for a fence, opening
a document would be a way to execute its author’s commands, and the blast radius would be every
user of the CLI. So: in markdown, only authored mode exists. narro build, narro dev,
narro check and narro export draw text and never spawn a process.
The ladder
Four ways to write the same object. Every one goes through the same validator, and nothing
downstream can tell them apart. Or write none of them:
perform the demo in a shell and let narro-terminal tape
write the tape, the playbook and the capture for you.
A transcript you already wrote
The floor. A ```bash block with prompts in it becomes a terminal by changing one word:
```term
$ npm create narro@latest my-deck
✔ Scaffolded my-deck in 8.4s
$ cd my-deck && npm run dev
➜ Local: http://localhost:5173/
```
Lines beginning with the prompt were typed; everything else is what came back. ANSI colour survives, because a pasted transcript has it. It is drawn at build time as positioned HTML — no emulator in the browser, no measurement pass, nothing to load — which is what makes it work in single-file mode, in a PDF and in a PPTX.
A tape, when you need marks, waits and keys
The rung above, and it reads VHS .tape syntax, because thousands of demos are already written
in it:
```tape {window=mac poster=ready}
Set TypingSpeed 50ms
Type "npm create narro@latest my-deck"
Enter
Sleep 2s
Wait /Scaffolded/
Mark ready
```
A tape says what happens; a transcript says what was printed. Narro adds four directives VHS
has no equivalent for — Mark (a named moment), Mask (what varies between runs), Cue
(narration alignment) and Expect — and reports every directive that did not map instead of
dropping it. Source is refused inside a fence: a fence is a document, and a document does not
get to read ~/.ssh.
| Fence | Attributes |
|---|---|
term | theme, prompt, cols, rows, window, title, width |
tape | theme, cols, rows, window, title, width, poster |
A playbook file
demo.term.json beside the deck, shared by a slide, a README, a docs page and a video:
{
"cols": 92,
"rows": 24,
"steps": [
{ "id": "install", "type": "npm create narro@latest my-deck", "enter": true },
{ "id": "done", "output": "✔ Scaffolded my-deck in 8.4s\r\n$ ", "mask": ["\\d+\\.\\d+s"] },
{ "id": "ready", "expect": "Scaffolded" }
]
}
One action per step, from this set: type, key, paste, output, wait, expect, clear, resize, mark, hide, show.
A key step takes Enter, Tab, Backspace, Delete, Space, Escape, Up, Down, Left, Right, Home, End, PageUp, PageDown, Insert,
with Ctrl+, Alt+ and Shift+ in front of them.
TypeScript, for the programmatic case
import { defineTerminal } from "@getnarro/terminal";
export default defineTerminal({
steps: [{ id: "run", type: "npm run build", enter: true }],
});
expect may be written as a regular expression here and is stored as a string, because a
playbook has to survive a trip through JSON.
Writing a demo by performing it
Typing Type "…", Enter and Sleep 2s by hand is transcription: the commands are already in
your fingers. So perform the demo instead:
npx narro-terminal tape demo.tape # a shell opens; use it, then `exit`
npx narro-terminal render demo.term.json # the demo, as it happened
npx narro-terminal record demo.term.json --check # in CI: fail when the output changes
One session writes three files, and nothing runs twice:
demo.tape— what you typed, as the VHS tape you would have written. A burst of typing is oneType, a key pressed three times isUp 3, a pause of a second or more is aSleep(capped by--max-idle, default 3s), a paste isCopyandPaste, and your typing rhythm becomesSet TypingSpeed. A character you typed and rubbed out is folded into the text you meant unless you pass--keep-typos, and theexitthat ended the session is left out.demo.term.json— the playbook: each command as atypestep withenter: true, then what it printed as anoutputstep, so it renders at once. Keys a playbook cannot draw by themselves — Up recalling history, Tab completing, anything insidevim— are followed by a redraw of the rows they changed, so the render shows the screen you saw at every keystroke.demo.capture.json— the output, asrecordwrites it, so--checkworks from the first run.
The output comes from the session itself: getting it by running the commands again would repeat
whatever they did. The session is the same clean bash with a $ prompt that record uses, at
your terminal’s size unless --cols and --rows say otherwise. It runs what you type and
nothing else, which is why it needs no --yes; it needs a terminal, and says so without one.
Replies the terminal sends on the keyboard’s stream — cursor position, colours, mouse — are not
keystrokes, and are counted and dropped.
Recording, and the check that keeps a demo honest
record runs the playbook in a real terminal — a script(1) pty, so nothing native to install —
and writes demo.capture.json beside it: the bytes, with the second each arrived. A demo you
performed with tape already has one, from the session itself.
Then the point of the exercise:
npx narro-terminal record demo.term.json --check
It re-records, compares against the stored capture without writing, and exits 1 when a step’s output changed:
✔ «before» matches
✘ install output changed
stored ✔ Scaffolded my-deck in ‹masked›
now ✔ Created my-deck in ‹masked›
This is what nothing else in this field can do. A .cast carries what a terminal emitted and
not what the author meant; a VHS .tape carries the intent and not the reality. Neither can tell
you that the tool being demonstrated stopped saying what the demo claims it says. A playbook plus
its capture carries both — and because the capture is text, the answer is a diff a person reads.
Two things make it survivable in CI:
- Output is attributed to a command by the shell’s own echo of it, not by timestamp. Two recordings of one session are never timed alike, and time-based attribution reports jitter as a change on every run.
- Masks are session-wide. A duration, a version, a temp path and a port change every run and
mean nothing. A mask is written on the step whose
expectit relates to and applies to every step, because output arrives while the previous step is still running.
check reports a mask that matches nothing, because a mask that hides nothing means --check
will report the very difference it was written to absorb.
Output
| Command | What you get |
|---|---|
render | An animated GIF. Differenced frames, a counted palette, per-frame delays, and a --max-mb ladder that retries down to a size you asked for |
render --svg | An animated SVG: vector, usually smaller, and it needs no browser at all |
still | One PNG, at a mark, a second or a frame |
<narro-terminal> | The embed — 14.7 KB gzipped, no framework, playable and seekable |
<TerminalScene> | A terminal in a @getnarro/video composition, aligned to a narration cue |
```term / ```tape | A terminal on a slide, drawn at build time |
The SVG does not animate in a GitHub README. GitHub serves README images through its camo proxy as
<img>, and animation inside an<img>-loaded SVG is unreliable across browsers — reported broken in Firefox while working in Chrome. Use the GIF there, and the SVG on a docs site, a blog post or a slide. The CLI says so every time it writes one.
Themes: narro-dark, narro-light, asciinema, dracula, monokai, nord, solarized-dark, solarized-light, github-dark, github-light. A
cell stores a palette index, not a colour, which is what makes one recording re-themable — the
same session in a docs site’s light theme and a launch video’s dark one, from one file.
Coming from somewhere else
npx narro-terminal import session.cast --explain # asciicast v2 or v3
npx narro-terminal import demo.tape # a VHS tape
npx narro-terminal import old.ttyrec # a ttyrec
npx narro-terminal export demo.term.json --cast-version 3
The format is decided by what is in the file, not by its name. What an import cannot recover is
intent: a cast records what a terminal emitted, so it arrives as output and wait steps —
honestly a transcript — and everything downstream works on it unchanged. Export goes the other
way, including from an authored playbook, which produces a recording of a session that never
happened, typing and all, that asciinema play plays.
What check reports
unsupported-sequence, unsupported-sgr, expect-never-matched, output-truncated, line-too-wide, mask-never-matches, long-idle-gap, session-very-long, typing-faster-than-frames, glyph-not-in-face, font-not-embedded
--glyphs adds the two that need a browser. Every glyph in a monospace face advances by exactly
one cell, so a glyph whose advance is anything else came from a fallback face — and a fallback
glyph is what drifts a row off the grid. It measures in the same engine that will do the render,
with the same face embedded.
npx narro-terminal render demo.term.json --font ./JetBrainsMono.woff2
Without --font the render uses whatever monospace face the machine has, and two machines produce
different pixels. check says so rather than leaving it to be found by a frame diff.
Closed sets
Everything above is published as data in catalog.json, under
its terminal key: the commands, the step actions, the key names, the themes, the findings, the
tape directives and settings, the fences and their attributes, and every entry point. A name that
is not in that file does not exist.
Directives: Output, Require, Env, Set, Source, Type, Sleep, Hide, Show, Screenshot, Copy, Paste, Wait, Mark, Mask, Cue, Expect.
Settings: Set Shell, Set Theme, Set TypingSpeed, Set Width, Set Height, Set Cols, Set Rows, Set FontSize, Set FontFamily, Set LineHeight, Set LetterSpacing, Set Padding, Set Framerate, Set PlaybackSpeed, Set LoopOffset, Set WindowBar, Set WindowBarSize, Set BorderRadius, Set Margin, Set MarginFill, Set CursorBlink, Set WaitTimeout, Set WaitPattern.
Entry points: @getnarro/terminal, @getnarro/terminal/react, @getnarro/terminal/player, @getnarro/terminal/hast, @getnarro/terminal/renderer, @getnarro/terminal/record, @getnarro/terminal/convert, @getnarro/terminal/video, @getnarro/terminal/embed.