# TUI dynwf-style UX Redesign Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rebuild the runplan TUI as a dynwf-style drill-down (Plans → Run → Wave → Agent card → pagers) with per-agent observability (prompt/activity/outcome), queued steer, and the engine/control-API plumbing that feeds it.

**Architecture:** Two chains. Engine chain (Node): surface data already on disk — per-dispatch agent log via `HARNESS_TRANSCRIPT_PATH` (all 3 wrappers already honor it), per-task usage/attempt from journal, new activity/prompt/transcript endpoints, steer inbox mirroring the decisions-inbox pattern. TUI chain (Rust/ratatui): navigation stack + view rework per approved spec. Chains run in parallel; Rust codes against contract-pinned JSON shapes with fixtures.

**Tech Stack:** Node (src/runner.js, src/control-api.js, node:test), Rust ratatui (tui/src/*, cargo test).

**Spec (source of truth for all screens/keys):** `docs/specs/2026-07-16-tui-dynwf-ux-redesign.md` — APPROVED 2026-07-16. Every render task MUST match its mockup section exactly (S1, S1a, S2, S2a–d, S3–S6, keymap table).

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | E1, E2, R1 | src/runner.js · src/control-api.js · tui/src/{interaction,main,lib}.rs | ✅ no overlap |
| 2 | E3, R2 | src/control-api.js · tui/src/{control,state}.rs | ✅ no overlap |
| 3 | E4, R3 | src/agent-activity.js + src/control-api.js · tui/src/{lib,main}.rs | ✅ no overlap |
| 4 | E5, R4 | src/steer-inbox.js + src/runner.js + src/control-api.js · tui/src/{lib,main,interaction}.rs + tui/tests/pty.rs | ✅ no overlap |
| 5 | R5 | tui/src/pager.rs + tui/src/{main,interaction}.rs | single task |
| 6 | R6 | tui/src/{main,interaction,lib}.rs | single task |
| 7 | R7 | tui/src/{dag,main,interaction}.rs | single task |
| 8 | R8 | tui/src/{main,interaction,lib,control,state}.rs · tui/tests/{app_state,state,pty}.rs | single task |
| 9 | R9 | tui/src/{plans,main}.rs | single task |
| 10 | V1 | none (verification) | single task |

Decision-enumeration pass: no `gated` records. No irreversible ops (all changes additive, land mode established merge-to-main per project memory), no unresolved forks (spec approved with decisions locked), no external input needed.

## File Structure

- `src/runner.js` — dispatch env + journal record enrichment; steer drain in prompt builder (modify only).
- `src/control-api.js` — summary enrichment + new task endpoints (modify only).
- `src/agent-activity.js` — NEW: stream-json agent-log parser (pure, testable).
- `src/steer-inbox.js` — NEW: steer queue over run journal, mirrors `src/inbox.js` decision pattern.
- `tui/src/interaction.rs` — tab enum, nav stack, new actions.
- `tui/src/control.rs` + `tui/src/state.rs` — typed client for new payloads.
- `tui/src/lib.rs` — projections (waves, task rows, card, events, plans ladder).
- `tui/src/main.rs` — renderers per spec screens.
- `tui/src/pager.rs` — NEW: shared full-screen pager.
- `tui/src/dag.rs` — node selection + critical path.
- `tui/src/plans.rs` — detail-column ladder.

---

### Task E1: Per-dispatch agent log + prompt in journal

**Wave:** 1 · **Blocks:** E3, E4, E5 · **Blocked by:** —

**Files:**
- Modify: `src/runner.js` — `runWrapper` env (dispatch site ~1805-1863) + `dispatch.start` journal append
- Test: `src/test/` (follow existing runner test file for dispatch records)

**Contract:**
- Before spawning a wrapper, runner sets env `HARNESS_TRANSCRIPT_PATH = <repoRoot>/runstate/agent-logs/<slug>/<taskId>.a<attempt>.jsonl` scoped to the wrapper subprocess only, and `fs.mkdirSync(dirname, {recursive:true})` first (fail-closed: mkdir error aborts dispatch with a wrapped error naming the path). Wrappers already tee to this env var (`wrappers/na.sh:118`, `ca.sh:79`, `pi.sh:149`) — do NOT modify wrappers.
- `dispatch.start` journal record gains two fields: `agentLog` (the path above) and `prompt` (full prompt string passed to the wrapper; flows through existing `redactJournalValue`).
- `attempt` = 1-based count of `dispatch.start` records for this task in this run (existing retry loop indexes it).

**Behavior:** path is deterministic per (task, attempt) so later attempts never clobber earlier logs; no behavior change for wrappers or gates; `runstate/agent-logs/` sits inside existing runstate dir (already git-ignored — verify, add ignore entry if not).

**Acceptance:**
- Run: `node --test src/test/ 2>&1 | tail -5`
- Expected: PASS incl. new test asserting a dispatched task's `dispatch.start` record carries `agentLog` matching the pattern and `prompt` non-empty, and wrapper env contained `HARNESS_TRANSCRIPT_PATH`.

- [ ] Write failing test (fixture plan dispatch → inspect journal record + captured env)
- [ ] Implement env + mkdir + record fields
- [ ] Run acceptance → PASS
- [ ] Commit: `git add src/runner.js src/test/<file> && git commit -m "feat: record agent log path and prompt per dispatch"`

### Task E2: Fleet summary — lastEvent + pendingDecisions

**Wave:** 1 · **Blocks:** R2 · **Blocked by:** —

**Files:**
- Modify: `src/control-api.js` — `makeRunSummary` (:274) + summary payload builder
- Test: `src/test/control-api.test.js`

**Contract:** `RunSummary` JSON gains:
- `lastEvent: { ts: string, kind: string, task: string|null, text: string } | null` — humanized last line of `record.logPath` (`readRunLog` tail). Humanize map (pin verbatim, fallback `<kind> <task?>`):
  - `gate0.start` → `gate0 <task> running…`
  - `dispatch.start` → `dispatch <task> <adapter>…`
  - `dispatch.done` → `dispatch <task> done rc=<rc>`
  - `review.passed` → `review <task> passed`
  - `task.state` → `<task> → <state>`
  - `ratelimit.wait` → `ratelimit <provider> <duration>`
  - `run.start|resumed|restarted|done|error|killed` → `run <suffix>`
- `pendingDecisions: number` — count of `decision.requested` events without matching `decision.answered` in the run journal. MUST work for both journal backends (`.jsonl` and `.db`); count from events, do not require the sqlite inbox handle.

**Behavior:** both fields computed in `makeRunSummary` so `GET /runs`, `GET /runs/:id`, and SSE `summary` frames all carry them with no extra fetch; unreadable log/journal → `lastEvent: null` / `pendingDecisions: 0` (never throw).

**Acceptance:**
- Run: `node --test src/test/control-api.test.js 2>&1 | tail -5`
- Expected: PASS incl. new tests: fixture run log tail → exact humanized string; journal with 2 requested + 1 answered → `pendingDecisions === 1`.

- [ ] Write failing tests
- [ ] Implement
- [ ] Run acceptance → PASS
- [ ] Commit: `git add src/control-api.js src/test/control-api.test.js && git commit -m "feat: last event and pending decisions in run summary"`

### Task R1: Nav stack, 4-view tab bar, inspector overlay

**Wave:** 1 · **Blocks:** R3–R9 · **Blocked by:** —

**Files:**
- Modify: `tui/src/interaction.rs` — tab enum, screen stack, actions
- Modify: `tui/src/main.rs` — tab bar, esc routing, inspector + help overlays
- Modify: `tui/src/lib.rs` — AppState screen-stack field + transitions

**Contract:**
- Tab enum: `{Workflow, Dag, Metadata, Plans}` → `Run, Dag, Events, Plans` (labels `1 Run · 2 DAG · 3 Events · 4 Plans`; F1–F4 and digits keep working). `Events` renders the existing tail view until R8 replaces it.
- New screen stack on AppState: `Screen::RunOverview | Screen::TaskCard { task_id: String } | Screen::Pager { source: PagerSource, task_id: String }` with `push/pop`; `Esc` pops; empty stack on Run tab → existing quit/detach behavior unchanged.
- `i` opens Inspector overlay (any attached view): renders the CURRENT Metadata-tab content (registry/status dump) in a centered overlay; `Esc`/`i` closes. Metadata tab itself REMOVED.
- `?` help overlay updated: per-view keymap from spec keymap table + glyph legend line `○ PENDING ◌ BLOCKED ⠋ WORKING ◐ WRITTEN ◑ REVIEWED ✓ COMMITTED ✗ ERROR ⚠ QUARANTINED`.
- Freed key: `s` no longer toggles detail sidebar (reserved for steer, R6).

**Behavior:** all existing lifecycle keys (`^S ^P ^K ^R`), plans keys, mouse hit-regions unchanged; TaskCard/Pager screens render placeholder text until R4/R5 fill them.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. tests: tab cycle order Run→DAG→Events→Plans; Esc pops pushed screen before quitting; `i` toggles inspector.

- [ ] Write failing interaction tests
- [ ] Implement enum + stack + overlays
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: tui nav stack, four-view tabs, inspector overlay"`

### Task E3: Per-task stats in run/task payloads

**Wave:** 2 · **Blocks:** R2 · **Blocked by:** E1

**Files:**
- Modify: `src/control-api.js` — `GET /runs/:id` task array + `GET /tasks/:id` (:1110-1134)
- Test: `src/test/control-api.test.js`

**Contract:** each task entry in both payloads gains:
- `usage: { totalTokens: number, inputTokens?: number, outputTokens?: number } | null` — cumulative sum of that task's `task.usage` journal events.
- `attempt: number` — count of `dispatch.start` records for the task (0 if never dispatched).
- `agentLog: string | null` — from latest `dispatch.start.agentLog` (E1).
- `toolCalls: number | null` and `idleSeconds: number | null` — from the agentLog file when it exists: `toolCalls` = tool-call event count via `countToolCalls` exported by E4's parser module (wave 3 lands before TUI consumes it — until then return `null`, field present); `idleSeconds` = now − file mtime.

**Behavior:** missing/unreadable journal or log → nulls, never 5xx; fields present (null) even when empty so the Rust deserializer is total.

**Acceptance:**
- Run: `node --test src/test/control-api.test.js 2>&1 | tail -5`
- Expected: PASS incl. test: fixture journal with two `task.usage` events (300+700) → `usage.totalTokens === 1000`, `attempt === 2`.

- [ ] Write failing tests
- [ ] Implement aggregation
- [ ] Run acceptance → PASS
- [ ] Commit: `git add src/control-api.js src/test/control-api.test.js && git commit -m "feat: per-task usage, attempt, agent log in task payloads"`

### Task R2: Typed client for new payloads + endpoints

**Wave:** 2 · **Blocks:** R3–R6 · **Blocked by:** E2

**Files:**
- Modify: `tui/src/control.rs` — new fetch/post methods (typed-HTTP pattern from phase-2 design)
- Modify: `tui/src/state.rs` — new typed structs
- Modify: `tui/src/lib.rs` — `RunSummary` fields

**Contract:**
- `RunSummary` gains `#[serde(rename="lastEvent", default)] last_event: Option<LastEvent>` (`LastEvent { ts, kind, task: Option<String>, text }`) and `#[serde(rename="pendingDecisions", default)] pending_decisions: u32`.
- `TaskStats { usage_total: Option<u64>, attempt: u32, tool_calls: Option<u32>, idle_seconds: Option<u64>, agent_log: Option<String> }` parsed from E3 payload (serde aliases matching E3 field names exactly: `usage.totalTokens`, `attempt`, `toolCalls`, `idleSeconds`, `agentLog`).
- Client methods (same auth/error handling as existing calls): `fetch_task_activity(task_id, since) -> ActivityPage { entries: Vec<ActivityEntry { seq, ts, label }>, next_since, total }`; `fetch_task_prompt(task_id) -> TaskPrompt { prompt, attempt }`; `fetch_task_transcript(task_id) -> TaskTranscript { stdout: Option<String>, stderr: Option<String>, reply: Option<serde_json::Value>, attempt }`; `post_steer(task_id, text, restart: bool) -> SteerReply { ok: bool, id: Option<String>, queued: bool, restart: bool }` (matches E5 reply incl. pure-retry `id: null, queued: false`).
- Unknown extra JSON fields ignored (existing policy).

**Behavior:** methods are pure client plumbing tested against fixture JSON strings matching E3/E4/E5 contracts — no live server needed.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. deserialization tests for each fixture payload.

- [ ] Write failing deserialization tests (fixture JSON per contract)
- [ ] Implement structs + methods
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: typed client for task stats, activity, prompt, transcript, steer"`

### Task E4: Activity parser + activity/prompt/transcript endpoints

**Wave:** 3 · **Blocks:** E5 · **Blocked by:** E1

**Files:**
- Create: `src/agent-activity.js` — pure stream-json log parser
- Modify: `src/control-api.js` — three new GET routes
- Test: `src/test/agent-activity.test.js`, `src/test/control-api.test.js`

**Contract:**
- `parseAgentActivity(logPath, { since = 0 }) -> { entries: [{ seq: number, ts: string|null, label: string }], nextSince: number, total: number }` and `countToolCalls(logPath) -> number|null`. Handles all three adapter stream formats (claude/north stream-json `type:"assistant"` with `tool_use` blocks; cursor-agent and pi line formats — read one real log from `tmp/logs/` per adapter to pin shapes, add trimmed lines as test fixtures under `test/fixtures/agent-logs/`). Label = `<Tool>(<primary arg>)` truncated to 120 chars, matching spec S2 Activity lines. Unparseable line → skipped, counted in `total` only; never throws on partial/truncated tail (agent still writing).
- Routes (auth + 404 handling identical to existing `/tasks/:id`):
  - `GET /tasks/:id/activity?since=N` → `{ taskId, ...parseAgentActivity(agentLog, {since}) }`; task without agentLog → `{ taskId, entries: [], nextSince: 0, total: 0 }`.
  - `GET /tasks/:id/prompt` → `{ taskId, attempt, prompt }` from latest `dispatch.start` (E1); none → 404.
  - `GET /tasks/:id/transcript` → `{ taskId, attempt, stdout, stderr, reply }` from latest journal `dispatch` record's embedded transcript (`extractWrapperTranscript` shape, `src/runner.js:3732`); none → 404.

**Behavior:** all reads are point-in-time file reads — no watchers, no state; `since` cursoring makes TUI polling cheap.

**Acceptance:**
- Run: `node --test src/test/agent-activity.test.js src/test/control-api.test.js 2>&1 | tail -5`
- Expected: PASS incl. per-adapter fixture → exact expected labels; `since` returns only newer entries.

- [ ] Capture one real log per adapter from `tmp/logs/`, trim to fixtures
- [ ] Write failing parser tests, implement parser
- [ ] Write failing route tests, implement routes
- [ ] Run acceptance → PASS
- [ ] Commit: `git add src/agent-activity.js src/control-api.js src/test/agent-activity.test.js src/test/control-api.test.js test/fixtures/agent-logs && git commit -m "feat: agent activity parser and task observability endpoints"`

### Task R3: Run overview — waves sidebar, two-line rows, events strip

**Wave:** 3 · **Blocks:** R4 · **Blocked by:** R1, R2

**Files:**
- Modify: `tui/src/lib.rs` — replace `WorkflowProjection` with waves/rows/events projection
- Modify: `tui/src/main.rs` — render per spec S1/S1a

**Contract:**
- Projection: `RunOverviewProjection { header: HeaderProjection, banner: Option<Banner>, waves: Vec<WaveRow { label, done, total, glyph, selected }>, tasks: Vec<TaskRow { task_id, line1, line2, selected }>, events: Vec<String> /* ≤3 */, focus: Focus::{Waves,Tasks} }`.
- Render EXACTLY per spec S1: two-line task rows (line 1: glyph, id, human adapter name + model, state, right-aligned elapsed; line 2 dim: stats or `waits <deps>`), waves sidebar with worst-state glyph + done/total, events strip (toggle `e`), footer per spec.
- Adapter display mapping (from spec, NEVER show `north`): `north→claude, cursor→cursor, pi→pi, codex→codex`.
- Banner ladder: pending decision (`pending_decisions > 0`) → `⚠ DECISION REQUIRED …` else ratelimit line (existing) else none.
- Empty states verbatim from spec S1a: unstarted wave line `Not started — blocked on <wave> (<ids> remaining)`; unattached → mini-launcher.
- `Tab` toggles waves↔tasks focus; `Enter` on task pushes `Screen::TaskCard`.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. projection tests: fixture run → exact line1/line2 strings for WORKING (tokens·calls·idle) and BLOCKED (`waits R3.6 R3.7`) rows; decision banner wins over ratelimit.

- [ ] Write failing projection tests (exact strings from spec mockup)
- [ ] Implement projection + render
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: run overview with waves sidebar and two-line task rows"`

