Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.DS_Store
coverage/
project_master_roadmap copy 2.md
.claude/worktrees/
139 changes: 139 additions & 0 deletions docs/superpowers/specs/2026-04-16-tui-control-room.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# TUI Control Room (Layer 2)

**Date:** 2026-04-16
**Status:** APPROVED (eng + CEO). Sequential-only.
**Author:** Lewis + Claude

## Context & Motivation

Flight's TUI is currently a single-pane viewer. Operators working live agent runs need more: the ability to quickly filter down to a specific `tool_name`, `agent_id`, or `execution_outcome`; watch new runs arrive without manual refresh; and annotate verdicts inline without leaving the terminal.

This spec formalizes Layer 2 of the TUI roadmap — the "control room" layer. It ships as three sequential PRs against `src/tui/`. Each PR is a single focused commit that builds on the prior.

---

## PR Boundaries

### PR 1: Filter & Navigation

Adds a `/`-activated filter bar to the main tree view.

- `/` opens an inline filter bar at the bottom of the tree pane.
- Filter operates with AND semantics across three fields: `tool_name`, `agent_id`, `execution_outcome`.
- Filter state is held on `TuiApp` (existing app state struct).
- `refresh()` re-applies the active filter on each poll tick — no separate filter-render path.
- `Enter` drills from the tree into the detail pane for the selected row.
- `Esc` returns to the tree; when focus is on the filter bar, `Esc` also clears the filter and restores the full tree.
- An empty-result placeholder ("No matching entries") is shown when the filter produces zero rows.

### PR 2: Annotations + Live Feed

Adds verdict keybinds and a 1-second poll loop that feeds new rows into a tail pane.

**Verdict keybinds:**
- `g` — good verdict; writes via `runAnnotate`, badge appears on tree row.
- `b` — bad verdict; writes via `runAnnotate`, badge appears on tree row.
- `n` — note modal (reuses existing `createAnnotateModal` from `src/tui/annotate.ts`).

**Live tail feed:**
- `setInterval(..., 1000)` in `TuiApp.start()` polls for new rows.
- Diff logic appends only new rows into the tail pane (avoids full re-render).
- Scroll-lock: tail pane auto-scrolls unless the operator has scrolled up (scroll-up breaks lock; new content arriving re-locks only if operator scrolls back to bottom).
- Tail pane scope follows the current tree selection — switching selection refreshes the tail pane from indexed rows for that session/turn.

**Modal safety:**
- `pausePolling()` called on modal open; `resumePolling()` on modal close. Prevents poll ticks from mutating state during modal render.
- `clearInterval` called in `destroy()` to prevent leaked timers on exit.

**Error handling:**
- Annotation write failure is non-fatal. Surfaces as an ephemeral status line in the tail pane; does not crash or freeze the TUI.

### PR 3: 3-Panel Layout

Extracts a persistent layout module and reorganises the screen into three columns.

- New file: `src/tui/layout.ts`.
- Layout: Tree (30%) | Detail (45%) | Tail (25%) — persistent across the session, not toggled.
- `s` / `v` / `q` open summary / view / query as modal overlays. `Esc` dismisses.
- `Tab` cycles focus: Tree → Detail → Tail → Tree.
- Overlays re-layout on terminal resize (SIGWINCH handling delegated to layout module).

---

## Hard Constraints

### Sequential Only

Each PR depends on the prior:

- PR 1 introduces filter state on `TuiApp`.
- PR 2's poll loop wraps `TuiApp.refresh()` — which must already honour the filter (PR 1) before the loop is added.
- PR 3's layout refactor moves everything PR 1 and PR 2 touched. Attempting layout changes before the earlier code is stable will cause repeated merge conflicts.

No worktree parallelism available. Land in order.

### Cleanup First

Per the repo CLAUDE.md, dead-code removal lands as its own commit before PR 1. Specifically: removal of 4 unused TUI exports. This is a pre-requisite commit, not a numbered PR.

---

## Acceptance Criteria

### PR 1: Filter & Navigation

