# 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.