# Spec: runplan TUI — agent OS dashboard

Audience: AI coding agents first (Cursor implementer). Mockups are the contract — match them.

## Invocation + lifecycle

- `runplan --tui <slug>`: attach-or-start (plan w1.t1). Run already live → attach only. Journal exists idle → submit resume + attach. Fresh → submit run + attach.
- `runplan --tui` (no slug) → **launcher mode**: opens directly on F4 All Plans with no attached run. F1/F2/F3 tabs disabled (dimmed in tab bar; F-keys/Tab no-op with a one-line hint "select a plan first"); header shows `run: —` and status strips render placeholder dashes. The ONLY way out of F4 is `⏎` on a plan (attaches + jumps to F1, all tabs enable) or `q` (quit). Same attach semantics as F4 ⏎ — view-only, never launches.
- TUI is a **viewer/controller only**. It never orchestrates. Closing (q / Ctrl-C) detaches; run continues under daemon.
- Data: control-api direct — `GET /stream?runId=&snapshot=1` (SSE: summary/task/log/ratelimit frames), `GET /runs/:id/log?since=`, `GET /runs/:id/status`. Auth bearer `~/.harness/token`, port `~/.harness/control-api.port`. Control verbs: `POST /runs/:id/(pause|resume|kill)` + decision answers.
- Reconnect: SSE drop → banner `◌ reconnecting…` in header, exponential retry, `Last-Event-Id` replay. Never crash on gap; re-snapshot.

## Stack

Rust + ratatui + crossterm, cargo crate at `tui/` (repo root), binary `runplan-tui`. `bin/runplan --tui` execs the built binary (fail-closed: missing binary → error with build hint, never a degraded fallback). Rationale: immediate-mode fullscreen rendering (flicker-free at the 80ms spinner tick), native mouse events via crossterm (SGR 1006 handled by the library — the §Mouse escape-parsing paragraph collapses to crossterm event mapping), built-in tabs/tables/gauges/scrollbars + canvas widget for DAG edges, single static binary. TUI speaks ONLY HTTP/SSE to the control API (token from `~/.harness/token`, port from `~/.harness/control-api.port`) — zero coupling to harness JS internals. Build wired into gate0 (`cargo build --release` + `cargo clippy -- -D warnings` count as build warnings under the no-warn gate).

## Global layout (every tab)

```
row 1   tab bar + run identity + connection state
row 2   status strip: state, wave progress, tasks, cost/budget, restarts, decisions
row 3   rate-limit strip: per-provider gauge + state
mid     tab body (fills)
last    key bar (context-sensitive, always visible)
```

- Min terminal 80×24; degrade: drop rate-limit strip first, then compress status strip to one segment.
- Colors: green=done/ok, cyan=running (+ braille spinner), yellow=paused/waiting/sus, red=failed/quarantined/killed, dim=pending/skipped.
- **Animated spinner (REQUIRED):** every running task/wave glyph `⣾` is a live braille spinner cycling `⣾⣽⣻⢿⡿⣟⣯⣷` at ~80ms — in BOTH F1 tree rows and F2 DAG boxes (glyph inside the box border animates; running-box double-border stays static). Single shared ticker for all spinners (one interval, not one per task). Pause animation when run state is `paused`/`done`/`killed`. Run state badge: `● RUNNING` cyan, `⏸ PAUSED` yellow, `✔ DONE` green, `✖ FAILED` red, `◌ reconnecting` dim blinking.
- Active tab: inverse video. F-keys always work; also `1/2/3/4` and Tab/Shift-Tab cycle.

## F1 — Workflow view (default tab)

dyn-wf style: left tree of waves→tasks, right pane = focused agent live tail. Focus follows selection; auto-follows newest dispatch until user moves selection (then `FOLLOW OFF`, `f` re-enables).