### Task E5: Steer inbox + steer/restart endpoint + prompt drain

**Wave:** 4 · **Blocks:** R6 · **Blocked by:** E1, E4

**Files:**
- Create: `src/steer-inbox.js` — steer queue over run journal (mirror `src/inbox.js` structure)
- Modify: `src/runner.js` — drain into `buildTaskPrompt` (:4218) + task-restart handling
- Modify: `src/control-api.js` — `POST /tasks/:id/steer`
- Test: `src/test/steer-inbox.test.js`, `src/test/control-api.test.js`

**Contract:**
- `src/steer-inbox.js`: `requestSteer(journal, { task, text, by }) -> { id, seq }` appends `steer.requested { id: "s-<seq>", task, text, by, ts }`; `pendingSteers(journal, taskId) -> [...]` (requested minus consumed); `consumeSteers(journal, taskId, attempt)` appends one `steer.consumed { id, task, attempt }` per drained entry. Same BEGIN IMMEDIATE/rollback discipline as `inbox.js`; works on both journal backends.
- Runner: for modes `implement` and `fix`, `buildTaskPrompt` output appends, when pending steers exist:
  `Operator steer (read before anything else):\n- <text>\n- <text>` — drained (consumed) at dispatch time, journaled BEFORE the wrapper spawns (fail-closed: consume-write failure aborts dispatch).
