Documentation
Everything deckhand does, in detail. For a gentler intro, start with getting started.
Deck formats
Decks come in two flavors that share all their powers: a single markdown file with separators, or a JSON manifest that lays out one markdown file per slide.
Single-file decks
Two separators give the deck its two dimensions:
# first topic ← column 1, top
---
# second topic ← column 2, top
--
## the weeds ← column 2, one level deeper
---on its own line starts a new column (earlier / later)--on its own line starts a deeper slide in the same column- both are ignored inside fenced code blocks
A deck can open with frontmatter carrying a title and theme:
---
title: my talk
theme:
border_type: rounded
accent: magenta
---
The leading block is only treated as frontmatter when it parses as a
YAML mapping — a bare --- at the top still just separates columns.
Presenter notes
Everything after a bare ??? line is that slide's presenter notes:
invisible when presenting, streamed to deckhand notes.
Embedded terminals
A fenced code block whose info string is terminal becomes a live,
interactive PTY on the slide:
```terminal rows=10
python3 -q
```
- the body runs via
$SHELL -c; leave it empty for an interactive shell — the working directory is the deck's directory rows=Nsets the viewport height (default 12, max 40);rows=fillexpands to all remaining slide height- nothing executes without consent: each terminal shows the command
it would run until you approve it in-deck — t (or click),
then y; s stops it again, R restarts
it.
--eagerstarts every terminal automatically when its slide first appears - once running, terminals keep running while you navigate elsewhere, and die when deckhand exits
- each terminal keeps 10,000 lines of history with a scrollbar on its right border once there's something to scroll
- multiple terminal blocks on one slide stack vertically; fill-height ones split the leftover space evenly
Syntax highlighting
Fenced code blocks with a language tag get lightweight highlighting —
keywords, strings, comments, numbers/constants, called functions, and
Capitalized types — for rust, python, javascript/typescript, go,
c/c++, sh, json, yaml, toml, and sql. Unknown languages render plain.
Colors come from the theme (code_keyword, code_string,
code_comment, code_literal, code_function, code_type),
defaulting to a One Dark-flavored truecolor palette — RGB, so
terminal color schemes can't remap it. It's a small built-in
tokenizer, not a grammar engine, so the wasm presenter stays light
and the browser gets the same colors.
QR codes
A fenced block whose info string is qr renders its body — a URL, some
text — as a scannable QR code:
```qr
https://deckhand.sh
```
It draws black-on-white whatever the terminal palette, so phone cameras actually read it. Works right here in the browser presenter, too — try it in the playground.
Images as ASCII art
A markdown image alone on its own line renders as colored ASCII art, scaled to the slide and resolved against the deck's directory:

