This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/docs/superpowers/specs/2026-05-28-maestro-tui-design.md
T
Miguel Palhas c856333e4a
ci / rust (push) Successful in 2m40s
docs(maestro): describe the single-host architecture
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.

The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
2026-08-22 10:26:49 +01:00

26 KiB
Raw Blame History

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

Partly superseded (2026-08-22). Maestro is now single-host — the registry and cross-host catalog it references no longer exist. See CLAUDE.md.

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 PaneIds; 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:

[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:

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.
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
        └── ...