```
┌ runplan ─────────────────────────────────────────────────────────────────────────────────────────┐
│ ▌F1 Workflow▐  F2 DAG  F3 Metadata  F4 Plans  run: pbr ● RUNNING   daemon ✓   ⏱ 00:42:17   ◉ live │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ wave 3/5   ████████████░░░░░░░░ 6/11 tasks   cost $4.82 / $12.00   restarts 0/3   decisions: none │
│ limits  anthropic ▂▄▂ ok   openai ▇▇▇ rate-limited · resume 04:12   cursor ▂▂▄ ok                 │
├───────────────────────────────────┬───────────────────────────────────────────────────────────────┤
│ WAVES                             │ AGENT TAIL  w3.t1 · coder · sonnet-5 · pid 41233 · 12:41 ⟳    │
│                                   │ ───────────────────────────────────────────────────────────── │
│ ✔ w1  scaffold           2/2 4m12 │ 19:02:11  gate0 running (typecheck)                           │
│ ✔ w2  state core         3/3 18m  │ 19:02:48  typecheck ✓ 0 errors                                │
│ ⣾ w3  resolver tier      1/3      │ 19:02:49  build started (turbo run build)                     │
│   ├─ ⣾ w3.t1 policy table   12:41 │ 19:03:15  ▸ @scope/journal:build cache hit                    │
│   ├─ ◌ w3.t2 llm decision  queued │ 19:04:02  ▸ @scope/runner:build compiling…                    │
│   └─ ◌ w3.t3 agent tier   blocked │ 19:05:44  ▸ warning: none                                     │
│ ◌ w4  notifications      0/2      │ 19:06:10  build ✓                                             │
│ ◌ w5  chaos suite        0/1      │ 19:06:11  test started (node --test)                          │
│                                   │ 19:07:02  ▸ 148 pass · 0 fail · 3 skip                        │
│ ▼ w3.t1  policy table             │ 19:07:03  gate0 ✓ GREEN                                       │
│   state gated · attempt 1         │ 19:07:04  review dispatch → reviewer.high                     │
│   seat coder.high → codex.sh      │ █                                                             │
│   branch task/pbr-w3t1            │                                                    FOLLOW ON  │
├───────────────────────────────────┴───────────────────────────────────────────────────────────────┤
│ ↑↓ select  ⏎ zoom tail  f follow  p pause dispatch  K kill run  a answer  1-4/F1-4 tabs  ? help  q │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```

- Tree rows: state glyph (✔ ⣾ ◌ ✖ ⊘quarantined ⏸), task id, short title, per-wave `done/total`, live elapsed on running tasks.
- Selection detail card (`▼` block) under tree: state, attempt, seat→wrapper binding, branch.
- `⏎` zoom: tail pane fullscreen (tree hidden), `Esc` back.
- Tail = `log` SSE frames filtered to selected task; scrollback PgUp/PgDn (paused auto-scroll until End).
- `decisions: none` turns `decisions: 1 PENDING ⚠` (yellow, blinking) when a HALT waits; `a` opens answer modal (choice list from pending decision, submits `POST .../decisions/:id`).

## F2 — DAG view

Wave-layered graph (waves = columns), box per task, edges = dependencies. Selected box inverse; same selection model as F1 (selection shared across tabs).

```
┌ runplan ───────────────────────────────────────────────────────────────────────────────────────────┐
│  F1 Workflow  ▌F2 DAG▐  F3 Metadata  F4 Plans  run: pbr ● RUNNING  daemon ✓   ⏱ 00:42:17    ◉ live │
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ wave 3/5   ████████████░░░░░░░░ 6/11 tasks   cost $4.82 / $12.00   restarts 0/3   decisions: none  │
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
│      w1              w2                  w3                  w4                w5                  │
│                                                                                                    │
│  ┌─────────┐     ┌─────────┐       ╔═════════╗                                                     │
│  │✔ w1.t1  │──┬──│✔ w2.t1  │──┬───▶║⣾ w3.t1  ║──────┐     ┌─────────┐                              │
│  │ scaffold│  │  │ journal │  │    ║ policy  ║      ├────▶│◌ w4.t1  │      ┌─────────┐             │
│  └─────────┘  │  └─────────┘  │    ╚═════════╝      │     │ notify  │─────▶│◌ w5.t1  │             │
│               │               │                     │     └─────────┘      │ chaos   │             │
│  ┌─────────┐  │  ┌─────────┐  │    ┌─────────┐      │                      └─────────┘             │
│  │✔ w1.t2  │──┘  │✔ w2.t2  │──┼───▶│◌ w3.t2  │──────┤     ┌─────────┐          ▲                   │
│  │ liveness│     │ locks   │  │    │ llm-dec │      ├────▶│◌ w4.t2  │──────────┘                   │
│  └─────────┘     └─────────┘  │    └─────────┘      │     │ digest  │                              │
│                               │                     │     └─────────┘                              │
│                  ┌─────────┐  │    ┌─────────┐      │                                              │
│                  │✔ w2.t3  │──┴───▶│◌ w3.t3  │──────┘                                              │
│                  │ liveness│       │ agent-t │                                                     │
│                  └─────────┘       └─────────┘                                                     │
│                                                                                                    │
│ ✔ done   ⣾ running   ◌ pending   ✖ failed   ⊘ quarantined   ⏸ paused      critical path ══▶        │
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ w3.t1 policy table · gated · attempt 1 · coder.high→codex · dur 12:41 · blocks 3 downstream        │
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ←↑↓→ navigate  ⏎ open in Workflow  p pause  K kill  1-4/F1-4 tabs  ? help  q detach                │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```

