UH-41 — Interactive TUI architecture
Epic-level architecture document for the uh tui Mission Control interface. Captures the load-bearing decisions from the UH-46 grill so downstream slices (UH-47 mission browser, UH-44 mission run flow, UH-43 adapter/sandbox manager, UH-42 polish) inherit a stable contract.
Last updated: 2026-05-18. UH-46 shipped.
1. Position in the system
Section titled “1. Position in the system”| Surface | Audience | Stability bar |
|---|---|---|
CLI (uh <subcommand>) |
Agents (Claude / Codex / Pi / OMP / OpenCode / Hermes / Hermes-Proxy) and scripts | Frozen — third-party plugins consume it. Breaking changes require a major bump. |
TUI (uh tui) |
Humans (Lalo, reviewers) | Opinionated, evolves freely. Mission Control by default. |
| Plugin (planned) | Foreign coding agents driving UH on a user’s box | Ships skills + hooks + (optional) MCP server; consumes the CLI. |
The TUI sits on top of the CLI: it never invents its own data shapes, never bypasses the CLI’s safety properties, and never freezes a contract the CLI doesn’t already commit to.
2. Layers
Section titled “2. Layers”┌────────────────────────────────────────────────────────────────┐│ View src/tui/{dashboard.tsx,index.tsx} ││ OpenTUI/Solid renderables, key handlers, layout │├────────────────────────────────────────────────────────────────┤│ State src/tui/state.ts ││ Solid signals + fs.watch + selection + adapter-check │├────────────────────────────────────────────────────────────────┤│ Model src/tui/model.ts ││ Pure async TS — snapshot reader, no Solid, no renderer │├────────────────────────────────────────────────────────────────┤│ Harness primitives (existing) ││ src/harness/paths.ts, src/harness/registry.ts, ... │└────────────────────────────────────────────────────────────────┘The split is strict so the model layer is unit-testable with plain Vitest (no renderer mocking) and the state layer is testable through injected watcherFactory / adapterChecker seams. The view is verified by manual smoke + the --once headless render.
3. Decisions
Section titled “3. Decisions”D1 — Primary surface: TUI for humans, CLI for agents
Section titled “D1 — Primary surface: TUI for humans, CLI for agents”uh tui is the default human entry point. The CLI is the stable API every other surface (foreign-agent plugins, CI pipelines, scripts) targets. CLI breaking changes need a major version bump; TUI ergonomics evolve freely.
Alternatives considered: CLI-first (TUI as htop-style observer only), hybrid split (separate read-only TUI + a modal run TUI).
D2 — Mission Control by default, --once escape hatch
Section titled “D2 — Mission Control by default, --once escape hatch”uh tui stays open, watches .harness/ live, never auto-exits. uh tui --once renders one frame and exits cleanly — for CI screenshots, docs gifs, and smoke tests. Persistence of UI state (focused pane, last-selected mission) is a UH-42 follow-up — UH-46 ships without persistence; default mode is “Mission Control minus persistence.”
Alternatives considered: session tool (open per task), both-configurable.
D3 — Mnemonic pane focus + Tab fallback + soft miller link
Section titled “D3 — Mnemonic pane focus + Tab fallback + soft miller link”Pane focus is a (Adapters), m (Missions), s (Sandboxes). Tab cycles forward and Shift+Tab backward as a discovery fallback. Selecting a mission additionally scrolls the Sandboxes pane to the bound sandbox via the existing sandboxes[].mission_id relationship.
Reserved single-letter keys: a m s r q. UH-43 and UH-44 actions (c, d, r for create/discard/run) need a keymap discipline; the chosen shape is deferred to UH-43’s grill.
Alternatives considered: pure Tab cycle, full miller columns, mnemonics without the miller link.
D4 — Hybrid refresh: fs.watch for snapshots, append-only NDJSON tail for live runs
Section titled “D4 — Hybrid refresh: fs.watch for snapshots, append-only NDJSON tail for live runs”The dashboard data (adapter manifests, mission directories, sandbox index) is watched with fs.watch and re-loaded after a 200 ms debounce. Live mission-run events (UH-44) consume the per-mission append-only events.ndjson file each adapter already writes during execution, tailed line-by-line.
UH-46 implemented the fs.watch half; UH-44 wired up the live tail. The contract below is the actual on-disk shape every adapter (hermes, codex, hermes-proxy, oh-my-pi) already emits.
events.ndjson contract (consumed by UH-44):
{"event":"runtime.started","timestamp":"2026-05-18T20:00:00.000Z","runtime":"codex"}{"event":"codex.thread.started","timestamp":"2026-05-18T20:00:01.123Z","thread_id":"…"}{"event":"codex.turn.completed","timestamp":"2026-05-18T20:00:30.000Z"}{"event":"runtime.finished","timestamp":"2026-05-18T20:00:31.000Z","runtime":"codex","status":"succeeded"}Rules:
- One JSON object per line, terminated by
\n. timestampis RFC 3339 / ISO 8601 UTC. Consumers may also acceptts,time, oratas fallbacks.event(preferred) /kind/typecarries the categorical label. Adapters namespace their own events under<runtime>.<verb>(e.g.codex.thread.started). The shared baseline isruntime.startedandruntime.finished.- Schemas above
event/timestampare open: adapters MUST tolerate unknown keys; consumers MUST treat the parsed object asRecord<string, unknown>. - Append-only — no truncation, no rotation during a run.
- File path:
.harness/missions/<id>/events.ndjson. Coexists with the post-runruntime-session.yamlsummary; both are written.
Alternatives considered: fs.watch everywhere, named-pipe subscription, polling.
D5 — Selection drives a footer preview line; on-focus-row adapter check
Section titled “D5 — Selection drives a footer preview line; on-focus-row adapter check”Selecting a row in any pane updates a single <text> footer line with that row’s context. The Adapters pane additionally fires runtimeRegistry.check(<id>) on selection, with a 5 s TTL and one-in-flight cap to prevent arrow-spam from flooding the registry. The result is appended to the same footer line.
This satisfies UH-46’s literal acceptance line “Refreshes adapter check <id> on focus” without crossing into UH-43 (mutations) or UH-47 (drilldown).
Alternatives considered: no-op selection, dedicated preview pane (deferred to UH-47), defer-until-UH-47.
D6 — Tiered failure surface
Section titled “D6 — Tiered failure surface”Failures get screen real estate proportional to severity:
| Failure | Surface |
|---|---|
.harness/project.yaml absent |
Full-frame takeover with uh init hint |
| Schema-malformed adapter / mission / sandbox file | Per-row badge (✖/?) from the model layer |
fs.watch error or dropped-event burst |
Sticky footer warning, auto-clears after 5 s |
Adapter check returns failure |
Footer preview line on adapter selection |
| Transient loader throw | Footer error, last-good snapshot stays on screen |
The takeover is the only modal failure surface, reserved for the one case that’s truly unrecoverable without action (no harness to observe). Everything else stays in-place so the user can keep navigating and retry with r.
Alternatives considered: silent (status quo), banner-everywhere, takeover-on-everything.
4. Runtime selection — why Bun
Section titled “4. Runtime selection — why Bun”Solid’s JSX cannot be emitted by tsc (Solid is Babel-only). dist/cli.js is plain Node-compatible JavaScript with no .tsx artifacts. The uh tui subcommand spawns Bun as a child process with bun --preload @opentui/solid/preload src/tui/index.tsx, so the Babel transform runs at module load. src/ ships in the npm tarball via package.json#files because the TUI source imports shared harness, schema, and adapter modules from sibling source directories.
If Bun is not on PATH, the CLI exits 1 with an install hint. Node-only deployments can keep using every other uh subcommand — the TUI is the only Bun-dependent surface.
5. Lifecycle invariants
Section titled “5. Lifecycle invariants”Carried over from docs/research/tui-framework.md §6:
renderer.destroy()is the only entry to terminal restoration. Never callprocess.exit(0)withoutdestroy()first — the terminal stays in raw mode.destroy()is idempotent on a single instance (_isDestroyedguard) but never callcreateCliRenderertwice in one process.- Cleanup order is fixed: process listeners removed → SIGINT/SIGTERM handlers detached → timers cleared → stdin cooked → stdout passthrough →
destroyevent fires → renderable tree teardown → Zig core restores screen + cursor + kitty kb + mouse. onCleanup()fires per-component on tree teardown (post-order). “Renderer-wide” cleanup (e.g. closing a websocket) goes onrenderer.on("destroy", …).
createDashboardState honors this: dispose() closes every watcher, clears the debounce + watcher-warning timers, and disposes the Solid root. The Dashboard component calls dispose() from onCleanup.
6. fs.watch contract
Section titled “6. fs.watch contract”createDashboardState(root) opens three watchers:
| Target | Triggers reload on |
|---|---|
.harness/adapters/ |
Adapter manifest add/remove/edit |
.harness/missions/ |
Mission directory add/remove + mission.yaml edits |
.harness/sandboxes/ |
index.yaml edits + per-sandbox subtree changes |
Events from any watcher feed a 200 ms debounce. The debounce window is exposed via DEBOUNCE_MS so tests can override. The watcher factory is exposed via WatcherFactory so tests inject deterministic event sources without spinning real fs.watch handles.
macOS kqueue drops events under heavy churn (~50/sec sustained). Mitigation: the r keybind triggers an immediate refresh() that bypasses the debounce. The watcher-warning footer surfaces watcher errors so users know when to press r.
7. Selection + cache
Section titled “7. Selection + cache”state.ts exposes:
state.selectAdapter(row) state.selectedAdapter()state.selectMission(row) state.selectedMission()state.selectSandbox(row) state.selectedSandbox()state.adapterCheck(id) // sync read, may return nullstate.refreshAdapterCheck(id) // async, deduplicated, 5 s TTLThe dashboard wires <select onChange> to the setters, and createEffect on selectedAdapter to fire the check. createMemo on selectedMission powers the soft miller link to selectedSandbox.
8. Future-slice hooks
Section titled “8. Future-slice hooks”| Slice | What it adds | What this doc commits |
|---|---|---|
| UH-47 Mission browser | Drilldown view with <code> and <diff> |
Enter on a mission row opens the detail view |
| UH-44 Mission run flow | Live events.ndjson consumer + subprocess trigger |
Contract in §3 D4 above; adapters already emit the NDJSON |
| UH-43 Adapter + sandbox manager | c create / d discard / re-check |
Reserves c d (and a keymap discipline TBD in UH-43’s grill) |
| UH-42 Polish | Keymap overlay (?), theming, error UX, state persistence |
XDG state path: ~/.config/uh/tui-state.json |
9. Out of scope
Section titled “9. Out of scope”- Remote sessions (someone else’s harness over SSH / WebSocket) — local only.
- Editing mission packets from the TUI —
mission.yamlis read-only here. - A web UI / static dashboard —
docs/ROADMAP.mdremains the canonical written index;uh tuiis the live one. - A custom renderer or bundler — runtime Babel via
bun --preloadis the only build path.