Single-Run Monitor TUI - Engineering Spec
Status: DraftDate: 2026-06-30
1. Goal & How It Ships
The single-run monitor TUI is a full-screen terminal UI that replaces the current scroll-basedbunx 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/bunx smithers-orchestrator up -i <workflow>- existing flag; now launches the TUI after starting/attaching to a run.bunx smithers-orchestrator up -iwith no workflow argument - shows the fuzzy workflow picker (reusing existingfuzzySelect+buildWorkflowPickerOptionslogic fromtui.js), then monitors the selected run.bunx smithers-orchestrator up --run-id RUN_ID- attach the TUI to an already-running run without starting a new one.
runTuiCommand scroll path is removed. The new entry point is runTuiMonitor(runId, opts) exported from packages/tui/src/index.ts.
2. Runtime & Stack
| Layer | Package | Notes |
|---|---|---|
| Runtime | Bun (>=1.1) | All IO, child process, WebSocket global |
| TUI renderer | @opentui/core | Zig-native core via Bun FFI; createCliRenderer |
| React reconciler | @opentui/react | createRoot(renderer).render(...) |
| React | react 19 (peer) | Standard reconciler; react-dom is a peer of @opentui/react |
| JSX | /** @jsxImportSource react */ | Standard React JSX, NOT smithers-orchestrator’s jsx-runtime. The smithers jsx-runtime is only for workflow definition files. |
| Data | smithers-orchestrator/gateway-react | SmithersGatewayProvider + gateway hooks |
packages/tui must use /** @jsxImportSource react */ (or tsconfig-level jsxImportSource: "react"). Never use the smithers-orchestrator jsx runtime here - that pragma 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.createGatewayPersistence calls navigator.* APIs that do not exist in Bun. Pass no persistence option to createGatewayCollections. The in-memory sync backend (no-persistence 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: Before constructing the client, probe http://127.0.0.1:7331/health. If unreachable, spawn smithers gateway --background (or reuse the existing ensureGateway utility from apps/cli/src/gateway.js) and wait up to 10 s for it to answer. This mirrors the behavior of bunx smithers-orchestrator ui.
Hook inventory
| Hook | Source | Feeds |
|---|---|---|
useGatewayRun(runId) | gateway-react | Header status dot, workflow name, model, run state, frame counter |
useGatewayRunEvents(runId, { maxEvents: 2000 }) | gateway-react | LOGS mode transcript; TIMELINE tick strip (seq numbers → frame markers) |
useGatewayNodeOutput({ runId, nodeId, iteration }) | gateway-react | NodeInspector Output tab; TREE node metadata/elapsed |
useGatewayApprovals({ runId }) | gateway-react | Approval banner in TREE mode; gate markers in TIMELINE |
useGatewayActions() | gateway-react | cancelRun, resumeRun, submitApproval, rewindRun mutations |
useGatewayRun hook drives the header and is polled via the collection’s built-in live-stream (no polling interval needed). useGatewayRunEvents drives both the LOGS stream and the TIMELINE scrubber; the DevTools frame protocol (packages/protocol/src/devtools) parses DevToolsSnapshot and DevToolsDelta payloads from FrameCommitted events to reconstruct the node tree at each frame.
4. Bootstrap / Provider Stack
exitOnCtrlC: false because the TUI handles c / q in its own keymap and shows a confirm banner before cancelling a running run.
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)
- Status dot -
●colored: green = running, yellow = waiting-approval/event/timer, red = failed, dim = done/cancelled. - Workflow name - from
useGatewayRun().data.workflowName. - Short run id - first 12 chars.
- Model - from run metadata.
- Elapsed - ticking
mm:sscounter from runcreatedAtMs. - Frame counter -
fn/totalfromFrameCommittedevent count vsuseGatewayRunEventstotal seq. - live|paused -
[live]while following events;[f14]while frozen on frame 14 in TIMELINE mode.
Mode 1: TREE (default)
Layout: horizontal split.-
Left pane (
<scrollbox>, ~40% width): collapsible node tree.- Each row:
indent + chevron + glyph + label + right-aligned elapsed/meta - Chevron:
▾expanded,▸collapsed,·leaf. - Glyph:
✓finished,●running,⏸waiting,○pending,✗failed. - j/k / arrow keys move cursor;
spacefolds/unfolds;⏎selects node → updates right pane. - Data:
useGatewayRunEvents+ DevTools snapshot/delta reconstruction.
- Each row:
-
Right pane (
NodeInspector): tabbed panel for the selected node.- Tabs: Output / Logs / Tools / Diff / Props
- Auto-default per node kind:
- Has output → Output tab.
- Currently running → Logs tab.
- Container/parallel node → Props tab.
- Output tab:
<code>block with formatted node output fromuseGatewayNodeOutput. - Logs tab: scrollable
<scrollbox>of agent transcript events filtered to this node fromuseGatewayRunEvents. - Tools tab: tool-call events for this node, each with read/write/shell side-effect badge.
- Diff tab:
<diff>element (OpenTUI diff primitive) showing code changes if node produced a diff in its output. - Props tab:
<code>block with node metadata (nodeId, iteration, attempt, state, timing) from the DevTools snapshot. - Tab switching:
1–5or left/right arrows when inspector is focused.
-
Approval banner: when
useGatewayApprovals({ runId }).datahas a pending item, a highlighted overlay row appears above the keybar:[approval needed: <label>] a approve d deny. Keysa/dcallactions.submitApproval.
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 - all
AgentStream/ tool-call events fromuseGatewayRunEvents, interleaved in seq order. - Each line:
[nodeId-color] │ event text - Tool calls rendered with side-effect badges:
[read](dim),[write](yellow),[shell](red). - Follow mode: on by default. Scrollbox auto-scrolls to bottom as new events arrive.
ftoggles follow; the[live]/[paused]indicator in the header reflects this. [/]walk backward/forward across node attempts (filters events to the selected attempt).- Data:
useGatewayRunEventswithmaxEvents: 2000.
Mode 4: TIMELINE
Layout: horizontal split.- Top strip (
<scrollbox>horizontal): tick bar showing frame numbers. Notable frames (first agent event, tool calls, gate raises) are marked with│. Gate/approval frames are marked⊛. The selected frame is highlighted. - Body: the TREE view rendered as it was at the selected frame, reconstructed from the DevTools snapshot at that frame number.
- Bottom controls:
jump(⏎ on a frame),fork(spawns a new run from this frame viaactions.rewindRun),rewind(callsactions.rewindRunthen re-attaches),replay(re-runs from frame 0),back-to-live(L) returns to the live head. - Backed by the DevTools jump-to-frame protocol:
DevToolsEventstream fromuseGatewayRunEvents, replayingDevToolsDeltaops against the initialDevToolsSnapshotto build the tree at each frame. - Data:
useGatewayRunEvents(full event buffer),useGatewayActionsfor fork/rewind mutations.
Mode 5: HIJACK
Activation: pressh while a node is running (cursor on a running node in TREE mode).
Flow:
- A
<select>overlay appears (OpenTUI select primitive): “Hijack [nodeId] ([engine])? [confirm / cancel]”. - On confirm:
a.
renderer.suspend()- yields the terminal back to raw stdio. b. Resolve hijack candidate viaresolveHijackCandidate(adapter, runId)fromapps/cli/src/hijack.js. c. Build the launch spec viabuildHijackLaunchSpec(candidate). d.launchHijackSession(spec)- spawnsclaude --resume <id>/codex resume <id>/ etc. inheriting stdio. e. On native CLI exit:renderer.resume()(or re-initialize if needed), show a banner: “Hijack session ended. [a] resume automation [d] dismiss”. acallsactions.resumeRun({ runId })and returns to live TREE mode.ddismisses the banner; the run remains paused.
6. Full Keymap & Responsive / Compact Behavior
Global keys (all modes)
| Key | Action |
|---|---|
j / ↓ | Move cursor down |
k / ↑ | Move cursor up |
space | Fold/unfold node (TREE); page-down (LOGS) |
⏎ | Select / confirm |
g | Toggle TREE ↔ GRAPH |
l | Switch to LOGS mode |
t | Switch to TIMELINE mode |
h | HIJACK active node |
a | Approve pending gate |
d | Deny pending gate |
c | Cancel run (shows confirmation banner first) |
R | Resume failed run (actions.resumeRun) |
y | Copy run id to clipboard (via Bun.write to pbcopy/xclip) |
? | Toggle help overlay (lists all keys) |
q | Back one level / quit (Esc also closes overlays) |
Esc | Close overlay / back |
Mode-specific keys
| Key | Mode | Action |
|---|---|---|
1–5 | TREE | Switch NodeInspector tab |
←/→ | TREE (inspector focused) | Walk inspector tabs |
f | LOGS | Toggle follow mode |
[ / ] | LOGS | Walk node attempts |
L | TIMELINE | Back to live head |
⏎ on tick | TIMELINE | Jump to frame |
F | TIMELINE | Fork from current frame |
W | TIMELINE | Rewind from current frame |
Responsive / compact behavior
useTerminalDimensions() (from @opentui/core) is polled on resize. When terminal width falls below 100 columns:
- TREE: left and right panes stack vertically (node tree above, inspector below). Inspector height is capped to
floor((height - headerRows - keybarRows) * 0.45). - GRAPH: card labels truncated to 12 chars; arrows reduced to
→. - LOGS: tag prefix truncated to 8 chars.
- TIMELINE: tick strip compressed to show every Nth frame label.
COMPACT_THRESHOLD = 100 in packages/tui/src/layout.ts.
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
Unit / component tests (packages/tui/src/**/*.test.tsx):
- Use
@opentui/react’s test renderer (if available) or a headless snapshot renderer to mount components in a fake terminal. - Supply a fake
SmithersGatewayClientconstructed with a mockSyncTransportthat returns canned collections (no network). - Assert on rendered cell content (text, glyphs, colors) with snapshot tests.
- Simulate keyboard events via the renderer’s event API and assert state transitions.
packages/tui/e2e/*.test.ts or e2e/tui/):
- Seed a local Gateway (
packages/server) with deterministic run rows: a fixedrunId, 3 nodes (one finished ✓, one running ●, one pending ○), 2 pending approvals, and 20 canned events. - Launch the TUI process via
Bun.spawnpointed at the seeded Gateway URL. - Use
@microsoft/tui-test(or OpenTUI’sTestRendererif it exposes a PTY API) to interact with the spawned process: send keystrokes, capture frames, diff against golden snapshots. - The seeded Gateway is a real
packages/serverinstance seeded with aSmithersDbfixture; no mocking of the Gateway itself.
- No agent CLI required - the fake agent is a stub workflow that emits canned
AgentStreamevents into the seeded DB. - No browser -
@microsoft/tui-testdrives a PTY subprocess, not a browser. - Deterministic timing - elapsed timers in the header are frozen by injecting
createdAtMsinto the seeded run row such that elapsed = a fixed value.
8. File / Module Layout
New package: packages/tui
CLI wiring change (apps/cli/src/)
-
apps/cli/src/tui.js-runTuiCommandis removed (the scroll-based path). The file retains only pure utility exports consumed by other CLI commands:buildWorkflowPickerOptions,pickerMaxItems,truncate,wrapText,renderRunCard,fetchCard,streamRun,childFailurePromise,waitForRunRow,formatOutputRow,formatStreamText,normalizeStreamText,displayNode,isCompletedToolPhase. -
apps/cli/src/commands/up.js- the--interactive/-ibranch, instead of callingrunTuiCommand, calls:The workflow-pick and input-prompt flow before launching the run remains in the CLI command (not in the TUI package) so the fuzzy-picker can still run pre-fullscreen. -
No changes to
apps/cli/src/hijack.js-packages/tuiimportsresolveHijackCandidate,buildHijackLaunchSpec, andlaunchHijackSessiondirectly from the CLI package via workspace import.
Open Questions
-
renderer.suspend()/renderer.resume()API: Does@opentui/core’screateCliRendererexpose these methods, or does HIJACK mode need to destroy and re-create the renderer after the native CLI exits? The TUI chat engineering doc references this API but it was removed in 0.20.2; need to confirm the current OpenTUI API surface. -
<diff>element: Is OpenTUI’s<diff>primitive available in the current@opentui/coreversion, or does the Diff tab need to render unified diff text inside a<scrollbox>manually? -
@microsoft/tui-testPTY driver: Does it support Bun-spawned processes, or does it require Node? If Bun subprocess PTY attachment is unsupported, the integration test may need to use a Node shim or switch to OpenTUI’s own test renderer API. -
Gateway autostart interface: The existing
ensureGatewayutility inapps/cli- is it a stable function export thatpackages/tuican import, or does it need to be moved to a shared package first? -
DevTools frame protocol completeness: Does the current
packages/protocol/src/devtoolsinclude a stableapplyDelta(snapshot, delta): DevToolsSnapshothelper, or doespackages/tuineed to implement the merge logic itself from theDevToolsDelta/DevToolsDeltaOptypes?