- [ ] Filter by `tool_name` — only matching rows shown.
- [ ] Filter by `agent_id` — only matching rows shown.
- [ ] Filter by `execution_outcome` — only matching rows shown.
- [ ] Combined AND across all three fields — intersection applied correctly.
- [ ] Empty-result state — placeholder renders when filter produces zero rows.
- [ ] `Esc` clears filter (when filter bar is focused) and restores full tree.

### PR 2: Annotations + Live Feed

- [ ] `g` writes good verdict — tree badge appears immediately.
- [ ] `b` writes bad verdict — tree badge appears immediately.
- [ ] `n` writes note — extend existing modal test.
- [ ] Poll tick appends new rows to tail pane without full re-render.
- [ ] Poll is paused during modal open (regression-critical — verify no state mutation during modal render).
- [ ] Annotation write failure is non-fatal — surfaces as tail pane status line, TUI remains interactive.

### PR 3: 3-Panel Layout

- [ ] Layout snapshot: column widths match spec (30 / 45 / 25 %).
- [ ] Overlay open/close restores prior focus to the panel that was active before the overlay.
- [ ] `Tab` cycles focus Tree → Detail → Tail → Tree.
- [ ] Terminal resize: tested manually and documented in PR notes (automated snapshot not required).

---

## Resolved Design Questions

**[P2] Tail pane scope** — RESOLVED: follows current tree selection, not global. Switching selection refreshes the tail pane from indexed rows for that session/turn. A global feed was considered but discarded as too noisy during deep inspection.

**[S2] Verdict annotation export** — RESOLVED: verdicts written via `g`/`b` use the same JSONL schema as `flight log annotate` and flow through `flight log export` unchanged. PR 2 must verify end-to-end: `flight log export <session> | grep verdict` surfaces the new row.

---

## Reused Infrastructure

| Component | Source | Role in this work |
|---|---|---|
| `FlightDB` | `src/query.ts` | SQLite-indexed `tool_name`, `agent_id`, `execution_outcome` columns. PR 1 reuses existing indexes — no schema migration required. |
| `runAnnotate` | annotations module | Verdict write path for `g`/`b` keybinds. Unchanged. |
| `createAnnotateModal` | `src/tui/annotate.ts` | Existing note modal. Reused by PR 2 for the `n` keybind. |
| `TuiApp.refresh()` | `src/tui/` | Existing refresh method. PR 2 wraps it in `setInterval(..., 1000)`. |

---

## Dogfood Window

CEO requirement: 4 weeks of internal dogfood before any marketing. Note this in each PR description.

---

## Out of Scope

- Keyboard-chord bindings (dotted-path field filters).
- Remote collaboration or session sharing.
- Persistent layout preferences (panel width saved to disk).
5 changes: 3 additions & 2 deletions packages/claude-code/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { describe, it, expect } from "vitest";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { mkdtempSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const BIN = resolve(import.meta.dirname, "../bin/init.js");
const BIN = resolve(dirname(fileURLToPath(import.meta.url)), "../bin/init.js");

function run(args: string[], env?: Record<string, string>): SpawnSyncReturns<string> {
return spawnSync(process.execPath, [BIN, ...args], {
Expand Down
4 changes: 2 additions & 2 deletions packages/flight-proxy/src/tui/detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function formatAnnotations(annotations: Annotation[]): string[] {
});
}

export function resolveSessionIdForItem(item: TuiTreeItem | null): string | null {
function resolveSessionIdForItem(item: TuiTreeItem | null): string | null {
if (!item) {
return null;
}
Expand All @@ -123,7 +123,7 @@ export function resolveSessionIdForItem(item: TuiTreeItem | null): string | null
return latestSession?.session.session_id ?? null;
}

export function loadSessionEntries(db: FlightDB, sessionId: string, limit: number = ENTRY_LIMIT): LogEntry[] {
function loadSessionEntries(db: FlightDB, sessionId: string, limit: number = ENTRY_LIMIT): LogEntry[] {
const rows = db.query({ sessionId, limit });
const entries = rows.map((row) => rowToIndexedEntry(row));

Expand Down
Loading
Loading