- Layout: layered by wave (no generic graph layout lib needed — plan is wave-structured). Edges drawn with box chars; crossing minimized by task order within column. Horizontal scroll when > width.
- Critical path (longest incomplete chain) drawn double-line `═▶`; running-task box double-border.
- Footer line = selected task summary incl. downstream-blocked count.
- `⏎` jumps to F1 with same task selected + tail focused.

## F3 — Metadata view

All run metadata, grouped, scrollable. Read-only.

```
┌ runplan ──────────────────────────────────────────────────────────────────────────────────────────┐
│  F1 Workflow  F2 DAG  ▌F3 Metadata▐  F4 Plans  run: pbr ● RUNNING  daemon ✓   ⏱ 00:42:17   ◉ live │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ RUN                                          BINDINGS (resolved)                                  │
│  slug          pbr                            coder.high     codex.sh · gpt-5.4 · t/o 20m         │
│  plan          docs/plans/pbr.md              coder.low      cursor.sh · composer · t/o 10m       │
│  preset        codex (preset/v1)              reviewer.high  ca.sh · sonnet-5 · fb → rev.medium   │
│  started       2026-07-12 18:21:04            fixer          codex.sh · gpt-5.4 · fb → ca.sh      │
│  elapsed       00:42:17                       resolver       — not in preset → fixer.high         │
│  journal       runstate/pbr.db (v2)                                                               │
│  run log       runstate/pbr.log.jsonl        TIMEOUTS                                             │
│  daemon        pid 3811 · tick 8s ago         git 5m · provision 15m · gate 3h                    │
│  runner        pid 41102 · hb 4s ago          gate p95 11m · sus at 22m · kill at 3h              │
│                                                                                                   │
│ BUDGET                                       RECOVERY                                             │
│  cost          $4.82 / $12.00 (40%)           restarts       0 / 3                                │
│  resolver cap  $0.00 / $5.00                  last reconcile 18:21:05 (2 demoted, 1 swept)        │
│  tokens        1.24M in · 210K out            quarantined    none                                 │
│                                               fingerprints   none                                 │
│ DECISIONS                                                                                         │
│  none pending · 1 answered (d-03 env-repair ✓ auto, resolver-agent, $0.41)                        │
│                                                                                                   │
│ WARNIGNORE Δ  none        NOTIFY  ntfy ✓ (2 sent)        SCOPE GUARD  active · 0 violations       │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ↑↓ scroll  c copy journal path  p pause  K kill  1-4/F1-4 tabs  ? help  q detach                  │
└───────────────────────────────────────────────────────────────────────────────────────────────────┘
```

## F4 — All Plans view

Fleet table — same data as the web UI front page (`GET /runs` = `listSummaries`), every known run on this machine. Row selection independent of F1-F3 task selection.

```
┌ runplan ─────────────────────────────────────────────────────────────────────────────────────────┐
│  F1 Workflow  F2 DAG  F3 Metadata  ▌F4 Plans▐  run: pbr ● RUNNING   daemon ✓   ⏱ 00:42:17  ◉ live │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ status: [● all] running paused blocked done failed    scope: [this project] all    sort: [active] │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│    STATUS      PLAN                          PROJECT            PROGRESS         STARTED   ACTIVE │
│  ▸ ⣾ running   autonomy-p4-babysitter-and-…  mega-plan-harness  ███░░░░░░  3/17  Jul12 19:10   4s │
│    ⏸ paused    commerce-checkout-v2          shop               ████░░░░░  3/9   Jul12 14:02   2h │
│    ⚠ blocked   grok-wrapper                  mega-plan-harness  ████████░  8/9   Jul10 09:15   1d │
│    ✔ done      autonomy-p3-daemon            mega-plan-harness  █████████ 10/10  Jul08 08:00   2d │
│    ✔ done      autonomy-p2b-resolver-watch…  mega-plan-harness  █████████ 10/10  Jul08 06:11   2d │
│    ✖ failed    preset-tmpfile-cleanup        mega-plan-harness  ██░░░░░░░  1/4   Jul02 11:40   9d │
│                                                                                                   │
│                                                                                                   │
│ 6 plans · 1 running · 1 blocked ⚠ · 1 paused                                                      │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ↑↓ select  ⏎ open in Workflow  s status  o scope  t sort  r refresh  1-4/F1-4 tabs  ? help  q     │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```