- Route `POST /tasks/:id/steer` body `{ text?: string, restart?: boolean }`:
  - Non-empty `text` → queue via `requestSteer`; reply 200 `{ ok: true, id, queued: true, restart: <bool> }`.
  - Empty/whitespace `text` with `restart: true` → pure retry, nothing queued; reply 200 `{ ok: true, id: null, queued: false, restart: true }`. Empty text with `restart: false` → 400 `{ error: "invalid-steer" }`.
  - `restart: true` semantics by task state: WORKING → engine SIGTERMs that task's running wrapper child; retry loop treats this marker (`steer-restart`) as immediate redispatch with SAME binding and mode, fresh prompt (drains queued steers). QUARANTINED/ERROR → clear quarantine and re-enqueue via the existing retry path (preserved-branch rules apply). Any other state (PENDING/BLOCKED/COMMITTED) → 409 `{ error: "task-not-restartable" }` (queued steer stays queued).
  - This route serves the autonomous plane — distinct from the supervised-only `/runs/:id/steer` verb, which stays untouched.

**Behavior:** steer never mutates a mid-flight agent; delivery is exactly the spec's queued semantics. Restart preserves the task branch per existing preserved-branch retry rules (do not bypass them).

**Acceptance:**
- Run: `node --test src/test/steer-inbox.test.js src/test/control-api.test.js 2>&1 | tail -5`
- Expected: PASS incl.: queued steer appears verbatim in next built prompt then never again; restart on non-running task → 409.

