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:
@@ -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
|
||||
└── ...
|
||||
```
|
||||
Reference in New Issue
Block a user