From 2b96330534a7fa103895b827ca48c911c12031d8 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Thu, 28 May 2026 18:00:47 +0000 Subject: [PATCH] =?UTF-8?q?docs(maestro):=20TUI=20design=20=E2=80=94=20`ma?= =?UTF-8?q?estro=20tui`,=20vim-style=20layout,=20multi-session=20liveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../specs/2026-05-28-maestro-tui-design.md | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-maestro-tui-design.md diff --git a/docs/superpowers/specs/2026-05-28-maestro-tui-design.md b/docs/superpowers/specs/2026-05-28-maestro-tui-design.md new file mode 100644 index 0000000..a38ceed --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-maestro-tui-design.md @@ -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] +"" = "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, + sessions: HashMap, + 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>)`. + - 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)` 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, + screen: Arc>, + state: Arc, // Connecting | Live | Reconnecting | Ended +} + +// Lives in: +struct SessionEmulators(HashMap); +``` + +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`. 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 + → 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-` 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 + └── ... +```