- [ ] Write failing inbox tests, implement module
- [ ] Write failing prompt-drain test, wire runner
- [ ] Write failing route tests (incl. restart paths), wire route + SIGTERM redispatch
- [ ] Run acceptance → PASS
- [ ] Commit: `git add src/steer-inbox.js src/runner.js src/control-api.js src/test/steer-inbox.test.js src/test/control-api.test.js && git commit -m "feat: queued steer inbox with optional task restart"`

### Task R4: Task/agent card

**Wave:** 4 · **Blocks:** R5, R6 · **Blocked by:** R3

**Files:**
- Modify: `tui/src/lib.rs` — `TaskCardProjection`
- Modify: `tui/src/main.rs` — render S2/S2a/S2b
- Modify: `tui/src/interaction.rs` — card keys `p a t g r` + `↑↓` sibling nav
- Modify: `tui/tests/pty.rs` — card-screen assertion tracks real card header (`p expand`), placeholder `TASK CARD` removed by this task

> **Deviation record (R4 review, 2026-07-16):** commit `4d3cac1` edited `tui/tests/pty.rs` before it was in this Files list — placeholder text removal broke pty assertion, edit required to keep suite green. Plan amended post-hoc to own the file here; no other task claims `tui/tests/`.

**Contract:**
- `TaskCardProjection { sidebar: Vec<TaskRow /* one-line compact */>, status_line, stats_line, branch_line, deps_line, prompt_preview: (first 2 lines, total), activity_preview: Vec<String> /* last 3 */, gate_line, review_line, outcome: Vec<String>, failure: Option<FailureBlock { lines, attempts_line }> }`.
- Render per spec S2 (running), S2a (completed: land result + real outcome), S2b (failed: `Failure` block + `Attempts` line lead). Data: card refreshes activity via `fetch_task_activity` poll (2s while WORKING, on-open otherwise); prompt/transcript lazy on first key use.
- `↑↓` moves selection in sidebar and retargets card. `r` opens confirm overlay `Retry <task>? y/n`; `y` → `post_steer(task_id, "", true)` (E5 pure-retry path, no steer queued). Engine replies 409 (`task-not-restartable`) when the state does not allow it → TUI shows the server `error` string verbatim as a toast. `r` never touches the run-level `^R` restart.

