docs(maestro): TUI design — maestro tui, vim-style layout, multi-session liveness

Spec covers the new `crates/maestro-tui/` crate gated on a `tui` feature of
the umbrella binary: ratatui + vt100, sidebar/tab/pane components, configurable
keymap (defaults are defaults), first-class mouse, vim-buffer mental model.

Goals + non-goals; v1 ships tabs-only, v1.5 enables splits on the same layout
tree. Talks only to the local daemon's `/api/*` surface — peer sessions reach
us via the catalog mirror, attach hops are broker-mediated transparently.
This commit is contained in:
2026-05-28 18:00:47 +00:00
parent 973b49e3df
commit 2b96330534
@@ -0,0 +1,408 @@
# Maestro TUI — design
**Status:** draft (brainstormed 2026-05-28)
**Scope:** new `maestro tui` subcommand on the umbrella `maestro` binary, providing a multi-session terminal UI that talks to the local daemon.
**Related:**
- `docs/superpowers/specs/2026-05-24-maestro-design.md` — overall maestro architecture
- `docs/superpowers/specs/2026-05-26-connection-orchestration.md` — broker / catalog-mirror surface this TUI rides on
- NAP-230 (CLI attach footer) — adjacent, separate; NAP-244 (CLI through broker) — orthogonal
## 1. Goals & non-goals
### Goals
- A keyboard-and-mouse driven terminal UI that exposes the same surface as `maestro-web`: list daemons + sessions, spawn, attach, kill.
- Multi-session viewing: attach to several sessions at once, switch between them as tabs, split tabs into side-by-side panes — mental model is vim's buffers/windows/tabs.
- Sessions remain live in the background even when not visible (their PTY bytes are consumed continuously into a maintained screen state).
- Configurable keymap: every action is bindable, the shipped defaults are just defaults.
- First-class mouse support (click sidebar / tabs / panes, drag split dividers).
### Non-goals (v1)
- A vim-style `:` command palette. Parked as v1.5 candidate once direct bindings prove their reach.
- Persisting tabs/splits across TUI restarts.
- Saving named split layouts ("workspaces") to disk.
- Pixel-perfect rendering across exotic terminal emulators — we trust ratatui + crossterm.
### Phasing
The full goal set above is what the design supports — but for delivery the work is staged so we ship something useful before the split-tree work is done:
- **v1** — sidebar + tab bar + one pane per tab, full multi-session liveness, configurable keymap, mouse. No splits. The layout tree (§3.3) is implemented but every tab has exactly one leaf.
- **v1.5** — splits enabled. The pane-tree code path is already there; v1.5 unlocks `SplitHorizontal` / `SplitVertical` actions and the focus-motion / resize bindings.
Same crate, same implementation plan, just two milestones inside it.
### Out of scope (forever)
- Adding persistence to maestro. Sessions persist because tmux owns them on the daemon side. The TUI is a multi-attach *client*; closing it closes its WS connections, the sessions keep running.
## 2. Architecture
```
┌──────────── maestro tui (process) ─────────────┐
│ │
│ ┌──────────┐ ┌──────────────────────────┐ │
catalog WS ───► │ │ Catalog │ │ Render loop │ │
HTTP REST ────► │ │ store │◄──►│ (ratatui, 30 Hz max) │ │
│ └──────────┘ │ sidebar │ tabs │ panes │ │
│ └──────────────────────────┘ │
│ ▲ ▲ │
│ ┌────────────────────┐ │ │ │
│ │ Session emulators │──┘ │ │
│ │ (1 task / session) │ │ │
│ │ vt100 ↔ WS ↔ buf │ │ │
│ └────────────────────┘ │ │
│ │ │
│ ┌────────────────────┐ │ │
│ │ Input router │──────────┘ │
│ │ (crossterm events) │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────┘
│ ▲ ▲
▼ │ │
┌──────── local maestro daemon ────────┐
│ GET /api/sessions /api/daemons │
│ POST /api/sessions │
│ DEL /api/sessions/:id │
│ WS /api/events │
│ WS /api/attach/:id ×N │
└──────────────────────────────────────┘
```
Four concurrent concerns inside the TUI process, each its own Tokio task / unit:
1. **Catalog store** — single source of truth for the daemons-and-sessions tree. Bootstrapped via `GET /api/daemons` + `GET /api/sessions`, then kept live by `WS /api/events`.
2. **Session emulators** — one task per *attached* session, owning a `tokio_tungstenite` WS to `/api/attach/:id` and a `vt100::Parser`. Stays alive even when the session is not visible — that is how background liveness works.
3. **Input router** — owns the crossterm event stream. Distinguishes between TUI-mode keys (sidebar nav, tab/split keys, action keys, mouse events) and pass-through keys (anything else when a session pane is focused, forwarded as binary frames to that session's WS sink).
4. **Render loop** — at most ~30 Hz, redraws on (a) any emulator screen-dirty bit, (b) catalog change, (c) input that changes focus/layout. Composes: sidebar (left), tab bar (top), pane tree (rest).
The TUI talks **only** to the local daemon's `/api/*` surface — same contract `maestro-web` uses. Peer sessions reach us via the catalog mirror (NAP-246); attaching to a peer session goes through the broker (NAP-247) without any TUI-side awareness of "remote".
The new crate is `crates/maestro-tui/`. It lives behind a `tui` cargo feature on the `maestro` umbrella binary, included in the default feature set. Registry-only / daemon-only hosts opt out per the established NAP-245 pattern (`--no-default-features --features registry`).
## 3. Components
### 3.1 Sidebar (`sidebar::Sidebar`)
Left column, default 28 cols wide, togglable. Two-level tree: daemons → sessions, ordered (self first, peers alphabetical). Each session row shows label, command, age, and a status glyph:
- ● attached in current TUI
- ○ live but not attached
- ✗ ended (kept visible briefly, then aged out by catalog events)
Sidebar is navigation-only. Possible actions: highlight motion, attach highlighted, toggle-attach highlighted, collapse/expand daemon. It never enters Insert mode and never forwards keystrokes to a session.
### 3.2 Tab bar (`tabs::TabBar`)
Top row of the main area. A tab is a named workspace owning a layout tree. Tabs are TUI-local — closing the TUI does not persist them.
### 3.3 Layout tree (`layout::Layout`)
A binary tree per tab. Leaves are `PaneId`s; internal nodes are horizontal or vertical splits with a ratio in `[0.1, 0.9]`. Operations:
- `split(direction)` — replace the focused leaf with a split. The new pane inherits the same session by default (vim semantics: `:split` shows the current buffer in both).
- `close()` — remove the focused leaf, collapse the parent.
- `move_focus(direction)` — geometric: find the nearest leaf in `h/j/k/l` direction.
- `resize(direction, delta)` — adjust the nearest ancestor split's ratio.
Each leaf binds to a `SessionId`. Multiple leaves *can* bind to the same session — viewing the same session in two splits, identical to vim opening a buffer in two windows. The session's emulator owns one canonical screen; multiple panes render that same screen.
### 3.4 Spawn modal (`spawn_modal::SpawnModal`)
Standalone overlay form for creating a session. Fields: daemon (dropdown sourced from the catalog), cwd (text), command (text), label (text, optional), env entries (repeatable `KEY=VALUE`). Submit constructs a `CreateSessionRequest` and `POST /api/sessions`; the new session appears in the sidebar via the events WS, not by waiting on the POST response. Field set matches the existing `SpawnArgs` in `crates/maestro-cli/src/cli.rs`.
### 3.5 Attach pane (`pane::Pane`)
Renders the bound session's emulator screen via `vt100::Screen` into a `ratatui` `Buffer`. When focused in Insert mode, all keystrokes that aren't the leader are forwarded to that session's WS as binary frames. On every render, if the pane's allocated `(cols, rows)` differs from the last value sent for that session, the TUI sends a `ClientControl::Resize`. When multiple panes show the same session at different sizes, the per-dimension minimum wins: `cols = min(p.cols)` and `rows = min(p.rows)` across all panes bound to the session, computed independently. Matches tmux's convention when a session is attached at two sizes.
### 3.6 Help overlay (`help::Help`)
Transient overlay listing all current bindings (resolved through the active keymap). Closes on any key. This is the discoverability mechanism in lieu of a command palette.
### 3.7 Modal model
Three mutually-exclusive modes:
- **Normal** — sidebar nav, tab/split keys, action keys (spawn, kill, help). Default.
- **Insert** — pane has full passthrough; the only intercepted key is the **leader**. Enter from Normal when a pane is focused; leave with the configured `LeaveInsert` binding.
- **Modal overlay** — Spawn modal, Kill-confirm, or Help. Captures all input until dismissed.
The leader is the one byte the TUI consumes in Insert mode — same idea as tmux's prefix. Default `Ctrl-a`, configurable.
### 3.8 Action set (the verbs the TUI knows about)
This is the closed list of things the user can ask the TUI to do. Bindings map input → action; the action surface itself is the stable contract.
| Action | Effect |
|---|---|
| `EnterInsert` | Active pane begins forwarding all input (except leader) to the session WS |
| `LeaveInsert` | Return to Normal mode |
| `FocusSidebar` | Move focus to the sidebar tree |
| `FocusPane(Left/Right/Up/Down)` | Geometric focus motion across the current tab's layout |
| `AttachHighlighted` | Attach to the sidebar's highlighted session: if a pane is focused (in the main area), bind the session to that pane (replacing its previous binding); if focus is on the sidebar, open the session in a new tab |
| `ToggleAttachHighlighted` | Attach/detach the highlighted session without changing focus |
| `OpenSpawnModal` | Open the spawn form |
| `KillHighlighted` | Kill highlighted session (always followed by a confirm overlay; not bindable to bypass) |
| `NewTab` / `CloseTab` / `NextTab` / `PrevTab` / `GotoTab(n)` | Tab management |
| `SplitHorizontal` / `SplitVertical` / `ClosePane` | Split-tree management |
| `ResizePane(dir, delta)` | Adjust nearest split ratio |
| `ToggleSidebar` | Hide / show sidebar |
| `ShowHelp` | Open the help overlay |
| `Quit` | Quit (confirm if any pane has an active attach) |
### 3.9 Keymap (`keymap::Keymap`)
A two-level table loaded from config: `mode → (input → action)`, with separate Normal and Insert tables (Insert is sparse — usually only the leader is bound). Input lives in a canonical form: `"ctrl-w h"`, `"g t"`, `"mouse:click(pane)"`, `"mouse:dblclick(sidebar-row)"`, `"mouse:drag(split-divider)"`.
Maestro ships a default keymap that covers every action — recorded as the **shipped default**, not a hardcoded contract. Defaults to be finalized during implementation; the configuration surface is the part being committed to here.
Config lives at `[tui.keymap]` in `~/.config/maestro/config.toml`:
```toml
[tui.keymap]
leader = "ctrl-a"
chord_timeout_ms = 1000
[tui.keymap.normal]
# (illustrative — not contractual)
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"x" = "KillHighlighted"
"ctrl-t" = "NewTab"
"ctrl-w h" = "FocusPane(Left)"
"mouse:click(pane)" = "FocusPaneAtCursor"
"mouse:drag(split-divider)" = "ResizePaneByDrag"
"mouse:click(sidebar-row)" = "HighlightSidebarRow"
"mouse:dblclick(sidebar-row)" = "AttachHighlighted"
"mouse:click(tab)" = "GotoTabAtCursor"
[tui.keymap.insert]
"<leader>" = "LeaveInsert"
```
User can rebind, unbind (`= "Noop"`), or supply a partial table that merges over the shipped defaults.
**Chord handling.** Prefixes (`g t`, `ctrl-w h`) are handled by a small state machine inside the resolver: if the current key extends a known prefix, hold it for one cycle waiting for the next key; if the next key doesn't complete a binding, drop the chord. Timeout (default 1s, configurable via `chord_timeout_ms`) flushes pending chords.
**Validation.** Parsed at TUI startup in two passes:
1. **Lex pass** — canonicalise each key string. Bad input (`"ctrl-glorb"`) collected with line numbers.
2. **Resolve pass** — every action name resolved against the closed `Action` enum. Unknown actions collected.
If either pass produces errors, the TUI prints the diagnostic list and exits 2. A `--keymap-check` flag (validate without launching) makes this scriptable. Prefix conflicts (`g` shadows `g t`) are warnings, not errors — last-load-wins.
### 3.10 Mouse support
Crossterm exposes mouse events; ratatui forwards them. The TUI consumes them in two layers:
1. **Region hit-test** — the render layer tags regions (sidebar rows, tab cells, split dividers, panes) with stable identifiers; the input router resolves a mouse coordinate to one tagged region.
2. **Action binding** — mouse events become canonical input strings; the same keymap table handles them as keyboard input.
Mouse passthrough to the session (for tmux-aware programs that want their own mouse) is enabled when a pane is in Insert mode and the underlying session indicates mouse support; otherwise the TUI consumes the click. The exact heuristic for "underlying session indicates mouse support" is to be validated during implementation — `TERM` inspection is the obvious starting point.
## 4. Data flow
### 4.1 Catalog bootstrap & live updates
On startup the TUI hits `GET /api/daemons` and `GET /api/sessions` against the local daemon (`http://127.0.0.1:7070` by default, configurable via `[shared].daemon_url`/`MAESTRO_DAEMON_URL`). The two responses populate `CatalogStore`:
```rust
struct CatalogStore {
daemons: HashMap<DaemonId, DaemonInfo>,
sessions: HashMap<SessionId, Session>,
version: u64, // bumped on every change
}
```
Then it opens `WS /api/events`. The receive task deserialises `RegistryEvent` frames (already the protocol surface emitted by `LocalEventBus`, NAP-248) and applies them:
- `SessionCreated` → insert
- `SessionUpdated` → replace
- `SessionEnded` → remove and mark any attached emulator as `Ended`
- `DaemonJoined` / `DaemonLeft` → likewise
Each apply bumps `version` and wakes the render loop via a `Notify`.
### 4.2 Attach lifecycle
Triggered by the `AttachHighlighted` action:
1. Look up the highlighted session in the catalog.
2. Open `WS /api/attach/:id` against the **local** daemon. No client auth — the local-bind trust model is "whoever can reach `bind_addr`" (matches `local_api.rs` v1). The broker handles JWT minting for cross-daemon hops internally; that's transparent to the TUI.
3. Spawn a `SessionTask`:
- Owns `(ws_sink, ws_stream, parser: vt100::Parser, screen_arc: Arc<Mutex<vt100::Screen>>)`.
- Receive side: `Message::Binary(bytes)` → `parser.process(&bytes)`; on dirty screen, notify the render loop.
- Receive side, text frames: route `ClientControl::Ping` back as `Pong`; other control frames are no-ops in v1.
- Send side: receives `SessionInput` enum from the input router — `Bytes(Vec<u8>)` becomes a binary frame; `Resize { cols, rows }` becomes a `ClientControl::Resize` text frame.
- On WS close: state transitions to `Ended`; the screen remains visible (frozen last frame) until the pane is closed.
```rust
struct SessionHandle {
tx: mpsc::Sender<SessionInput>,
screen: Arc<Mutex<vt100::Screen>>,
state: Arc<AtomicU8>, // Connecting | Live | Reconnecting | Ended
}
// Lives in:
struct SessionEmulators(HashMap<SessionId, SessionHandle>);
```
Panes hold a `SessionId` and look up the handle on every render — cheap and avoids stale references when a session ends and a new pane binds to a new one.
### 4.3 Resize propagation
Each pane, on render, computes its `(cols, rows)` from the ratatui `Rect` it is given. It compares with `last_sent_size: HashMap<SessionId, (u16, u16)>`. After the render pass, the per-session minimum across all panes bound to that session is recomputed; if it changed, one `Resize` is sent. Window-level SIGWINCH triggers a full re-layout; the same per-session min-size recompute handles propagation.
### 4.4 Input routing
Per frame the input router pulls events from the crossterm stream and runs:
```
Event → canonicalise(Event) # "ctrl-a", "g t", "mouse:click(pane)"
→ Keymap.resolve(mode, key) # → Option<Action>
→ if Some(action): dispatch(action)
→ if None: if mode == Insert and focus is a pane → forward bytes to that pane's SessionTask
else: ignore
```
Bytes forwarded to a session in Insert mode go straight onto the session task's input channel without TUI interpretation — that is how `Ctrl-C`/`Ctrl-Z`/`Esc` reach the remote shell. The leader is the **only** key the TUI consumes in Insert mode.
### 4.5 Spawn & kill
Spawn: `OpenSpawnModal` → form → submit constructs a `CreateSessionRequest` and `POST /api/sessions`. On 201, the catalog WS will emit a `SessionCreated` that adds it to the sidebar; the modal closes on submit, doesn't wait for the event.
Kill: `KillHighlighted` → confirm overlay → on confirm `DELETE /api/sessions/:id`. Same pattern — the event stream is the source of truth for the disappearance; the request just kicks it off.
If either request fails (4xx / 5xx / network), a status-bar toast surfaces the error for ~5s; catalog state is unchanged.
## 5. Error handling & resilience
### 5.1 Daemon unreachable at startup
If the initial `GET /api/web/info` (health probe) fails after a short retry (3× 500ms backoff), the TUI does not enter the main loop. It prints a single line on the inherited terminal explaining what it tried to reach and how to fix it (`maestro daemon configure`, `systemctl --user status maestro-daemon`), then exits 1.
### 5.2 Events WS reconnect
On disconnect: exponential backoff (1s base, 30s cap, ±25% jitter, infinite retries). Sidebar shows `△ catalog offline` in its footer until reconnect. On reconnect, re-fetch `/api/daemons` + `/api/sessions` and diff against `CatalogStore` — sessions that disappeared while disconnected become `Ended`, new ones appear. This avoids drift across reconnects without needing the events WS to replay missed events.
### 5.3 Attach WS drops (per-session)
A session's WS dropping does *not* end the session — only signals our client lost contact. The `SessionTask` transitions to `Reconnecting`, posts a status overlay on every pane bound to it (`reconnecting…`), and retries with the same backoff. On reconnect:
- Re-open `WS /api/attach/:id` on the local daemon.
- Send a fresh `ClientControl::Resize`.
- Do **not** clear the vt100 screen — last-known frame stays visible (frozen) until new bytes arrive.
If the underlying session has actually ended (daemon emits `SessionEnded` on the events WS while we're reconnecting), the task aborts reconnect and transitions to `Ended`.
### 5.4 vt100 parser glitches
`vt100::Parser::process` is infallible on malformed input — it discards unrecognized sequences. The user-visible failure mode is rendering glitches (wrong colour cells, wrap math off). Recovery: a per-pane `RedrawSession` action (default unbound, available to keymap) re-initialises the parser at the current size and asks the remote `tmux refresh-client -t maestro-<id>` via a daemon endpoint (`POST /api/sessions/:id/refresh`, **new in v1**, shared with `maestro-web`).
If `vt100` actually panics on input (shouldn't, but: parser bugs happen), the panic hook catches it, logs, and ends that one `SessionTask` cleanly — the rest of the TUI stays up.
### 5.5 Terminal restore on panic / abrupt exit
The TUI installs `panic::set_hook` and a `SIGTERM`/`SIGINT` handler that:
- Disable mouse capture
- Leave the alternate screen buffer
- Restore the original termios (saved at startup)
- Print the panic message / signal name
- Re-raise the signal so the parent shell sees the correct exit reason
A `Drop` impl on the same RAII guard handles the normal-exit path. Pattern matches the existing `RawTty` guard in `crates/maestro-cli/src/attach.rs`.
## 6. Testing strategy
### 6.1 Unit tests — pure logic, no I/O
- `keymap::parse` — table tests over the canonical form: valid key/chord strings parse, bad strings surface the right diagnostic, conflicting bindings produce the expected warning.
- `layout::Layout` — algebraic: split-then-close is identity, focus motion under arbitrary trees finds the geometrically-nearest leaf, ratio bounds are enforced. Property tests via `proptest`: "focus is always on a real leaf", "ratios sum to 1.0 within each split", "close-on-singleton collapses to empty".
- `action::dispatch` — given a `(CatalogStore, Layout, Mode)` snapshot and an `Action`, the post-state is deterministic. Covers edge cases (kill-while-focused-pane-shows-it, attach-when-already-attached, close-pane-when-only-one-left).
- `vt100` framing — bytes-in, screen-out. We don't test `vt100` itself, but we do test that our `SessionTask`'s parser correctly accumulates a partial CSI across `process` calls (WS frames have no guarantee of sequence alignment). Fixtures from real bash/vim/claude byte streams.
### 6.2 Integration tests — against a real daemon (in-process)
`crates/maestro-tui/tests/integration.rs` spins up a `SessionManager` + `LocalApiState` + axum router on a random port — same seam `maestro-e2e` uses. The TUI's networking layer is pointed at that port.
Coverage:
- Catalog bootstrap loads both REST snapshots and reaches steady state.
- Events WS receives `SessionCreated` after `POST /api/sessions` and updates the sidebar state.
- Attach against a daemon-owned bash session round-trips bytes (test feeds keystrokes through the input router, asserts the bash session sees them via `echo`'d output through the same WS into the parser).
- Reconnect: forcibly drop the events WS, assert backoff fires, assert state reconciles on reconnect.
Real `tmux`, isolated `$TMUX_TMPDIR`. Behind `cargo test --features integration`; CI runs them, default `cargo test` does not (slow).
### 6.3 Render snapshot tests
`ratatui::backend::TestBackend` for known-buffer assertions:
- Sidebar tree rendering (collapsed, expanded, with status glyphs).
- Tab bar at varying counts, with truncation.
- Split layouts (h-split, v-split, nested).
- Help overlay.
- Spawn modal.
Goldens stored under `tests/snapshots/`. Choice between `insta` or a small home-grown matcher made during implementation.
### 6.4 End-to-end via `maestro-e2e`
`maestro-e2e` (NAP-217) already spins registry + two daemons. We add a `tui` e2e target that drives the TUI by piping crossterm events into a substitutable Tokio source.
Coverage:
- "Spawn → catalog updates → attach → type → see output → kill" round-trip across two daemons.
- Sidebar shows peer sessions (catalog mirror); attach against a peer session goes through the broker (NAP-247) without TUI-side awareness of "remote".
Behind the same `--features e2e` gate.
### 6.5 What we explicitly don't test
- Pixel-perfect rendering across exotic terminals — trust `ratatui` + `crossterm`. Manual smoke per release.
- Mouse behaviour in CI — non-interactive runs don't expose mouse. Mouse hit-test logic (region tagging → resolved region) is covered by unit tests; the actual event plumbing is exercised manually.
- `vt100` correctness on weird ANSI — if a program breaks rendering, file a follow-up with the byte stream as a fixture.
### 6.6 Acceptance bar for v1 sign-off
Rough target: ~30 unit, ~6 integration, ~10 snapshot, ~2 e2e. Not a contract — a sanity check that we've covered each layer.
## 7. Open questions / follow-ups parked for after v1
- **Command palette** — vim-`:` style ex-line. To revisit once direct bindings prove their reach in real use.
- **Tab persistence** — saving the tab/split layout across TUI restarts. Probably wanted, but only after the layout primitives are stable.
- **Named workspaces** — saving and restoring multiple layouts.
- **Mouse passthrough heuristic** — finalize how we detect that the inner program wants mouse events so the TUI yields its mouse handling.
- **`wezterm-term` swap** — if `vt100` proves insufficient for some real program (vim/claude with exotic sequences), the emulator behind the `SessionTask` is swappable behind a thin trait.
- **`POST /api/sessions/:id/refresh`** — small new daemon endpoint behind the per-pane redraw recovery; share with `maestro-web`.
## 8. Repo layout
```
crates/
├── maestro/ # umbrella binary; `tui` feature (default-on) wires TUI subcommand
└── maestro-tui/ # new crate — all TUI logic
├── src/
│ ├── lib.rs # `run(Config) -> Result<()>`
│ ├── cli.rs # clap subcommand definition
│ ├── catalog.rs # CatalogStore, REST bootstrap, events WS
│ ├── session_task.rs # one task per attached session
│ ├── emulators.rs # SessionEmulators registry
│ ├── input.rs # crossterm event canonicalisation + router
│ ├── keymap.rs # config parsing, resolver, chord state machine
│ ├── action.rs # Action enum + dispatch
│ ├── layout.rs # binary split tree
│ ├── sidebar.rs # sidebar widget
│ ├── tabs.rs # tab bar widget
│ ├── pane.rs # attach pane widget
│ ├── spawn_modal.rs # spawn form
│ ├── help.rs # help overlay
│ ├── render.rs # top-level draw orchestration
│ └── terminal.rs # RAII setup/restore, panic hook
└── tests/
├── integration.rs # in-process daemon
├── snapshots/ # ratatui TestBackend goldens
└── ...
```