Terminal apps
Set target.mode: 'tty' to capture a terminal app: a full-screen TUI, or a CLI that prints a
screen and exits. The kit starts the app in a pseudo terminal of a fixed size, keeps its screen in
a headless terminal emulator (xterm.js), and renders that screen (not the
byte stream) with a bundled JetBrains Mono into a PNG, in
its own Chromium.
That PNG is the terminal area, so everything after capture works exactly as for a web app:
frames, the README table,
the portfolio and the hero banner.
The window frame style reads as a terminal window. A tty config can also record animated
clips.
Install
Section titled “Install”Next to the kit and Playwright, install a pseudo terminal package:
pnpm add -D @noctcore/showcase-kit playwright @lydell/node-ptynpx playwright install chromiumnpm i -D @noctcore/showcase-kit playwright @lydell/node-ptynpx playwright install chromiumbun add -d @noctcore/showcase-kit playwright @lydell/node-ptybunx playwright install chromium@lydell/node-pty is an optional peer dependency: prebuilt for Windows, macOS and Linux (x64 and
arm64), with no install scripts. The official node-pty works too when it is installed instead;
the kit tries @lydell/node-pty first, then node-pty. Neither is loaded until a tty config runs,
so web-only projects never need one. Without either, a tty run stops with the install command.
Run the CLI with Node
Section titled “Run the CLI with Node”Run the CLI with Node: npx showcase, pnpm exec showcase, or bunx showcase (which runs the
showcase bin with Node). Under the Bun runtime itself (bun --bun or bunx --bun), the pseudo
terminal package kills the app at once, so tty mode refuses to start there and says
run with node. The check runs before any browser or app starts.
Your app itself can be a Bun app: the kit only needs Node for its own process.
command: ['bun', 'run', 'src/index.tsx'] is fine.
A complete config
Section titled “A complete config”import { defineConfig } from '@noctcore/showcase-kit';
export default defineConfig({ name: 'Rumi', target: { mode: 'tty', command: ['bun', 'run', 'src/index.tsx'], // Fixture data and a frozen clock, so every run shows the same screen. env: { RUMI_MOCK: '1', RUMI_SHOWCASE: '1' }, cols: 120, rows: 32, }, ready: /resources \(\d+\)/, deviceScaleFactor: 2, terminal: { theme: 'dark', font: { size: 15 } }, shots: [ { id: 'resources', title: 'Resources', caption: 'Every resource on one screen.' }, { id: 'servers', title: 'Servers', keys: '{Tab}', waitFor: /servers \(\d+\)/ }, { id: 'logs', title: 'Logs', keys: ['{Tab}', 'l'], waitFor: 'logs', delayMs: 200 }, { id: 'deploy', title: 'Deploy', nav: async tty => { await tty.press('L'); await tty.waitForText('Deploying'); }, }, ], frame: { style: 'window', theme: 'dark', title: '{name}' },});ready is text on screen, a substring or a RegExp, that shows once the app has drawn its first
finished screen. The shots run top to bottom in one app process, so servers starts from
resources and logs starts from servers.
Start from showcase init --tty
Section titled “Start from showcase init --tty”npx showcase init --ttyThis writes a starter showcase.config.mjs (--ts for showcase.config.ts) filled in from your
package.json:
nameis the package name in title case, without its scope.commandis['node', '<bin>']when the package has abin(the first one when there are several); otherwise yourstartscript, then yourdevscript, run with the package manager your lockfile shows (pnpm start,bun run start,yarn startornpm run start); otherwise'node index.js'.cols: 120,rows: 32,deviceScaleFactor: 2, onehomeshot, a darkwindowframe and the default outputs, with commented examples forenv,readyand akeysshot.
init refuses to overwrite an existing config unless you pass --force, and even then it will not
write a config next to one with another name (delete the old one first). See the
CLI reference.
target in tty mode
Section titled “target in tty mode”Every key is listed with its default and limits in the config reference. What each one is for:
command (required) starts the app. A string runs through the shell (/bin/sh -c on macOS and
Linux, cmd.exe /d /s /c on Windows), like start in the other modes. An array
['node', 'dist/cli.js'] is spawned directly, with no shell, so its arguments arrive exactly as
written (on Windows, see the shim note below).
cwd is the working directory, relative to the config root (the config’s folder unless you set
root). Default: the config root.
env adds environment variables: an object, or a function of the language for apps that take
their language from an env var:
env: ({ lang }) => ({ MYAPP_MOCK: '1', MYAPP_LANG: lang }),inheritEnv decides which of your own environment variables the app gets. See
The app’s environment.
cols and rows fix the terminal size. Default 120 by 32. They are never read from your own
terminal, so a capture looks the same whatever window you run it in.
quitKey is sent when the kit is done with the app, before it kills the process tree. Default
'q'. It is parsed like keys, so '{C-c}' works. false skips it and just kills.
inputDelayMs is a grace period after ready, before setup and the first key. Default 300.
Keys sent before an app has switched its terminal to raw mode are lost, and many apps switch only
after their first draw.
readyTimeoutMs bounds the wait for ready. Default 30000.
In tty mode viewport, colorScheme and css are config errors: the image size comes from cols,
rows, the font size and the padding, and the look from the terminal block.
So are timeouts.readyMs (use target.readyTimeoutMs) and timeouts.networkIdleMs. The web
target keys (url, start, reuseExisting, cdpUrl, pageMatch) are errors too, and the tty
keys are errors in url and cdp mode.
The app’s environment
Section titled “The app’s environment”The app always gets a pinned terminal environment:
| Variable | Value |
|---|---|
TERM |
xterm-256color |
COLORTERM |
truecolor |
FORCE_COLOR |
3 |
TZ |
UTC |
LANG, LC_ALL |
en_US.UTF-8 (set on every OS, read only on macOS and Linux) |
It never inherits NO_COLOR, CI, TERM_PROGRAM, TERM_PROGRAM_VERSION, WT_SESSION, COLUMNS
or LINES, which would make it act as if it ran in CI or in another terminal. env is applied last,
so it can override any of the above, including those.
By default (inheritEnv: true) the app also inherits the rest of your environment, tokens and home
paths included, and whatever it prints ends up in a committed image.
With inheritEnv: false (or []), the app still gets what a program needs to start:
- on macOS and Linux,
PATH; - on Windows,
PATH,PATHEXT(to find.cmdshims),SystemRoot(Node aborts at startup without it) andComSpec(to run command strings and shims).
They are not secrets, but they may reveal paths such as your user folder. Nothing else is needed for
Node or Bun apps: without HOME or USERPROFILE they still find the home folder, and without
TEMP, TMP or TMPDIR they use the system temp folder (/tmp, C:\Windows\Temp). A command
string that uses ~, or a tool that reads $HOME for its config (git, XDG apps), needs
inheritEnv: ['HOME'].
Names in the list match case-insensitively on Windows only. The CI and terminal hints above stay out
even when listed: set them in env if the app needs one.
Shots in tty mode
Section titled “Shots in tty mode”A tty shot has the same id, title, caption, alt and delayMs as a web shot. To get somewhere
it has keys, or a nav function that gets the terminal session instead of a page; its waitFor is
text on screen instead of a selector; and restart starts the app afresh. The table with defaults is
in the config reference.
How a run goes
Section titled “How a run goes”One app process runs per language, and the shots run in order in it, so each shot starts where the last one left off. For each language the kit:
- starts the app and waits for
ready(withoutready, for any text at all), up totarget.readyTimeoutMs; - waits
inputDelayMs, then runssetuponce, with{ tty, lang, mode: 'tty', config }; - per shot: presses the
keys(or runsnav), waits forwaitFor, waitsdelayMs, waits for the screen to stop changing, and renders the screen to the raw PNG; - closes the app: sends
quitKey, waits up to 1.5 s for it to quit, then kills its whole process tree by PID.
Without waitFor, the kit gives the app up to one second to redraw after the keys (it moves on as
soon as the screen changes); set waitFor for anything slower. timeouts.shotMs (default 15000)
bounds each waitFor.
Before it renders, the kit waits until the screen has not changed for 100 ms, so a shot never shows
a frame the app is still drawing (a terminal can hand one write over in pieces). That adds about
100 ms to a shot. For an app whose screen never stops changing, the wait gives up after 1 s (sooner
if the shot’s timeouts.shotMs runs out), takes the screen as it is and warns: freeze the app for
captures, see Terminal determinism.
Without ready, the kit only waits for the app to draw anything, then the inputDelayMs grace,
which may catch an app halfway through its first screen. Set ready to text the finished screen
shows.
A failed shot fails alone: the next shot runs in the same app, the other files are still written, and the run fails at the end with every failure listed. Each error shows the screen the app was on.
restart: true closes the app and starts a fresh one (running setup again) before that shot, for a
screen you can only reach from a clean start.
A string, or an array of strings that are sent one after another. Plain text is typed a character at a time. Names in braces are keys:
| Keys | Names |
|---|---|
| Editing | {Enter}, {Tab}, {S-Tab}, {Esc}, {Space}, {Backspace}, {Insert}, {Delete} |
| Movement | {Up}, {Down}, {Left}, {Right}, {Home}, {End}, {PageUp}, {PageDown} |
| Function | {F1} to {F12} |
| Ctrl | {C-x} for Ctrl plus a letter or one of @ [ \ ] ^ _ ? Space: {C-c}, {C-l} |
| Alt | {A-x} for Alt plus any character: {A-x}, {A-1} |
A literal { |
{{ |
keys: 'jjj' // three presses of jkeys: '{Tab}'keys: ['{Down}', '{Down}', '{Enter}']keys: '/deploy{Enter}' // types /deploy, then Enterkeys: '{C-c}'Key names are not case-sensitive ({enter} works), but the C- and A- prefixes are upper case. An
unknown name, or a { that is never closed, is an error that lists the known keys. The arrow keys,
{Home} and {End} follow the app: once it turns on application cursor keys (most ncurses-style TUIs
do), they send the sequences it expects. After a lone {Esc} the kit waits 50 ms before the next key,
so the app does not read the pair as Alt plus that key.
For anything keys cannot say, give the shot your own steps instead: nav: async tty => { ... }.
It receives the terminal session:
| Method | Does |
|---|---|
press(keys) |
Presses keys, with the same syntax as keys. |
type(text, { delayMs? }) |
Types text as is (no {Key} names), at once or one character every delayMs. |
waitForText(pattern, { timeoutMs? }) |
Waits until a substring or RegExp is on screen. Default timeout 10000 ms. |
screenText() |
The visible screen as text, one line per row. |
sleep(ms) |
Pauses. |
resize(cols, rows) |
Resizes the terminal. |
Use keys or nav, not both: a shot with both is a config error. A waitFor on the shot still runs
after nav, bounded by timeouts.shotMs, while waitForText inside nav uses its own timeoutMs.
{ id: 'search', title: 'Search', nav: async tty => { await tty.press('/'); await tty.type('billing', { delayMs: 40 }); await tty.waitForText(/\d+ results/); },}A setup function gets the same session as tty, once per app process, before the first shot.
Ctrl+C
Section titled “Ctrl+C”On Ctrl+C the kit closes every open app with its quitKey and kills its process tree, waiting at most
five seconds; a second Ctrl+C kills at once. The process tree is always killed, but the quit key is
best effort: the browser’s own Ctrl+C handling can end the run before it is sent.
The terminal block
Section titled “The terminal block”terminal sets how the screen is drawn. Every key is optional; the list with limits is in the
config reference.
terminal: { theme: 'light', font: { size: 14, fallbackFile: 'fonts/SymbolsNerdFontMono-Regular.ttf' }, lineHeight: 1.4, padding: 16, cursor: 'show',},theme is 'dark' (the default), 'light', or your own
{ background, foreground, cursor?, ansi }, every color in #rrggbb, with exactly 16 ansi colors
(the 8 normal ones, then the 8 bright ones). The built-in themes:
theme: 'dark', Tokyo Night: background #1a1b26, foreground #c0caf5#15161eblack (0)#f7768ered (1)#9ece6agreen (2)#e0af68yellow (3)#7aa2f7blue (4)#bb9af7magenta (5)#7dcfffcyan (6)#a9b1d6white (7)#414868bright black (8)#ff899dbright red (9)#9fe044bright green (10)#faba4abright yellow (11)#8db0ffbright blue (12)#c7a9ffbright magenta (13)#a4daffbright cyan (14)#c0caf5bright white (15)
theme: 'light', Tokyo Night Day: background #e1e2e7, foreground #3760bf#b4b5b9black (0)#f52a65red (1)#587539green (2)#8c6c3eyellow (3)#2e7de9blue (4)#9854f1magenta (5)#007197cyan (6)#6172b0white (7)#a1a6c5bright black (8)#ff4774bright red (9)#5c8524bright green (10)#a27629bright yellow (11)#358affbright blue (12)#a463ffbright magenta (13)#007ea8bright cyan (14)#3760bfbright white (15)
Colors the app sets by number above 15 (the 256-color cube and gray ramp) and 24-bit colors are drawn as the app set them; only the first 16 come from the theme. Bold text in one of the 8 normal colors is drawn in its bright variant, as most terminals do.
font.file replaces the bundled JetBrains Mono with a .woff2, .woff, .ttf or .otf file,
relative to the config. boldFile, italicFile and boldItalicFile set the other faces. A custom
file never mixes with the bundled faces and the browser does not fake bold or italic, so set those
three as well if the app uses bold or italic text.
font.fallbackFile is a second font for glyphs the main one lacks, for example a symbols or Nerd
Font for icons.
font.size is in CSS pixels. Default 15.
lineHeight is a multiple of the font size, rounded to whole pixels. Default 1.32.
padding is the space in CSS pixels between the grid and the edge of the capture. Default 12.
cursor is 'hide' (the default) or 'show', which draws a block cursor where the app left it
visible, never blinking.
The image size
Section titled “The image size”The raw capture is cols cells wide and rows cells high, plus padding on each side, times
deviceScaleFactor. With the defaults a cell is 9 by 20 CSS pixels (the bundled font’s advance is
0.6 of the size, and 15 times 1.32 rounds to 20), so 120 by 32 at DPR 2 is 2208 by 1328 pixels.
Cells are whole pixels wide, and ligatures and kerning are off, so columns line up exactly.
Rendering never touches the network: the fonts are inlined into the page. The bundled font is
JetBrains Mono 2.304 under the SIL Open Font License 1.1; the license ships with the package in
dist/fonts/OFL.txt.
Platform notes
Section titled “Platform notes”Windows
Section titled “Windows”The pseudo terminal is ConPTY. LANG has no effect there, so pin the locale inside the app.
An array command whose program is a .cmd or .bat shim (pnpm, npm, and most tools installed
through a Node package manager) cannot be spawned directly, so the kit runs it through cmd.exe,
which cannot pass an argument that contains ", % or a line break. The kit refuses those with an
error rather than pass something else. Run the program the shim starts directly (for example
['node', 'node_modules/my-cli/dist/cli.js', ...]), or use a command string and quote it yourself.
@lydell/node-pty ships its spawn helper already executable. With the official node-pty 1.1.0 a
spawn can fail with posix_spawnp failed
(node-pty issue #919) until its spawn-helper
binary is made executable, so prefer @lydell/node-pty. Fonts rasterize differently from Windows
and Linux; the cell grid stays the same.
@lydell/node-pty has prebuilds for x64 and arm64 on glibc. Alpine (musl) is untested.
Run the CLI with Node (above). The app you capture can still run on Bun.
- Terminal determinism: what the kit pins for you, and what your app should offer so two runs give the same pixels.
- Clips: animated recordings of the same app.