**Behavior:** unknown/missing data renders `—`, never blank panels; footer per spec S2.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. projection tests for the three states with exact section headers (`Prompt · N lines · p expand`, `Activity · last 3 of N · a all`, `Outcome`).

- [ ] Write failing projection tests (3 states)
- [ ] Implement projection + render + keys
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src tui/tests/pty.rs && git commit -m "feat: task agent card with prompt, activity, outcome sections"`

### Task R5: Shared pager (prompt/activity/transcript/gate)

**Wave:** 5 · **Blocks:** — · **Blocked by:** R4

**Files:**
- Create: `tui/src/pager.rs` — pager state + projection (scroll, follow, search)
- Modify: `tui/src/main.rs`, `tui/src/interaction.rs` — `Screen::Pager` wiring

**Contract:**
- `PagerState::new(title, source: PagerSource::{Prompt,Activity,Transcript,Gate}, lines)` with: `↑↓/PgUp/PgDn` scroll, `End` follow-tail toggle, `/` incremental search + `n/N`, `Esc` pop. Header `"<task> ▸ <source>  line X/Y · follow ✓|—"` per spec S2d.
- Activity source polls `fetch_task_activity(since)` every 2s while task WORKING and follow on; others static.

**Behavior:** pager is the ONLY long-text surface — card previews stay fixed-height.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. search jumps to match line; follow sticks to appended lines.

- [ ] Write failing pager tests
- [ ] Implement + wire
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: shared pager for prompt, activity, transcript, gate"`