PNG, JPEG, GIF, and WebP. Images referenced mid-sentence stay inline text, and the browser presenter shows a placeholder — pixels need a filesystem.
Side-by-side layout
A fenced block whose info string is row lays its cells out
side-by-side, splitting the slide width equally; || on its own line
separates cells. A cell stacks anything a slide can hold — markdown,
code blocks, terminals, QR codes, images — except another row. Make
the outer fence longer than any fence inside the cells:
````row
```qr
https://deckhand.sh
```
||
```terminal rows=10
python3 -q
```
````
Cells are top-aligned and the row is as tall as its tallest cell.
rows=fill terminals inside a row fall back to their fixed height
(default 12) — "the rest of the slide" isn't a height a side-by-side
cell can claim.
Per-slide themes
A theme fence anywhere on a slide overrides the theme for that slide
only (margins, width, borders, colors — the status bar keeps the deck
theme):
```theme
margin: 0
accent: red
```
JSON manifests
For bigger talks, point deckhand at a manifest: deckhand deck.json.
{
"title": "my talk",
"theme": { "accent": "magenta" },
"columns": [
"slides/intro.md",
["slides/topic.md", "slides/topic-deep.md"],
{ "terminal": { "command": "htop" }, "title": "live demo" },
{ "file": "slides/fin.md", "notes_file": "slides/fin-notes.md" }
]
}
columnsis the presentation left-to-right; each entry is a string (single-slide column), an array (a column with depth, top first), or an object- object slides take exactly one of
file,terminal, orpanes, plus optionaltitle,notes(inline markdown),notes_file, andtheme - a
terminalslide is one big PTY, filling the slide unless you give itrows panesstacks several panes vertically — any mix of markdown files and terminals:
{ "panes": [
"slides/context.md",
{ "terminal": { "command": "python3 -q", "rows": 8 } },
{ "terminal": {} }
] }
- paths are relative to the manifest; referenced markdown files are one
slide each (no
---splitting) but keep???notes andterminal/themefences - a slide file's own
themefence merges under the manifest's per-slidetheme(manifest wins field by field)
Theming
Layout, borders, and colors merge field by field from four sources, each later one winning:
- built-in defaults (solarized-safe indexed grays)
~/.config/deckhand/theme.json(or under$XDG_CONFIG_HOME) — your personal defaults, applies to every deck and the notes view- the deck's theme — manifest
"theme"or markdown frontmatter - the slide's theme — manifest slide
"theme"or athemefence
All fields, all optional:
{
"max_width": 84,
"max_height": 30,
"margin": 2,
"margin_y": 1,
"align_x": "center",
"align_y": "top",
"pin_title": true,
"title_gap": 1,
"border_type": "rounded",
"accent": "magenta",
"muted": 244,
"term_border": "gray",
"snapshot_border": "yellow",
"status_bg": "#3a3a3a",
"status_fg": 250,
"h1_style": "banner",
"h1": "yellow",
"h2": "light-yellow",
"bullet": "magenta",
"quote": "green",
"link": "light-blue",
"inline_code": "yellow",
"code_bg": 235,
"code_fg": 252,
"code_keyword": "#c678dd",
"code_string": "#98c379",
"code_comment": "#7f8490",
"code_literal": "#d19a66",
"code_function": "#61afef",
"code_type": "#e5c07b",
"qr_dark": "#102040",
"qr_light": "#fdf6e3"
}
Colors take a name ("cyan", "light-blue"), hex ("#rrggbb"), or a
0–255 palette index. qr_dark/qr_light color QR blocks (modules /
background, quiet zone included), defaulting to true black on true
white — restyle at your own risk: scanners need a dark code on a light
background with strong contrast, so dark navy on cream scans fine but
an inverted or low-contrast pairing often won't. snapshot_border styles terminals showing a
baked-in snapshot, superseding term_border for those — set it in a
slide's theme fence to mark that slide's captures distinctly; unset,
snapshots use term_border like any terminal. border_type is
plain, rounded, double, or thick.
Layout
One content box, two axes, the same three questions per axis — and three rules: margins always win, caps bound everything drawn, alignment only places what's left.
| axis | air (min) | cap (max) | leftover space |
|---|---|---|---|
| x | margin_x (default 2) |
max_width (default 96) |
align_x: left / center / right |
| y | margin_y (default 0) |
max_height (default unlimited) |
align_y: top / center / bottom |
margin sets both axes at once; the per-axis keys win over it. Units
are terminal cells (columns / rows). max_height caps everything the
slide draws — fill terminals and a pinned title included.
pin_title anchors a slide-leading heading to the top of the content
area (i.e. at row margin_y), with title_gap blank rows (default 1)
below it; the body lays out in the remaining space, still following
align_y. That's how a title sits on the same row on every slide
while the body stays centered.
h1_style: "banner" draws level-1 headings as large block glyphs,
three rows tall (A–Z, 0–9, light punctuation — case-insensitive). A
heading the font can't render, or that won't fit the width, falls
back to a normal H1 — try it on the title slide in the
playground.
Tip: prefer palette indexes (16–255) or hex over ANSI names for grays. Schemes like solarized remap the 16 ANSI colors, turning ANSI "dark gray" nearly invisible — deckhand's own defaults use indexed grays for exactly this reason.
Keys
| key | action |
|---|---|
| ←/h →/l | previous / next column |
| ↓/j ↑/k | deeper / shallower |
| space, n | next slide (walks every column depth-first) |
| shift-space, backspace, p | previous slide |
| g, G | first / last column |
| o | overview — type a slide's jump code, or hjkl + enter, or click |
| 1–9 | select a terminal pane (marked with ▸) |
| t, enter | run (after y) / focus the selected terminal |
| Ctrl-q | release terminal focus |
| shift-pgup/pgdn | scroll terminal history (also shift-↑/↓) |
| s / R | stop / restart the selected terminal |
| ? | help |
| q | quit |
While a terminal is focused, every key except Ctrl-q and the shift-scroll keys goes to the PTY. The mouse works too: click a terminal to focus it, click outside to release, scroll-wheel through history, click a slide in the overview to jump.
On slides with several terminals, each pane shows its number and ▸ marks the one t will focus.
Live reload
Local decks live-reload while presenting: deckhand watches the deck
file — and for manifests, every referenced slide and notes file, plus
your user theme — and hot-swaps the deck when anything changes. Your
position is kept (clamped if the deck shrank), live terminals survive
when the deck's terminal blocks are unchanged, and a mid-edit parse
error keeps the current deck with the error shown on the status bar.
Disable with --no-watch; remote decks aren't watched.
Presenter notes over a socket
deckhand present listens on a unix socket (default
$TMPDIR/deckhand.sock) and broadcasts the current slide, its notes,
the next slide's title, and a talk timer. deckhand notes renders that
from any other terminal and reconnects automatically.
Running two decks at once? Give each a socket:
deckhand present talk.md --socket /tmp/talk.sock
deckhand notes --socket /tmp/talk.sock
Presenting from URLs and gists
Any deck argument can be a URL:
deckhand https://example.com/talks/deck.md
deckhand https://gist.github.com/you/abc123
- plain URLs fetch that file; remote manifests fetch their referenced paths relative to the manifest's URL
- gist page URLs resolve through the GitHub API; the deck file is
picked by priority:
deck.json,deck.md, the only file, first.md— so a gist can hold a whole multi-file deck - embedded terminals in remote decks run in your current directory
The play page does the same resolution in your browser.
Compiling to one file
deckhand compile flattens any deck — including manifests and remote
decks — into a single markdown file that presents identically:
deckhand compile deck.json -o talk.md
deckhand compile https://gist.github.com/you/abc123 -o talk.md
The deck title and deck theme are preserved as frontmatter, per-slide
themes as theme fences. Only per-slide title overrides have no
single-file syntax; bare ---/-- lines inside slide bodies are
rewritten to *** so they can't split slides on re-parse.
Snapshots: real terminal output on the web
If a deck has terminal blocks, compiling requires choosing
--snapshots or --no-snapshots — capturing executes the deck's
commands on your machine, so deckhand won't guess. With --snapshots,
each block runs in a real PTY, output settles
(--snapshot-wait-ms, default 1500), and the screen is captured into
the compiled file at --snapshot-cols width (default 80).
--snapshot-root picks the working directory captures run in — it
controls what shell prompts display — defaulting to the deck's
directory. The capture lives inside the terminal fence as a
%%snapshot COLSxROWS marker plus base64-encoded ANSI.
Contexts that can't spawn PTYs — this site's presenter, the
playground, play — replay the capture through
the same terminal emulator the native TUI uses, colors and all, marked
with a snapshot hint on the border. Presenting natively always runs
the command live and ignores the snapshot.
Architecture: presenters and providers
The presentation core (navigation, layout, theming, drawing) is
platform-independent; each context plugs in a terminal provider
that supplies the live innards of terminal blocks. The native TUI's
provider spawns real PTYs; the browser build on this site provides
placeholders. The TerminalProvider trait is public — a provider
could just as well run commands over ssh, in a container, or replay a
recording.
Limitations
- slides taller than the window are clipped, not scrolled
- images render as ASCII art, and only when presenting natively — the browser presenter shows a placeholder
- native presenting is unix-only (PTYs + unix sockets)
- the browser presenter can't run terminals — that's what the real one is for