# Headless Driving (`retra drive`)
retra can run the real workspace — panels, apps, focus, dialogs,
chords — with no terminal attached, while a Deft script drives it
Expect-style: send input, wait for screen content, read the screen
back. This is the built-in way to test and debug TUI interfaces (and
the apps behind them) from CI, scripts, and LLM agents — no pty, tmux,
or sleep-based tricks.
```sh
retra drive script.dft # default workspace at 80x24
retra drive script.dft -- workspace.dft # explicit workspace spec
retra --cols 120 --rows 40 drive script.dft
retra drive script.dft --cast out.cast # record the headless session
````
Exit code is `0`/`1` — script errors and `deftest` failures fail the
run, exactly like `deft test`.
## The `drive/*` stdlib functions
Available in the driver script's runtime (and in any runtime while a
drive session is active; they return `%Err` in interactive sessions).
| Function | Purpose |
|---|---|
| `[drive/send-key :enter ?%{ :ctrl true :shift … }]` | Inject a key event. Key names use the Key vocabulary (`:enter`, `:escape`, `:page_up`, `:a`…`:z`, digits, `:f1`…`:f12`). |
| `[drive/send-text "hello"]` | Inject text as if typed (text events, chunked). |
| `[drive/send-mouse %{ :type :down :x 8 :y 5 :button :left }]` | Inject a mouse event. `:type` is `:down`/`:up`/`:drag`/`:move`/`:scroll`; `:x`/`:y` are absolute screen cells (0-based); `:button` is `:left`/`:middle`/`:right`; `:scroll` takes `:dy`. |
| `[drive/resize 100 30]` | Resize the terminal and re-layout. |
| `[drive/expect "Pattern" ?timeout-ms ?%{ :regex true }]` | Wait until the screen contains the pattern — substring by default, PCRE2 regex with `:regex true`. Default timeout 5000 ms. Returns `:ok` or `%Err{:message …}` including the current screen. |
| `[drive/expect-not "Pattern" …]` | Wait until the pattern is gone; `%Err` if still present at timeout. |
| `[drive/screen]` | The composited screen as text: one line per row, trailing blanks trimmed. |
| `[drive/screen-cells]` | Non-blank cells as maps: `%{ :r :c :ch :fg "#rrggbb" :bg :bold :italic :underline :reverse :wide }`. |
| `[drive/frame]` | The screen as a complete ANSI escape frame (full repaint bytes). |
| `[drive/screenshot "path.txt"]` | Write the screen text to a file; returns the path. |
| `[drive/size]` / `[drive/version]` | `%{ :cols N :rows N }` / frame counter (bumps every rendered frame). |
| `[drive/quit]` | End the session (also happens when the script ends). |
## Writing a drive script
`drive/expect` is the synchronization point: injected events land on
the host's next tick (~16 ms), so wait on screen content, never
sleep. This makes scripts deterministic where pty-based harnesses are
flaky.
defsuite "launcher" { deftest "opens and launches" { [assert/eq [drive/expect "DRIVE-HELLO"] :ok] [drive/send-text "hello"] [assert/eq [drive/expect "typed: hello"] :ok] } }
Scripts can use the full language: imports, `deftest`/`assert/*`,
`fs/*`, `os/run` — the driver runtime is a normal Deft runtime with
test mode on.
## Headless service mode
```sh
retra --headless --keep --port 9400 -- workspace.dft
````
Runs the workspace with no terminal: apps run (simulation / service
mode), nothing is rendered, and `--port N` serves the
debug server on the workspace runtime — connect
from any client (eval, inspect, breakpoints) with
`stdext/debug-client`. Without `--keep`, the session ends when its
apps exit.
## Record → replay → golden
Deterministic UI regression tests: record a session's input, replay it
through the real input parser, and compare the final screen.
```sh
# 1. Record: a drive session logs its injected input as cast "i" events
# (encoded as the terminal bytes a real terminal would send).
retra drive script.dft --cast session.cast --record-input -- workspace.dft
# 2. Replay: re-feed the recorded bytes through the real parser at their
# recorded timestamps; compare the final screen against a golden.
retra --cols 80 --rows 24 --replay session.cast --golden golden/ -- workspace.dft
# 3. (Re)write the golden instead of comparing.
retra --cols 80 --rows 24 --replay session.cast --golden golden/ --update-golden -- workspace.dft
````
Golden files are `golden/<stem>.txt` (the final screen, reviewable) and
an optional `<stem>.frame.sha256` (hash of the full ANSI frame). A
mismatch prints a line diff and the `.actual.txt`, and exits 1.
Fidelity notes:
- Interactive sessions record their real stdin bytes the same way
(`--record-input --cast f.cast`), so user bug reports replay
headlessly.
- Resizes have no terminal byte form and are not recorded.
- Bare printable keys replay as `:text` events — what terminals
actually send.
- Replays run at recorded wall-clock pace; drive-script recordings are
naturally tight (~20 ms between events).
## Live RPC driving (`tui/*` on the debug server)
Any debug server (`[debug/serve]`, `retra --port N`) also answers
`tui/*` methods when the session is externally driven — the same
primitives the `drive/*` verbs use, over JSON-RPC:
| Method | Params | Result |
|---|---|---|
| `tui/screen` | — | `{"cols":N,"rows":N,"text":"…"}` |
| `tui/size` | — | `{"cols":N,"rows":N}` |
| `tui/version` | — | `{"version":N}` (frame counter) |
| `tui/send-key` | `{"key":"enter","ctrl":true,…}` | `{"ok":true}` |
| `tui/send-text` | `{"text":"hello"}` | `{"ok":true}` |
| `tui/send-mouse` | `{"type":"down","x":5,"y":3,"button":"left"}` | `{"ok":true}` |
| `tui/resize` | `{"cols":100,"rows":30}` | `{"ok":true}` |
| `tui/quit` | — | `{"ok":true}` |
There is deliberately no `tui/expect` over RPC — it would block the
loop servicing the socket; clients poll `tui/screen` instead. Combine
with `eval`/`inspect`/breakpoints for full behind-the-scenes debugging
of a live UI.
## Where it lives
- `ui/src/apps/tui/plugins/drive_plugin.zig` — the `drive/*` verbs +
the `DriverHost` interface hosts implement (retra today; retro is a
follow-up).
- `ui/src/apps/tui/screen_dump.zig` — screen → text / cells / ANSI
frame.
- `ui/src/apps/tui/drive_rpc.zig` — the `tui/*` JSON-RPC methods.
- `ui/src/apps/ansi_tui/ansi_tui.zig` `runHeadless` — the headless +
replay loops.
- `ui/src/apps/ansi_tui/input.zig` `encodeTermBytes` — event → terminal
bytes (the parser's inverse, unit-pinned round-trip).
- `ui/test-tui/` — reference tests (`smoke.dft`, `input.dft`,
`record.dft` + committed `golden/`); run with `zig build tui-tests`
from the ui checkout.