### Task R6: Steer composer overlay

**Wave:** 6 · **Blocks:** — · **Blocked by:** E5, R4

**Files:**
- Modify: `tui/src/interaction.rs`, `tui/src/main.rs`, `tui/src/lib.rs`

**Contract:**
- `s` (Run overview on selected task, or Task card) opens overlay per spec S2c: delivery-semantics header verbatim from spec, single input line (grows to 3), `Enter` → `post_steer(task, text, false)`, `Ctrl+Enter` → `post_steer(task, text, true)`, `Esc` cancel.
- Success → toast `steer queued (<id>)` / `steer queued + restarting <task>`; API error → toast with server `error` string verbatim; overlay stays open on error.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. keystroke test: typed text posted with correct restart flag per key.

- [ ] Write failing interaction tests
- [ ] Implement overlay + toasts
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: steer composer overlay"`

### Task R7: DAG node navigation

**Wave:** 7 · **Blocks:** — · **Blocked by:** R4

**Files:**
- Modify: `tui/src/dag.rs` — selection model + critical path
- Modify: `tui/src/main.rs`, `tui/src/interaction.rs`

**Contract:**
- `←↑↓→` move node selection (nearest node in that direction; viewport auto-scrolls to keep selection visible — replaces scroll-only `←→`), `Enter` pushes `Screen::TaskCard{node.id}`, `c` toggles critical-path highlight (longest dependency chain through incomplete tasks), `z` keeps existing zoom.
- Bottom strip per spec S3: selected node's `glyph id · state · tokens · idle` + `deps <ids>`.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. direction-move picks geometrically nearest node on fixture layout; critical path of fixture DAG matches hand-computed chain.

