β¨οΈβ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
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.
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.
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.
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.
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.