Single-Run Monitor TUI - Engineering Spec
Status: Implemented Date: 2026-06-301. Goal & How It Ships
The single-run monitor TUI is a full-screen terminal UI launched from thebunx smithers-orchestrator up --interactive flow (apps/cli/src/tui.js → runTuiCommand). Instead of clack cards streaming down the terminal, the user gets a persistent, keyboard-driven, five-mode monitor that stays live for the full lifetime of a run.
Entry points:
bunx smithers-orchestrator up --interactive <workflow>- existing flag (no short alias;-iis--input); picks/starts a run, then hands off to the monitor.bunx smithers-orchestrator up --interactivewith no workflow argument - shows the fuzzy workflow picker (reusing existingfuzzySelect+buildWorkflowPickerOptionslogic fromtui.js), then monitors the selected run.
runTuiCommand is retained; it still owns the pre-fullscreen picker/input prompts and starting the run. After the run starts it spawns the monitor as a child process via the smithers-mon bin (the @smithers-orchestrator/tui package’s bin, resolved by resolveTuiEntry()), passing the runId and forwarding SMITHERS_CLI / SMITHERS_GATEWAY_URL / SMITHERS_BACKEND / SMITHERS_TOKEN. The monitor (packages/tui/src/index.tsx) is a bin entry script, not an exported runTuiMonitor(runId, opts) function. When the smithers-mon bin can’t be resolved (e.g. a slim install), runTuiCommand falls back to the inline streamRun append-only path so the run is still observable. There is no up --run-id attach mode.
2. Runtime & Stack
Critical JSX note:
packages/tui sets jsxImportSource: "@opentui/react" at the tsconfig level (so every file uses the OpenTUI React JSX runtime, which declares the OpenTUI intrinsic elements). Never use plain react (its JSX runtime has no OpenTUI element types) and never use the smithers-orchestrator jsx runtime here - that runtime is exclusively for .tsx workflow files that use smithers’s declarative task DSL.
3. Data Layer
Provider setup
The TUI uses the same gateway hooks as the web UI. There is no DB adapter dependency in this package. All data flows through the Gateway HTTP/WebSocket API.SMITHERS_API_KEY (or a --auth-token) is set, after which it rejects unauthenticated RPC/WS calls (though /health still answers). So gatewayConfig.ts resolves a bearer token (in priority order: --token arg, then SMITHERS_TOKEN, then SMITHERS_API_KEY) and index.tsx threads it consistently through: the SmithersGatewayClient (token → HTTP Authorization header + WS auth.token), the /health probe, and - when it autostarts a local gateway - the spawned gateway --auth-token <token>. When no token is configured, a loopback gateway with no auth stays token-free.
No OPFS / navigator persistence. The TUI mounts the local Smithers collection provider without browser-only persistence. The in-memory QueryCollection path works correctly headless.
No createGatewayReactRoot. That helper wraps react-dom/client and is for browser contexts. Use @opentui/react’s createRoot directly.
Gateway autostart (as built): When the user has NOT pinned an explicit gateway (--gateway / SMITHERS_GATEWAY_URL), index.tsx probes http://127.0.0.1:7331 and, if unreachable, spawns the resolved CLI’s gateway subcommand (bunx smithers-orchestrator gateway --host 127.0.0.1 --port <port>, detached, via cliEntry.ts - never process.argv[1], which would recursively relaunch the monitor) and waits for it to answer. If the user DID pin a gateway and it is unreachable, the monitor errors out with a “start it with bunx smithers-orchestrator gateway” hint instead of autostarting a local one it would never connect to. There is no --background flag.
Hook inventory
The
useGatewayRun hook drives the header and is polled via the collection’s built-in live-stream (no polling interval needed). useGatewayRunEvents feeds the run-events surfaces (the LOGS transcript and the TIMELINE scrubber), not the tree. The tree comes from a separate hook, useGatewayRunTree(runId), a live query over the per-run nodes collection; that collection is kept fresh by the gateway’s streamDevTools stream (each devtools frame triggers a getDevToolsSnapshot refetch + reconcile), so the tree stays current without the TUI parsing any frame payloads itself.
4. Bootstrap / Provider Stack
As built: the bootstrap below runs at module top-level inpackages/tui/src/index.tsx, thesmithers-monbin entry script (a child process). There is NO exportedrunTuiMonitor(runId, opts)function and noimport { runTuiMonitor }call site - the entry parsesargv, probes/autostarts the gateway, then mounts. The sketch is illustrative of that same top-level wiring (exitOnCtrlC: false,onExitteardown).
exitOnCtrlC: false because the TUI routes q / Q / Ctrl-C (and external SIGINT/SIGTERM/SIGHUP) through its own onExit teardown rather than letting the renderer hard-exit. There is no in-TUI run-cancel; cancel from the CLI.
5. Five Modes
All five modes share:- A persistent header (1 row).
- A bottom keybar (1 row) showing the relevant keybindings for the active mode.
- The remaining terminal height for the mode body.
Header layout (all modes)
RunHeaderView (presentational) in packages/tui/src/Header.tsx,
fed by the gateway-connected Header (pulls useGatewayRun, ticks a 1s clock
while the run is live). Pure helpers live in packages/tui/src/headerUtils.ts.
Fields, left to right:
- Status dot -
●colored bystatusDotColor: cyan = running/recovering, amber = waiting-approval/event/timer, red = failed, green = succeeded/finished, grey = cancelled (terminal but not a failure, so dim rather than red), darker grey = unknown. - Workflow name -
useGatewayRun().data.workflowKey(falls back to(workflow)). - Short run id - shown whole unless longer than 24 chars, then elided from the end.
- Model - from the run row’s
modelwhen present; omitted (never fabricated) when the row carries none. - Elapsed - ticking
mm:ss(orhh:mm:sspast an hour) fromstartedAtMs/createdAtMs, frozen atfinishedAtMsonce the run is terminal. - live|paused -
[live]while running,[paused]while parked on a gate/event/timer, else the literal terminal state.
No frame counter (fn/total): the monitor subscribes to the run-event stream, which carries no_smithers_frames.frame_nototal, so there is no honest frame count to show in the header; it is intentionally omitted rather than guessed. (TIMELINE mode has its ownframe n/totalover the events it actually holds.)
Mode 1: TREE (default)
Layout: horizontal split.-
Left pane (
<scrollbox>, ~38% width): collapsible node tree.- Each row:
indent + chevron + glyph + label + inline meta. Rows do NOT render a per-node elapsed time (the snapshot node carries no readily-available per-node timing); the optional trailingmetais the node’s ownmetastring, rendered inline and dim after the label (truncated to fit the pane). - Chevron:
▾expanded,▸collapsed,·leaf. - Glyph:
✓finished/ok,●running,⏸waiting,○pending/queued,⊘cancelled (dim/grey, terminal but not a failure),✗failed,·other/unknown. - j/k / arrow keys move the cursor (the right pane tracks the cursored node live);
spacefolds/unfolds a node with children;⏎moves focus INTO the inspector (right) pane;tabtoggles focus between the two panes. - Data:
useGatewayRunTree(runId)- a live query over the per-runnodescollection, kept fresh via the gateway’sstreamDevToolsstream (refetch + reconcile ofgetDevToolsSnapshot). The TUI does not reconstruct the tree from raw event frames.
- Each row:
-
Right pane (
NodeInspector): tabbed panel for the selected node.- Tabs: Output / Logs / Diff / Props. (There is no Tools tab: the gateway exposes no per-node tool-call stream to the monitor; tool calls live only in the durable store and are surfaced by
bunx smithers-orchestrator node, so an always-empty tab would be dishonest.) - Auto-default tab per node, in priority order:
- Container node (root/parallel/loop/saga/try/workflow/group) → Props tab.
- Otherwise currently running → Logs tab.
- Otherwise has output → Output tab.
- Otherwise → Props tab.
- Output tab:
<code>block with formatted node output fromuseGatewayNodeOutput. - Logs tab: scrollable
<scrollbox>of agent transcript events filtered to this node fromuseGatewayRunEvents. - Diff tab: unified-diff text rendered in a
<code filetype="diff">block (a summary line + the patch), fetched lazily via thegetNodeDiffRPC only while this tab is active. It does not use an OpenTUI<diff>primitive. Falls back to a stat summary or an explicit empty/loading state. - Props tab:
<code filetype="json">block with node metadata -id,key(the unique row key),iteration,name,kind,status,agent,meta,cardLabel,parentId,childIds.key+iterationdistinguish repeated logical nodes (loop/retry attempts). - Tab switching:
1–4(output/logs/diff/props) or left/right arrows when inspector is focused.
- Tabs: Output / Logs / Diff / Props. (There is no Tools tab: the gateway exposes no per-node tool-call stream to the monitor; tool calls live only in the durable store and are surfaced by
-
Approval banner: when the CURSORED node has a pending gate in
useGatewayApprovals({ filter: { runId } }).data, a bordered banner renders at the top of the inspector (right) pane, ABOVE the tab bar (not above the keybar). It shows⏸ <title> [<mode>]plus a summary line and mode-appropriate controls: a plain gate shows[a] approve [d] deny; aselectlists its options and shows[[/]] choose [a] approve selected [d] deny; arankshows[a] approve (ranked as listed) [d] deny. Keysa/d(and[/]for aselect) work whether focus is in the tree or the inspector, and callactions.submitApproval. A pending durable HumanTask renders a distinct variant instead (the gateway has no RPC to submit the typed answer), pointing the operator atbunx smithers-orchestrator human inboxrather than offering an approve/deny that would strand the run.
Mode 2: GRAPH
Layout: full-body<box> canvas.
- Nodes rendered as
<box>cards with a title line (glyph + label) and a status-colored border. - Cards joined by ASCII arrows
──▶laid out in topological order (left to right, depth = column, breadth = row). - Card border colors mirror the glyph palette (green/blue/yellow/dim/red).
- Scrolling: j/k move viewport;
⏎on a node jumps to TREE mode with that node selected. - Data: same DevTools snapshot as TREE mode.
gtoggles between TREE and GRAPH.
Mode 3: LOGS
Layout: full-body<scrollbox>.
- Live agent transcript - the mapped run events from
useGatewayRunEvents, interleaved in seq order. - Each line:
seq [nodeId] │ event text. (No side-effect badges: the gateway maps no tool-call events into the run-event stream, so a read/write/shell badge would never fire on a real run.) - Follow mode: on by default. Scrollbox auto-scrolls to bottom as new events arrive.
ftoggles follow; a[live]/[paused]indicator in the LOGS mode status row reflects this. [/]walk backward/forward across node attempts (filters events to the selected attempt).- Data:
useGatewayRunEventswithmaxEvents: 2000.
Mode 4: TIMELINE
Layout: vertical stack - a status bar, a fixed-height tick strip, a divider, then the node-snapshot body below.- Tick strip: a
frame n/total+ selected-event info row above a single row of tick marks, then a controls row. The tick row shows a window of frames centered on the selection (it is NOT a horizontally scrolling<scrollbox>; it slicesmaxVisibleframes around the cursor). Notable frames (first agent event, gate raises) are marked; gate/approval frames stand out; the selected frame is highlighted. - Body: node state inspected as of the selected frame, reconstructed from the event history.
- Inspect-only: this is an event timeline you scrub to read run history; it deliberately offers no fork/rewind/replay/jump mutations. Wiring a “rewind” here would either no-op or rewind to a guessed frame, so the timeline stays honest and points you at the durable command instead: rewind a run with
bunx smithers-orchestrator rewind(fork withbunx smithers-orchestrator fork, replay withbunx smithers-orchestrator replay). - Keys:
j/k(or←/→) scrub events;Lreturns to the live head. - Data:
useGatewayRunEvents(full event buffer); no mutating actions.
Mode 5: HIJACK
Activation:h is a global key (works from any mode) that switches to HIJACK mode. It does NOT depend on the tree cursor being on a running node - HIJACK resolves the hijackable nodes itself and lists them.
Flow (as built):
- A
<select>overlay lists the hijackable (running) nodes - resolved from the run tree + live-session signal in the event stream. Pick one to confirm, or pressEscto cancel back to TREE. When there are no running nodes, HIJACK shows a “no running nodes available” message instead of a picker. - On confirm:
a. The renderer is suspended, yielding the terminal back to raw stdio.
b. The monitor shells out to
bunx smithers-orchestrator hijack RUN_ID --target NODE_IDvia the resolved CLI entry, inheriting stdio (the CLI spawnsclaude --resume/codex resume/ etc.). c. The cleanup kills the child (whole process group on POSIX) and restores the renderer on every exit path, including unmount mid-session. - On the native CLI exit the renderer resumes and a return banner shows. There is no in-TUI “resume automation” action:
bunx smithers-orchestrator hijackitself resumes the run when the session exits cleanly on a live run, so the banner only reports what happened -- clean exit (code 0): “Smithers automation resumed (handled by hijack on clean exit).”
- non-zero exit: “Run left as-is - re-run hijack to retry the hand-off.”
- The banner offers only
[d] dismiss.
6. Full Keymap & Responsive / Compact Behavior
Global keys (all modes)
These are the keys actually wired inApp.tsx. (There is intentionally no global cancel/resume/copy key; drive those from the CLI: bunx smithers-orchestrator cancel, bunx smithers-orchestrator up --resume, etc.)
Mode-specific keys
Responsive / compact behavior
Each layout-aware module readsuseTerminalDimensions() (from @opentui/react)
and compares the width against the constant COMPACT_WIDTH, declared locally in
packages/tui/src/App.tsx and the width-adapting mode files under
packages/tui/src/modes/. Below that threshold the modes switch to a compact
layout; the exact ratios and truncation lengths live in the code and are not
pinned here (to keep the spec from drifting on volatile constants):
- TREE: the side-by-side tree/inspector split collapses to a vertical stack (node tree above, inspector below).
- GRAPH: card labels are truncated and the connector arrows are shortened.
- LOGS: the per-line node tag prefix is truncated more aggressively.
- TIMELINE: the tick strip is compressed to fit the narrower width.
7. Testing Strategy (CI-safe)
CI has no agent CLIs and no browser. Every test must be self-contained against a seeded local Gateway or a fake in-memory sync backend.Approach
As built - two test layers, all inpackages/tui/tests/ (Bun’s runner):
-
Pure util tests (
*-mode.test.ts,event-frame.test.ts,diff-utils.test.ts,approval-utils.test.ts): exercise the mode helpers (tree flatten/glyphs, log attempt keys, timeline frame classification + node snapshots, diff view, approval decision shaping + therunApprovalSubmitordering) as plain functions - no renderer. -
Render tests (
*-render.test.tsx): mount the real presentational view each mode wraps (NodeInspectorView,LogView,TimelineView) with the headless@opentui/reacttest renderer (testRender) and injected props - no gateway, no provider, no agent, no browser. The thin*Modewrapper only reads the gateway hooks and forwards them, so the view under test can’t drift from production.renderHelpers.tsxwraps the async mount/idle/destroy in Reactact(...)so the suite stays warning-free.
mockInput.pressKey(...) inside act(...); assertions read captureCharFrame() cell content.
CI constraints addressed:
- No agent CLI and no browser - every test is prop-injection against the real components or pure helpers; nothing spawns a gateway, agent, or PTY.
- No real TTY - the headless
testRendersupplies a fake terminal of a fixed width/height.
packages/tui/tests/ - the pure-util *-mode/*-utils/*-config tests plus the *-render view tests; not an exhaustive listing):
8. File / Module Layout
New package: packages/tui
As built - a flat module layout (no hooks/, devtools/, bootstrap.tsx, ensureGateway.ts, layout.ts; the gateway tree/elapsed/frame logic lives in the shared gateway-react hooks, and each mode keeps its presentational view + a thin gateway wrapper in one file). The tree below is representative of the key modules, not an exhaustive file listing:
CLI wiring (apps/cli/src/), as built
-
apps/cli/src/tui.js-runTuiCommandis retained. It owns the pre-fullscreen flow (fuzzy picker + input prompts), starts the run as a detached background process, then hands off to the monitor. The handoff is a child-process spawn of thesmithers-monbin (launchTuiMonitor()→spawn("bun", [tuiEntry, runId], { stdio: "inherit", env })), where the env is built bybuildTuiMonitorEnv()and forwardsSMITHERS_CLI(the real CLI entry, always set), plusSMITHERS_GATEWAY_URL,SMITHERS_BACKEND, andSMITHERS_TOKENwhen present (the last so an explicit--auth-tokenreaches the monitor’s gateway client).tuiEntryis resolved byresolveTuiEntry()from the@smithers-orchestrator/tuipackagebin. The CLI distinguishes a clean monitor quit (exit 0, or an expected interrupt signal SIGINT/SIGTERM/SIGHUP) from a startup failure (non-zero exit) or a crash (other terminating signals → reported asTUI_MONITOR_CRASHED). -
Fallback stream path: when
resolveTuiEntry()returns null (e.g. a slim install without the TUI package),runTuiCommandcalls the inlinestreamRun(...)append-only loop instead, so the run stays observable without the full-screen monitor. -
The monitor is a bin entry script (
packages/tui/src/index.tsx), not an exportedrunTuiMonitor(...)function; there is noimport { runTuiMonitor }call site. -
No changes to
apps/cli/src/hijack.jsfrom the monitor side. The monitor’s HIJACK mode shells out tobunx smithers-orchestrator hijack RUN_ID --target NODE_IDvia the resolved CLI entry (SMITHERS_CLIor the resolved@smithers-orchestrator/clipackage).
Resolved Decisions (originally open questions)
-
Renderer suspend/resume for HIJACK - resolved: HIJACK suspends the renderer and restores it on every exit path of the spawned
bunx smithers-orchestrator hijackchild (the cleanup also kills the child’s process group). No destroy/re-create dance is needed. -
<diff>element - resolved: the monitor does not use an OpenTUI<diff>primitive. The Diff tab renders unified-diff text in a<code filetype="diff">block, fetched lazily via thegetNodeDiffRPC, with stat/empty/loading fallbacks. -
Terminal test driver - resolved: tests use the headless
@opentui/reacttestRender(mounting the real presentational views with injected props), not@microsoft/tui-testor a spawned PTY. This keeps the suite CI-safe (no agent, no browser, no real TTY) and impossible to drift from production. -
Gateway autostart - resolved:
index.tsxprobes the local gateway and, when unpinned and unreachable, spawns the resolved CLI’sgatewaysubcommand directly (viacliEntry.ts); a pinned-but-unreachable gateway errors out instead of autostarting. -
Run-tree reconstruction - resolved: the tree comes from the shared
useGatewayRunTreehook (a live query over the per-runnodescollection, kept fresh by the gateway’sstreamDevToolsstream, each frame driving agetDevToolsSnapshotrefetch + reconcile), keyed byrunNodeKey. Run events (useGatewayRunEvents) feed the logs/timeline surfaces, not the tree;packages/tuidoes not implement its own delta-merge.