- Columns: status (glyph + word, colored per global palette; running rows use the shared animated spinner), plan slug (ellipsized), project (repo basename), progress bar + `done/total`, started, last active (humanized, live).
- `blocked` = pending decision (⚠ yellow); footer aggregates counts.
- Filters/sort cycle in place: `s` status (all→running→paused→blocked→done→failed), `o` scope (this project ↔ all; "this project" = summaries whose repo == cwd repoRoot), `t` sort (active↔a-z↔status). Active choice shown `[bracketed]` in the filter bar.
- `⏎` on a row: switch the TUI's attached run — re-subscribe SSE with that runId (+snapshot), jump to F1. Header run identity + status strips follow the newly attached run. Attaching NEVER launches or resumes anything.
- `S` on a row: Start/Resume. Active paused run resumes dispatch; active running run is an idempotent no-op; every idle known run submits its persisted plan through daemon.
- `R` on a row: Restart. Confirm with `Restart will kill all active agents and restart runplan.` Then kill runner + every active task process group and resubmit persisted plan. No live process is not an error; zombie run starts.
- Data: `GET /runs` poll 5s + live SSE `summary` frames patching rows in place. Row of the currently attached run marked `▸`.
- Mouse (when enabled): click row = select, double-click = open, click filter/sort tokens = cycle that control.

## Controls (global, all tabs)

| Key | Action | Semantics |
|---|---|---|
| `p` | Pause dispatch | `POST /runs/:id/pause`. Running agents FINISH; no new lease. Badge → `⏸ PAUSED (draining n)` then `⏸ PAUSED`. `p` again = resume. NO confirm (reversible). |
| `K` (shift) | Kill run | Confirm modal (below). `POST /runs/:id/kill` → SIGTERM→SIGKILL all agent pgids + runner. Irreversible for in-flight work. |
| `S` (shift, F4) | Start/Resume | `POST /runs/:id/start`. Resume paused run; submit idle run; active run is idempotent. |
| `R` (shift, F4) | Restart | Confirm, then `POST /runs/:id/restart`. Kill active agents + runner before resubmit; zombie run starts. |
| `a` | Answer decision | Only when pending decision exists. |
| `f` | Toggle follow | F1 only. |
| `q` / Ctrl-C | Detach | Run keeps going. No confirm. |
| `?` | Help overlay | Full keymap. |

Kill confirm modal (centered, dims background):

```
                    ┌─ KILL RUN ────────────────────────────────┐
                    │                                           │
                    │  Kill run pbr — 2 agents running.         │
                    │  In-flight task work is lost (committed   │
                    │  tasks are safe; resume possible later).  │
                    │                                           │
                    │        [ y  kill ]     [ n  cancel ]      │
                    └───────────────────────────────────────────┘
```

## Mouse support (optional input layer — keyboard stays primary)

- Enable SGR 1006 mouse reporting (`\x1b[?1006h` + `?1002h`) on start; restore on exit/detach (ALWAYS, incl. crash path — wrap in exit handler).
- Hit-testing against the deterministic layout: click tab label → switch tab; click F1 tree row → select task; click F2 DAG box → select task; double-click row/box → Enter semantics (zoom / open in Workflow); wheel over tail pane → scrollback; wheel over tree/DAG → move selection / horizontal DAG scroll.
- Control zones (`p`/`K`/`a` labels in key bar) are clickable; kill still goes through the confirm modal.
- MUST degrade silently when the terminal/tmux/SSH doesn't report mouse — no error, no feature loss: every mouse action has a keyboard equivalent (NEVER a mouse-only affordance).
- Do not intercept when unsupported; note in help overlay: hold Shift for native terminal text selection while mouse mode is on.
- Implementation: crossterm `EnableMouseCapture` + event stream (SGR 1006 handled by crossterm); hit-map rebuilt on every layout render.

## Data contract (depends plan w3.t3)

| UI element | Source |
|---|---|
| task states, waves, cost, decisions | SSE `summary` frames (`buildSummaryPayload`) |
| agent tail | SSE `log` frames + `GET /runs/:id/log?since=` backfill |
| rate-limit strip | SSE `ratelimit` frames |
| heartbeat/daemon ages (F3) | `GET /runs/:id/status` poll 10s |
| pause/kill/answer | control verbs |
| F4 all-plans table | `GET /runs` poll 5s + SSE `summary` frames |

## Acceptance

- [ ] Attach to live run <1s to first frame; detach leaves run alive (verify pid).
- [ ] Pause: zero new dispatches while agents drain; resume re-leases.
- [ ] Kill: all agent pgids dead <5s; TUI shows `✖ KILLED`; re-run offers resume.
- [ ] SSE cut mid-run → `◌ reconnecting` → full recovery via replay/snapshot, no stale frames.
- [ ] All four tabs render at 80×24 (degraded) and 120×40 (full) without wrap artifacts.
- [ ] F4: filters/sort/scope cycle correctly; ⏎ re-attaches to selected run and lands on F1 with its data; attaching to a finished run never launches anything.
- [ ] Launcher mode (`runplan --tui`, no slug): opens on F4, F1-F3 unreachable until a plan is chosen, `q` quits; choosing a plan enables all tabs.
- [ ] Pending decision visible within one frame on every tab (status strip), answerable via `a`.
- [ ] No control action fires without token auth; kill requires modal confirm.
