Manual TUI testing — the driver (#1161)¶
Driving the real multi-pane TUI by hand for a play-test gate used to be
error-prone: keys landed in a live pane as literal text (#1156), hand-rolled
tmux harnesses died on $TMUX/TMUX_TMPDIR collisions (#1155), and every run
re-derived send-keys/capture-pane/sleep N plumbing from scratch — where
the blind sleeps were the main flake source.
scripts/tui-driver.sh is the fix: a sourceable driver library of
self-synchronizing functions that drive the live TUI and assert on it. It
builds ON the #1130 container sandbox (which already solves isolation —
throwaway home, mock repo, private tmux, pids/memory caps; see
container-testing.md). The container is the where;
this driver is the how.
- Library:
scripts/tui-driver.sh - Self-test / acceptance proof:
scripts/tui-driver-selftest.sh - Run it:
make tui-driver-selftest(gate) ·make tui-driver(drive by hand)
1. The interaction model — read this first¶
The #1156 mis-drive happened because the driver didn't know which mode the TUI was in. Get this right and the rest follows.
Nav mode vs interactive mode¶
| Mode | What the keyboard does | Menu bar shows | How you got here |
|---|---|---|---|
| Nav | Keys are TUI commands (n new, s open pane, t new tab, j/k move the tree cursor, …) |
context hints (n new · …, ↵ interact · …) |
default; Ctrl-] from interactive |
| Interactive | Every key — including Tab — forwards to the focused pane's shell/agent | only ctrl+] nav mode |
Enter on a focused pane |
| Attached (full-screen) | tmux owns the terminal; the TUI chrome is gone | the tmux status line ([af_…) |
o on a selected instance |
The single most important primitive is af_ensure_nav — it sends Ctrl-]
(a no-op in nav, the escape hatch from interactive) so a scenario can never
mistake a live pane for the host. Call it before any nav action. This is the
one-line fix for the entire #1156 class.
The focus ring¶
In nav mode, Tab/Shift-Tab cycle ring focus across regions: the
instances tree → each open pane → the automations strip → the projects section → back.
The menu bar is context-sensitive and follows focus:
- tree focused →
n new · …(plus instance verbs when a row is selected) - pane focused →
↵ interact · o attach · ← prev pane · → next pane · … · s open pane · x hide pane - automations focused →
enter manage · … - projects focused →
↵ switch · / search │ tab focus · ? help · q quit
af_focus_tree walks the ring (checking before it presses, so it never
Tabs off the tree) until the n new hint proves the tree has focus.
The instances tree¶
The left rail is a tree: each instance row carries its tabs as children. The
display-selected instance auto-expands (arrow ▾, its tab children shown)
while every other instance collapses (arrow ▸). That arrow is the reliable,
text-greppable selection signal the driver asserts on:
On a cold boot the cursor sits on the section header (no instance selected
— menu shows the plain n new · N new remote set); the first j moves the
cursor down onto the first instance. af_select is robust to any starting
position: it anchors at the top (k is idempotent there), then steps down
with j until the target row shows ▾ and the tree cursor is actually on
it.
Display-selected ≠ cursor-on-row (#1174 / #1199). The sticky
▾is only a display signal. A single auto-selected instance renders▾while the cursor still sits on the section header — thereGetSelectedInstance()isnil, soo/D/attach silently no-op even though the row looks selected (a false pass waiting to happen).af_selecttherefore also requires the menu to advertise a row-scoped verb (D kill) — present only when a real instance is under the cursor — and keeps pressingjpast the header until it appears.af_attach/af_open_paneinherit the fix because scenarios callaf_selectfirst.
Which key goes where (defaults, from keys/keys.go)¶
| Key | Nav action | Key | Nav action | |
|---|---|---|---|---|
n |
new instance | s |
open selected tab as pane | |
Enter |
interact (enter pane) | x |
hide focused pane | |
o |
attach full-screen | t |
new tab | |
Ctrl-] |
exit interactive → nav | w |
close tab | |
Tab |
cycle focus ring | 1–9 |
jump to tab | |
j/k,↓/↑ |
move tree cursor | m |
tasks overlay | |
←/→ |
switch focused pane | [/] |
previous/next section | |
D |
kill instance | / |
search | |
a |
archive/restore | q |
quit | |
p/y |
open/copy PR | e |
hooks editor | |
Ctrl-P |
switch project | Ctrl-U/Ctrl-D |
preview scroll | |
Ctrl-W |
detach (full-screen) |
While naming a new instance the form owns the keyboard and its keys are fixed:
Tab opens the program picker, Shift-Tab opens the initial-prompt field
(#1936), Ctrl-R opens the backend field (#1933), Enter submits,
Esc/Ctrl-C cancel the create. Inside the prompt field Enter is a newline —
Tab/Esc close it keeping the text, Ctrl-C cancels the whole create.
Enter, Tab, Shift-Tab, Esc, Ctrl-], and 1–9 are reserved and
cannot be rebound ([keys] config). Ctrl-W is the configurable detach key
(detach_keys); the driver reads it from AF_DRIVER_DETACH_KEY.
The detach key is recognized in every encoding a terminal may report it in, not
just the legacy control byte: an agent CLI that turns on the kitty keyboard
protocol or modifyOtherKeys makes the terminal send Ctrl-W as an escape
sequence instead (#1832). af_detach takes an optional raw sequence so a
scenario can drive those encodings without that terminal.
Detaching also drops those negotiated modes back to legacy, so the terminal you
land back in reports Ctrl keys normally again. On a real kitty terminal this is
the check the driver cannot make for you: attach to a claude session, detach, and
confirm Ctrl-] and friends still work in the TUI — a terminal left upgraded
needs a manual reset to recover.
←/→ switch panes only when a workspace pane has focus; with tree focus they
keep the tree's collapse/expand behavior.
2. The wait-not-sleep principle¶
Never synchronize on a fixed sleep. A sleep 2 is a bet that the TUI
finished repainting in under two seconds — it flakes when it's slow and wastes
time when it's fast. Instead, every action helper returns only once the screen
shows its completion marker:
af_send n # request a new instance
af_wait_for 'submit name' # ← block until the name prompt actually appears
af_wait_for <regex> [timeout] [label] polls capture-pane (a short poll
interval, not a synchronization sleep) until the screen matches, and dumps
the last screen on timeout so a failure is debuggable. af_wait_gone is the
inverse. Every scenario helper is built from these, so a scenario is a
straight-line list of intent with no timing knobs.
3. Driver command reference¶
Source the library inside the container, then call the functions. State lives
in the running TUI (a private tmux session, default name drive), so calls
compose across docker exec invocations.
Anti-flake core¶
| Function | What it does |
|---|---|
af_wait_for <re> [t] [label] |
poll until the screen matches <re> (no blind sleeps) |
af_wait_gone <re> [t] [label] |
poll until <re> is absent |
af_ensure_nav |
Ctrl-] → force nav mode (fixes the #1156 class) |
af_focus_tree |
Tab the ring until the instances tree has focus |
Scenario helpers (each self-synchronizes on its marker)¶
| Function | Keys | Completion marker |
|---|---|---|
af_reset_sandbox |
— | wipes instances/branches for a deterministic rerun (sandbox-scoped, fails closed) |
af_boot |
launch af |
Agent Factory frame + the session rail painted — Sessions ( or the scrolled ▲ N more the rail shows in its place (#2148) |
af_new_instance <name> |
n,text,Enter |
the row shows <name> … ● (ready) |
af_select <name> |
k×,j× |
<name>'s row shows ▾ |
af_open_pane |
s |
pane-focus menu (x hide pane) |
af_hide_pane |
x |
visible-pane set changes (pane header row) |
af_enter_interactive |
Enter |
interactive menu (ctrl+] nav mode) |
af_exit_interactive |
Ctrl-] |
interactive menu gone |
af_send_to_pane <text> |
marker+text,Enter |
short delivery marker echoes in the pane (then wait for output yourself) |
af_attach |
o |
TUI chrome gone (full-screen) |
af_send_line <text> [t] |
text,Enter |
the WHOLE line echoed on the attached screen. Fails closed (#2147): it clears and re-pastes up to AF_DRIVER_SEND_LINE_ATTEMPTS times, and if the line still never lands complete it returns non-zero having sent no Enter — a submitted partial leaves an unbalanced quote and drops the shell into > |
af_detach [raw_seq] |
Ctrl-W (once) |
TUI chrome back and the attach client is reaped (guards #1157) |
af_new_tab |
t |
tab-child count rises |
af_close_tab |
w |
tab-child count falls |
af_open_tasks / af_close_tasks |
m / Esc |
tasks overlay (r run) appears / gone |
af_click <x> <y> / af_click_instance <name> |
SGR mouse | injects a left click at a cell / on an instance row |
af_scroll <up\|down> [x] [y] |
SGR wheel | injects a wheel event |
af_set_config <toml> + af_relaunch |
— | rewrites config.toml (canonical since #1030) and reboots the TUI |
af_resize <cols> <rows> |
— | pins the session to an exact geometry that STICKS (window-size manual + resize-window), for tiny-size gates |
af_quit |
q |
back to a shell prompt |
Assertions & introspection¶
| Function | Pass condition |
|---|---|
af_assert_screen <re> / af_refute_screen <re> |
screen matches / does not match |
af_expect_selected <name> |
<name>'s row carries ▾ |
af_tmux_ls |
prints the tmux sessions (introspection) |
af_ps |
prints the daemon + tmux attach/new-session process tree |
af_assert_no_orphan_clients |
no tmux attach-session reparented to init (the #1155/#1157 leak signature); the daemon's own monitor clients are parented to the daemon and excluded |
Configuration (env vars)¶
AF_DRIVER_SESSION (drive), AF_DRIVER_COLS/ROWS (100/30),
AF_DRIVER_REPO ($HOME/sandbox/mock-repo), AGENT_FACTORY_HOME,
AGENT_FACTORY_AUTO_UPDATE (driver sets to false to prevent mid-test
self-updates that would timeout instance creation),
AF_DRIVER_TIMEOUT (25s), AF_DRIVER_POLL (0.25s),
AF_DRIVER_DETACH_KEY (C-w), AF_DRIVER_BIN (auto-resolved),
AF_DRIVER_HELP_SEEN (15).
Set AF_DRIVER_COLS/ROWS before af_boot to launch at a non-default
size — af_boot pins it so it sticks (see the tiny-size gate below). Change
the size mid-run with af_resize <cols> <rows>.
Driving a first-run overlay¶
af_boot writes help_screens_seen before launching, and the default 15
marks every one-time overlay seen — that suppression is what makes ordinary
scenarios deterministic, but it also means no scenario could reach a first-run
screen at all. Clear the bit for the overlay you want:
| bit | overlay |
|---|---|
1 |
general help (?) |
2 |
instance-start help (n) |
4 |
instance-attach help (o) |
8 |
interactive-pane help (enter on a live pane) |
The bits are app/help.go's mask() methods. Re-run af_reset_sandbox before
a second boot in the same container — it wipes state.json, so the overlay is
genuinely "first run" again; without it the first boot has already marked it
seen. scripts/tui-2413-scenario.sh is a worked example.
4. Running it¶
The self-test (acceptance proof + bitrot guard)¶
Boots a dedicated container sandbox (af-driver-selftest, so it never
disturbs a drive/playtest container you have open), then runs the exact
scenario that failed in #1156, now deterministic:
reset → boot → create two instances → select each (assert selection) → open a pane → enter interactive → type into the pane → exit → attach full-screen → detach → assert selection preserved → assert no orphan clients.
Green means the driver drives the TUI reliably. Any failure prints the step and the offending screen.
One scenario script (a per-fix real-TUI gate)¶
Runs a single scenario script in the same ephemeral, uniquely-named sandbox the
self-test uses, then tears it down. The path is repo-relative (the repo is
mounted read-only at /src); pin AF_SELFTEST_NAME to reuse a container
instead.
Use this for a regression scenario that belongs to one fix. Do not bolt such
a case onto tui-driver-selftest.sh: that scenario is the shared acceptance
proof, and destabilizing it costs more than the bug the new case guards.
Driving by hand¶
make tui-driver # boots af via the driver, then attaches you to the
# live session (detach with your tmux prefix + d)
Or drive over docker exec against a detached sandbox. The container name is
unique per run (#1171); pin it with AF_PLAYTEST_NAME so your docker exec
targets it:
export AF_PLAYTEST_NAME="af-playtest-$$"
make playtest-container-detached
docker exec "$AF_PLAYTEST_NAME" bash -lc '
source /src/scripts/tui-driver.sh
af_boot
af_new_instance alpha
af_new_instance beta
af_select beta && af_expect_selected beta
af_open_pane && af_enter_interactive
af_send_to_pane "echo hi"
af_exit_interactive
af_assert_no_orphan_clients
'
docker rm -f "$AF_PLAYTEST_NAME" # teardown
Everything runs inside the container — the host tmux server, the real
~/.agent-factory, and this repo are all untouched.
5. Gate-recipe library¶
To gate a visible-TUI PR, run the scenario for its class and assert the markers. All of these are driver calls; each already self-synchronizes.
Any TUI-visible change → the smoke gate¶
The self-test is the baseline gate for any PR that touches startup, the sidebar/tree, panes, interactive mode, or attach/detach. If it isn't green, stop.
Create-form changes (the #1936 class)¶
The naming form is a multi-field form behind a single row, so gate every field plus the paths that leave it:
af_boot
af_ensure_nav; af_focus_tree
af_send n; af_wait_for 'submit name'
af_wait_for 'initial prompt' # the field is advertised
af_send Tab; af_wait_for 'Select program' # sibling field still opens
af_send Escape; af_wait_for 'submit name'
af_send BTab; af_wait_for 'enter newline' # shift+tab opens the field
af_send_literal 'first line'; af_send Enter # enter is a NEWLINE here…
af_wait_for 'enter newline' # …so the field is still open
af_send Tab; af_wait_for 'initial prompt ✓' # closes, hint confirms it stuck
af_send BTab; af_wait_for 'first line' # reopening shows the text
af_send Escape # esc keeps the text (field, not dialog)
af_wait_for 'initial prompt ✓'
The backend field (#1933) is the third field and needs a wide terminal: its hint
sheds below ~93 columns (ui/menu.go hintDropOrder), so drive this at 120+ or the
marker will legitimately be absent.
af_boot # 120 cols or wider
af_ensure_nav; af_focus_tree
af_send n; af_wait_for 'backend' # the field is advertised
af_send C-r; af_wait_for 'Select backend' # daemon round trip, then the list
af_wait_for 'Repo default' # first row names the resolved default
af_send Escape; af_wait_for 'submit name' # esc backs out of the field only
af_send C-r; af_wait_for 'Select backend'
af_send Down; af_send Enter # pick the row below Repo default
af_wait_for 'backend ✓' # hint confirms a non-default backend
A backend the repo cannot use is listed with — unavailable (or — cannot check)
and refuses the pick with the daemon's own reason — the same sentence
af sessions create --backend <that one> prints. Selecting it must leave the form
open with the hint back to backend and no ✓. Then finish a create on a backend
the repo CAN use and confirm the session actually comes up there: the round trip is
the whole point and no marker stands in for it.
enter newline is the overlay's own hint row, used as the marker rather than
its Initial prompt title: the status-bar hint underneath says initial
prompt too, so the title alone cannot tell "field open" from "field
advertised".
Then finish the create by hand and confirm the agent receives the prompt as its
first input — that round trip is the whole point of the feature and no marker
can stand in for it. Re-run n afterwards and confirm the hint is back to
initial prompt with no ✓: a prompt must never leak into the next session.
Test the form in a repo that declares a non-local backend, too (#2599). The
naming row used to be built by provisioning the create's runtime, so in a repo
whose .agent-factory/config.json says backend = "docker" (or ssh/hook)
pressing n ran a real provisioner and the form never opened at all. Every
create-form gate above passes in a local repo while that is broken, which is why
this is its own step:
mkdir -p "$AF_DRIVER_REPO/.agent-factory"
printf '{"backend": "docker"}\n' >"$AF_DRIVER_REPO/.agent-factory/config.json"
af_boot; af_ensure_nav; af_focus_tree
af_send n; af_wait_for 'submit name' # the form opens at all
af_send_literal 'declared'; af_send Enter
af_wait_for 'docker' # the DAEMON refuses, naming docker
The second wait is the load-bearing one. A create that succeeds here as a local
session means the placeholder's backend was pinned local and the repo's declared
backend went with it — which passes "the form opens" and silently gives the user
a session their repo did not ask for. The refusal has to come from the daemon
and has to name the backend. scripts/tui-2599-scenario.sh automates all three
legs (form opens, backend honored, ctrl+r → local still creates).
Tree / selection / focus changes (the #1156, #1084 class)¶
af_boot
af_new_instance a; af_new_instance b; af_new_instance c # (cap: 3)
af_select a; af_expect_selected a
af_select c; af_expect_selected c
af_select b; af_expect_selected b
# after closing/killing, selection must not silently drift:
af_open_pane; af_close_tab; af_expect_selected b
Pane / interactive changes (the #1088, #1089 class)¶
af_select a; af_open_pane
af_enter_interactive
af_send_to_pane 'echo PANE_OK'; af_wait_for 'PANE_OK'
af_exit_interactive
af_refute_screen 'nav mode' # cleanly back in nav
af_hide_pane # pane hides, nothing killed
Attach / detach changes (the #1155, #1157, #1159 class)¶
af_select a
af_attach # full-screen
af_detach # syncs on the attach client being reaped
af_assert_no_orphan_clients # the hard leak check
af_expect_selected a # selection survives the round trip
Tabs (the #930 class)¶
Config / keymap changes (the #1030 class)¶
af_set_config "$(cat <<'TOML'
default_program = 'claude'
[program_overrides]
claude = 'bash'
[keys]
new = ['c']
TOML
)"
af_relaunch
af_ensure_nav; af_focus_tree
af_send c; af_wait_for 'submit name' # the rebound 'new' key works
Mouse (the #1143 class)¶
af_new_instance a; af_new_instance b
af_click_instance a; af_expect_selected a
af_click_instance b; af_expect_selected b
af_scroll down; af_scroll up
Tiny geometry / responsive-layout changes (the #1174-item-2 class)¶
A detached tmux session defaults to window-size latest, which snaps the
window back to the last-attached client (80x23) and ignores new-session
-x/-y. So a naive small-size boot silently ran at 80x23 and never exercised
the tiny layout. Boot with the size preset (af_boot pins it), or resize
mid-run with af_resize:
AF_DRIVER_COLS=60 AF_DRIVER_ROWS=15 af_boot # boots pinned at 60x15
af_new_instance a
af_select a; af_expect_selected a # selection still works when narrow
af_resize 40 10 # squeeze to 40x10 mid-run
af_assert_screen 'Sessions' # header must survive the squeeze
6. Gating a branch cut BEFORE #1166 (the driver isn't in the tree yet)¶
scripts/tui-driver.sh landed in #1166. A branch cut before that commit has
no driver to source, so source /src/scripts/tui-driver.sh fails with No such
file. Two ways to gate such a branch — pick one before you boot:
- Rebase the branch onto
master(preferred when the branch is yours and rebasing is clean) — this pulls the driver + self-test into the tree naturally, and you gate exactly what will merge. - Copy the driver in from
masterwhen a rebase is noisy or the branch is an external PR you don't want to rewrite. Inside the running sandbox container:
# From the host, into the sandbox container (name is unique per run, #1171):
docker cp scripts/tui-driver.sh "$AF_PLAYTEST_NAME":/src/scripts/
docker cp scripts/tui-driver-selftest.sh "$AF_PLAYTEST_NAME":/src/scripts/
# then drive as usual — the driver is pure harness, so master's copy gates
# any older product tree without changing what you're testing.
Because the driver only sends keys and reads the screen — it carries no product
code — master's copy is safe to run against an older af build; it asserts on
the same on-screen markers regardless of the branch under test.
7. Isolation & box safety (inherited from the container)¶
Every rule from the tui-playtest skill
is satisfied structurally by running inside the container: private tmux
server, throwaway AGENT_FACTORY_HOME, pre-built mock repo, pids/memory caps,
teardown is docker rm -f. The driver reinforces this:
- It only ever kills its own named session and (in
af_reset_sandbox) the sandbox'saf_*sessions — neverkill-server. af_reset_sandboxfails closed: it refuses to wipe anything unlessAGENT_FACTORY_HOMEand the mock repo are sandbox paths, so it can never touch a real~/.agent-factory.- Sessions run the cheap
bashprogram (the sandbox'sconfig.tomloverride), never a real agent or an unbounded generator.