- [ ] Write failing dag tests
- [ ] Implement selection + critical path + strip
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: navigable dag with critical path"`

### Task R8: Events view

**Wave:** 8 · **Blocks:** — · **Blocked by:** R4

**Files:**
- Modify: `tui/src/main.rs`, `tui/src/interaction.rs`, `tui/src/lib.rs`
- Modify: `tui/src/control.rs`, `tui/src/state.rs` — `ts`/`kind` fields on `LogEntry`/`LogStreamEvent` + tail line format `ts\tkind\ttask\ttext` (spec S4 wire fields; R2 landed without them, R8 owns this delta)
- Test: `tui/tests/app_state.rs`, `tui/tests/state.rs`, `tui/tests/pty.rs` — adapt struct literals + screen strings to events view

**Contract:**
- View 3 renders run log per spec S4: `HH:MM:SS  <kind padded>  <task padded>  <detail>`; `f` cycles filter `all → task → gate → dispatch → ratelimit → health → decision` (kind-prefix match); `End` follow; `Enter` on a line with a task pushes its TaskCard; empty state verbatim `No events yet — run has not started. 4 Plans to start one.`

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. filter cycle order + gate filter shows only `gate*` kinds.

- [ ] Write failing tests
- [ ] Implement
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src tui/tests && git commit -m "feat: filterable events view"`

### Task R9: Plans detail-column ladder + empty states

**Wave:** 9 · **Blocks:** — · **Blocked by:** R2

**Files:**
- Modify: `tui/src/plans.rs`, `tui/src/main.rs`

**Contract:** detail column per spec S5 ladder — stop at first rung: 1) `pending_decisions > 0` → `⚠ decision: <task> <summary>` (task/summary from newest pending decision if present in payload else `⚠ <n> decisions pending`); 2) RUNNING → `last_event.text` truncated to column; 3) PAUSED → `paused by user · <ago>`; 4) STALLED → `quarantined <task>: <failClass>` (from registry json when present, else `stalled`); 5) READY → gate reason or `—`; 6) DONE → `merged → main`. Empty state verbatim: `No plans found in docs/plans/. Create one: /plan → docs/plans/<slug>.jsonl`.

**Acceptance:**
- Run: `cargo test --manifest-path tui/Cargo.toml 2>&1 | tail -5`
- Expected: PASS incl. ladder test — one fixture per rung asserting exact column string.

- [ ] Write failing ladder tests
- [ ] Implement
- [ ] Run acceptance → PASS
- [ ] Commit: `git add tui/src && git commit -m "feat: plans reason-for-state column"`

### Task V1: End-to-end verify

**Wave:** 10 · **Blocks:** — · **Blocked by:** all

**Files:** none (verification only)

**Behavior/Acceptance:**
- [x] `node --test src/test/*.test.js` → all PASS (42/42 control-api incl. dynwf endpoints; 39 js test files green)
- [x] `cargo build --release --manifest-path tui/Cargo.toml && cargo test --manifest-path tui/Cargo.toml` → 210 passed / 0 failed, zero warnings
- [x] Live pty walk (tabs, plans ladder, launcher, help overlay) + 12-test pty suite as per-screen oracle; steer round-trip covered by control-api steer tests
- [x] Report: `docs/reports/2026-07-17-dynwf-ux-v1-verify.md` (fixes on branch + pre-existing main debt reproduced on main oracle)

---

## Amendment 2026-07-17 — external consumer + follow-up plan

- **Overdeck** (unified ops deck, `~/Projects/overdeck`, plan `docs/plans/2026-07-17-overdeck-v1.md` there) consumes this plan's E1–E5 control-api surface: run/task payloads, `GET /tasks/:id/{activity,prompt,transcript}`, decisions inbox, `POST /tasks/:id/steer`. These endpoint contracts are now EXTERNAL — changing them requires a sync task in the Overdeck plan, not a silent edit.
- **Follow-up plan:** `docs/plans/2026-07-17-tui-kit-extraction.md` — extracts the presentation layer into a `tui-kit` workspace crate + theme tokens (runplan + deck themes) so Overdeck's deck-tui reuses these widgets. Prereq: this plan's V1 passed and branch merged. Run forensics visuals (`docs/reports/2026-07-16-harness-run-analysis.html`) are re-built ONCE as Overdeck React components fed by E1–E5 data — do NOT componentize them separately in this repo.

## Self-Review (done at authoring)

1. **Spec coverage:** S1→R3, S1a→R3, S2/a/b→R4, S2c→E5+R6, S2d→R5, S3→R7, S4→R8, S5→R9+E2, overlays/keymap→R1, engine plumbing §"Engine work required" items 1-5 → E2+E3, E1+E4, E4, E5, R1-R9. No gaps.
2. **Vagueness/body-bloat:** no TBDs; no implementation bodies; all acceptance checks executable; humanize map + ladder + adapter map pinned verbatim.
3. **Seam consistency:** `agentLog`/`prompt` (E1) consumed by E3/E4; `lastEvent`/`pendingDecisions` (E2) consumed by R2/R3/R9 with matching serde renames; `post_steer(task_id, text, restart)` (R2) matches E5 route body; `countToolCalls` (E4) consumed by E3 (null until wave 3 — noted in E3).
4. **Waves:** every same-wave pair file-disjoint (checked); Rust chain serialized on shared `main.rs`/`interaction.rs`/`lib.rs`; engine chain serialized on `control-api.js`; E↔R pairs disjoint.
