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/plans/2026-05-28-maestro-tui.md
T
naps62-yolo d0f8997a78 docs(maestro): TUI v1 implementation plan — 25 TDD-cut tasks
Phases: scaffolding, pure-logic units (Action / InputKey / Keymap / TuiConfig),
catalog (REST + events WS), session liveness (per-session vt100+WS task with
reconnect), widgets (sidebar / tab / pane / overlays) with golden-snapshot tests,
top-level event loop, lifecycle wiring (spawn / kill), integration test against
in-process daemon, e2e through two daemons via the maestro-e2e harness.

v1 ships tabs-only; splits and command palette deferred to a follow-up plan.
2026-05-28 18:14:12 +00:00

108 KiB
Raw Blame History

Maestro TUI — implementation plan (v1)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship maestro tui v1 — a ratatui-based terminal UI with persistent sidebar, multi-tab attach-to-multiple-sessions UX, configurable keymap, and mouse support. Sessions stay live in the background even when not visible.

Architecture: New crates/maestro-tui/ library, gated behind a tui cargo feature on the umbrella maestro binary (default-on). Talks only to the local daemon's /api/* surface. Each attached session owns a Tokio task + vt100::Parser; the render loop reads Arc<Mutex<vt100::Screen>>. Per-session WS reconnect, no client-side JWT (local-bind trust model). v1 = one pane per tab; v1.5 adds splits inside the same layout primitive.

Tech Stack: ratatui (TUI lib), crossterm (terminal + mouse events), vt100 (PTY-byte → screen emulator), tokio + tokio-tungstenite (existing), serde/toml (config), proptest (layout/keymap property tests), axum::Router from existing maestro-daemon::local_api for in-process integration tests.

Reference spec: docs/superpowers/specs/2026-05-28-maestro-tui-design.md

Out of scope for this plan (filed as v1.5 follow-up): split panes, command palette (:), tab persistence, named workspace save/restore.

Workflow note: Per project convention, after each phase (groups of related tasks), push the branch and let gitea CI run the workspace-wide checks before continuing. Per-task TDD loops run locally inside crates/maestro-tui/.


Phase 0 — Scaffolding

Task 1: Create maestro-tui crate skeleton + clap subcommand stub

Files:

  • Create: crates/maestro-tui/Cargo.toml

  • Create: crates/maestro-tui/src/lib.rs

  • Modify: Cargo.toml (workspace members list)

  • Modify: crates/maestro/Cargo.toml (add tui feature + dep)

  • Modify: crates/maestro/src/main.rs (dispatch Tui subcommand)

  • Modify: crates/maestro-cli/src/cli.rs (add Tui variant)

  • Step 1: Create crate Cargo.toml

# crates/maestro-tui/Cargo.toml
[package]
name = "maestro-tui"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
maestro-protocol.workspace = true
maestro-config.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "signal", "time"] }
tokio-tungstenite.workspace = true
futures-util.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid = { workspace = true, features = ["v4", "serde"] }
ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
vt100 = "0.15"

[dev-dependencies]
proptest = "1"
tokio = { workspace = true, features = ["test-util"] }
maestro-daemon.workspace = true
  • Step 2: Create lib.rs entry with a run() stub
// crates/maestro-tui/src/lib.rs
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
//!
//! See `docs/superpowers/specs/2026-05-28-maestro-tui-design.md` for design.

#![deny(missing_debug_implementations)]

use std::net::SocketAddr;

#[derive(Debug, thiserror::Error)]
pub enum TuiError {
    #[error("not implemented")]
    NotImplemented,
}

#[derive(Debug, Clone)]
pub struct RunConfig {
    pub daemon_addr: SocketAddr,
}

pub async fn run(_cfg: RunConfig) -> Result<(), TuiError> {
    Err(TuiError::NotImplemented)
}
  • Step 3: Add to workspace + umbrella binary
# Cargo.toml (workspace, members)
members = [
    # … existing entries …
    "crates/maestro-tui",
]

# crates/maestro/Cargo.toml
[features]
default = ["cli", "daemon", "registry", "tui"]
tui = ["dep:maestro-tui"]

[dependencies]
maestro-tui = { workspace = true, optional = true }
# … existing entries …

Add maestro-tui.workspace = true to [workspace.dependencies] in the root Cargo.toml and point at path = "crates/maestro-tui".

  • Step 4: Add Tui subcommand variant
// crates/maestro-cli/src/cli.rs — inside enum Command
/// Open the terminal UI.
Tui(TuiArgs),

// At the bottom of the file
#[derive(Debug, clap::Args)]
pub struct TuiArgs {
    /// Override the local daemon's HTTP base URL (default `http://127.0.0.1:7070`).
    #[arg(long, env = "MAESTRO_DAEMON_URL")]
    pub daemon_url: Option<String>,
}
  • Step 5: Wire dispatch in crates/maestro/src/main.rs

Find the match over Command::* (or wherever the umbrella binary routes subcommands today — see crates/maestro/src/main.rs); add the Tui arm behind #[cfg(feature = "tui")]:

#[cfg(feature = "tui")]
Command::Tui(args) => {
    let daemon_addr = args
        .daemon_url
        .as_deref()
        .unwrap_or("http://127.0.0.1:7070")
        .parse_addr()?; // helper that strips scheme + parses SocketAddr
    maestro_tui::run(maestro_tui::RunConfig { daemon_addr }).await?;
    Ok(())
}
#[cfg(not(feature = "tui"))]
Command::Tui(_) => {
    eprintln!("maestro was built without the `tui` feature");
    std::process::exit(2);
}

If no parsing helper exists in the umbrella binary yet, inline a quick url::Url::parse + socket_addrs() instead — kept self-contained, no new module.

  • Step 6: Smoke-test it builds and dispatches

Run: cargo build -p maestro --features tui Expected: clean build.

Run: cargo run -p maestro --features tui -- tui Expected: exits with the TuiError::NotImplemented error message (Error: not implemented).

  • Step 7: Commit
git add crates/maestro-tui/ crates/maestro/Cargo.toml crates/maestro-cli/src/cli.rs crates/maestro/src/main.rs Cargo.toml
git commit -m "feat(maestro-tui): scaffold crate and wire \`maestro tui\` subcommand"

Phase 1 — Pure-logic units

These tasks are unit-testable without I/O or rendering. Build them first so the rest of the TUI sits on a tested core.

Task 2: Action enum

Files:

  • Create: crates/maestro-tui/src/action.rs

  • Modify: crates/maestro-tui/src/lib.rs (add pub mod action)

  • Step 1: Write the failing test

// crates/maestro-tui/src/action.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn action_parses_from_canonical_string() {
        assert_eq!(Action::parse("EnterInsert").unwrap(), Action::EnterInsert);
        assert_eq!(
            Action::parse("FocusPane(Left)").unwrap(),
            Action::FocusPane(Direction::Left),
        );
        assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
    }

    #[test]
    fn action_rejects_unknown_names() {
        assert!(matches!(
            Action::parse("Frobnicate").unwrap_err(),
            ActionParseError::Unknown(name) if name == "Frobnicate"
        ));
    }

    #[test]
    fn action_rejects_bad_arg() {
        assert!(matches!(
            Action::parse("GotoTab(abc)").unwrap_err(),
            ActionParseError::BadArg { .. }
        ));
    }
}
  • Step 2: Run the test — verify it fails

Run: cargo test -p maestro-tui action::tests Expected: errors that Action, Direction, ActionParseError don't exist.

  • Step 3: Implement Action + Direction + parser
// crates/maestro-tui/src/action.rs
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
pub enum Direction {
    Left,
    Right,
    Up,
    Down,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Action {
    Noop,
    EnterInsert,
    LeaveInsert,
    FocusSidebar,
    FocusPane(Direction),
    AttachHighlighted,
    ToggleAttachHighlighted,
    OpenSpawnModal,
    KillHighlighted,
    NewTab,
    CloseTab,
    NextTab,
    PrevTab,
    GotoTab(u8),
    ToggleSidebar,
    ShowHelp,
    Quit,
}

#[derive(Debug, thiserror::Error)]
pub enum ActionParseError {
    #[error("unknown action {0}")]
    Unknown(String),
    #[error("bad arg for {action}: {detail}")]
    BadArg { action: String, detail: String },
    #[error("malformed action string {0:?}")]
    Malformed(String),
}

impl Action {
    pub fn parse(s: &str) -> Result<Self, ActionParseError> {
        let s = s.trim();
        // No-arg actions
        match s {
            "Noop"                    => return Ok(Action::Noop),
            "EnterInsert"             => return Ok(Action::EnterInsert),
            "LeaveInsert"             => return Ok(Action::LeaveInsert),
            "FocusSidebar"            => return Ok(Action::FocusSidebar),
            "AttachHighlighted"       => return Ok(Action::AttachHighlighted),
            "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
            "OpenSpawnModal"          => return Ok(Action::OpenSpawnModal),
            "KillHighlighted"         => return Ok(Action::KillHighlighted),
            "NewTab"                  => return Ok(Action::NewTab),
            "CloseTab"                => return Ok(Action::CloseTab),
            "NextTab"                 => return Ok(Action::NextTab),
            "PrevTab"                 => return Ok(Action::PrevTab),
            "ToggleSidebar"           => return Ok(Action::ToggleSidebar),
            "ShowHelp"                => return Ok(Action::ShowHelp),
            "Quit"                    => return Ok(Action::Quit),
            _ => {}
        }
        // Arg actions: `Name(arg)`
        let (name, rest) = s
            .split_once('(')
            .ok_or_else(|| ActionParseError::Malformed(s.into()))?;
        let arg = rest
            .strip_suffix(')')
            .ok_or_else(|| ActionParseError::Malformed(s.into()))?;
        match name {
            "FocusPane" => {
                let dir = parse_direction(arg).map_err(|d| ActionParseError::BadArg {
                    action: name.into(),
                    detail: d,
                })?;
                Ok(Action::FocusPane(dir))
            }
            "GotoTab" => {
                let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
                    action: name.into(),
                    detail: format!("expected u8, got {arg:?}"),
                })?;
                Ok(Action::GotoTab(n))
            }
            _ => Err(ActionParseError::Unknown(name.into())),
        }
    }
}

fn parse_direction(s: &str) -> Result<Direction, String> {
    match s {
        "Left" => Ok(Direction::Left),
        "Right" => Ok(Direction::Right),
        "Up" => Ok(Direction::Up),
        "Down" => Ok(Direction::Down),
        other => Err(format!("expected Left/Right/Up/Down, got {other:?}")),
    }
}

impl fmt::Display for Action {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
            Action::GotoTab(n)   => write!(f, "GotoTab({n})"),
            other                => write!(f, "{other:?}"),
        }
    }
}

Add pub mod action; to lib.rs.

  • Step 4: Run tests — verify pass

Run: cargo test -p maestro-tui action:: Expected: 3 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): Action enum with canonical parser"

Task 3: InputKey + canonicalisation + chord state

Files:

  • Create: crates/maestro-tui/src/input.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing tests

// crates/maestro-tui/src/input.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};

    #[test]
    fn parse_canonical_simple_keys() {
        assert_eq!(InputKey::parse("a").unwrap(),    InputKey::char('a'));
        assert_eq!(InputKey::parse("ctrl-a").unwrap(), InputKey::ctrl_char('a'));
        assert_eq!(InputKey::parse("ctrl-w").unwrap(), InputKey::ctrl_char('w'));
        assert_eq!(InputKey::parse("esc").unwrap(),    InputKey::Esc);
    }

    #[test]
    fn parse_canonical_mouse() {
        assert!(matches!(
            InputKey::parse("mouse:click(pane)").unwrap(),
            InputKey::Mouse { kind: MouseKind::Click, region: Region::Pane, .. }
        ));
    }

    #[test]
    fn parse_canonical_rejects_garbage() {
        assert!(InputKey::parse("ctrl-glorb").is_err());
        assert!(InputKey::parse("mouse:zoom(pane)").is_err());
    }

    #[test]
    fn chord_state_holds_known_prefix() {
        let mut chord = ChordState::new(std::time::Duration::from_millis(1000));
        let key_g = InputKey::char('g');
        let key_t = InputKey::char('t');
        let known_prefixes = ["g t".to_string()].into_iter().collect();

        // First press: prefix-held, no resolution yet
        let r = chord.feed(&key_g, &known_prefixes);
        assert_eq!(r, ChordResult::Pending);

        // Second press: resolves to "g t"
        let r = chord.feed(&key_t, &known_prefixes);
        assert_eq!(r, ChordResult::Resolved("g t".into()));
    }

    #[test]
    fn chord_state_drops_unknown_continuation() {
        let mut chord = ChordState::new(std::time::Duration::from_millis(1000));
        let known: std::collections::HashSet<String> = ["g t".into()].into_iter().collect();
        chord.feed(&InputKey::char('g'), &known);
        let r = chord.feed(&InputKey::char('x'), &known);
        // 'g x' isn't a binding; should resolve as a lone 'x' after dropping the held 'g'
        assert_eq!(r, ChordResult::Resolved("x".into()));
    }
}
  • Step 2: Run tests — fail

Run: cargo test -p maestro-tui input::tests Expected: errors that InputKey, MouseKind, Region, ChordState, ChordResult don't exist.

  • Step 3: Implement input canonicalisation
// crates/maestro-tui/src/input.rs
use std::collections::HashSet;
use std::time::{Duration, Instant};

use crossterm::event::{
    Event as CtEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputKey {
    Char { c: char, ctrl: bool, alt: bool, shift: bool },
    Esc,
    Enter,
    Tab,
    Backspace,
    Up, Down, Left, Right,
    Mouse { kind: MouseKind, region: Region, col: u16, row: u16 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseKind { Click, DblClick, Drag, ScrollUp, ScrollDown }

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region { Sidebar, SidebarRow, Tab, Pane, SplitDivider, Modal, Unknown }

impl InputKey {
    pub fn char(c: char) -> Self {
        Self::Char { c, ctrl: false, alt: false, shift: false }
    }
    pub fn ctrl_char(c: char) -> Self {
        Self::Char { c, ctrl: true, alt: false, shift: false }
    }

    /// Canonical-string form used by the keymap table. Round-trips with `parse`.
    pub fn canonical(&self) -> String {
        match self {
            InputKey::Char { c, ctrl, alt, shift } => {
                let mut out = String::new();
                if *ctrl  { out.push_str("ctrl-"); }
                if *alt   { out.push_str("alt-"); }
                if *shift { out.push_str("shift-"); }
                out.push(*c);
                out
            }
            InputKey::Esc       => "esc".into(),
            InputKey::Enter     => "enter".into(),
            InputKey::Tab       => "tab".into(),
            InputKey::Backspace => "backspace".into(),
            InputKey::Up        => "up".into(),
            InputKey::Down      => "down".into(),
            InputKey::Left      => "left".into(),
            InputKey::Right     => "right".into(),
            InputKey::Mouse { kind, region, .. } => {
                let k = match kind {
                    MouseKind::Click      => "click",
                    MouseKind::DblClick   => "dblclick",
                    MouseKind::Drag       => "drag",
                    MouseKind::ScrollUp   => "scrollup",
                    MouseKind::ScrollDown => "scrolldown",
                };
                let r = match region {
                    Region::Sidebar      => "sidebar",
                    Region::SidebarRow   => "sidebar-row",
                    Region::Tab          => "tab",
                    Region::Pane         => "pane",
                    Region::SplitDivider => "split-divider",
                    Region::Modal        => "modal",
                    Region::Unknown      => "unknown",
                };
                format!("mouse:{k}({r})")
            }
        }
    }

    pub fn parse(s: &str) -> Result<Self, InputParseError> {
        if let Some(rest) = s.strip_prefix("mouse:") {
            let (k, rest) = rest.split_once('(').ok_or_else(|| InputParseError::Malformed(s.into()))?;
            let r = rest.strip_suffix(')').ok_or_else(|| InputParseError::Malformed(s.into()))?;
            let kind = match k {
                "click"      => MouseKind::Click,
                "dblclick"   => MouseKind::DblClick,
                "drag"       => MouseKind::Drag,
                "scrollup"   => MouseKind::ScrollUp,
                "scrolldown" => MouseKind::ScrollDown,
                _            => return Err(InputParseError::UnknownMouseKind(k.into())),
            };
            let region = match r {
                "sidebar"       => Region::Sidebar,
                "sidebar-row"   => Region::SidebarRow,
                "tab"           => Region::Tab,
                "pane"          => Region::Pane,
                "split-divider" => Region::SplitDivider,
                "modal"         => Region::Modal,
                "unknown"       => Region::Unknown,
                _               => return Err(InputParseError::UnknownRegion(r.into())),
            };
            return Ok(InputKey::Mouse { kind, region, col: 0, row: 0 });
        }
        match s {
            "esc"       => return Ok(InputKey::Esc),
            "enter"     => return Ok(InputKey::Enter),
            "tab"       => return Ok(InputKey::Tab),
            "backspace" => return Ok(InputKey::Backspace),
            "up"        => return Ok(InputKey::Up),
            "down"      => return Ok(InputKey::Down),
            "left"      => return Ok(InputKey::Left),
            "right"     => return Ok(InputKey::Right),
            _ => {}
        }
        // Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
        let mut ctrl = false; let mut alt = false; let mut shift = false;
        let mut rest = s;
        loop {
            if let Some(r) = rest.strip_prefix("ctrl-")  { ctrl = true;  rest = r; continue; }
            if let Some(r) = rest.strip_prefix("alt-")   { alt = true;   rest = r; continue; }
            if let Some(r) = rest.strip_prefix("shift-") { shift = true; rest = r; continue; }
            break;
        }
        let mut chars = rest.chars();
        let c = chars.next().ok_or_else(|| InputParseError::Malformed(s.into()))?;
        if chars.next().is_some() {
            return Err(InputParseError::Malformed(s.into()));
        }
        Ok(InputKey::Char { c, ctrl, alt, shift })
    }
}

#[derive(Debug, thiserror::Error)]
pub enum InputParseError {
    #[error("malformed input key {0:?}")]
    Malformed(String),
    #[error("unknown mouse kind {0:?}")]
    UnknownMouseKind(String),
    #[error("unknown region {0:?}")]
    UnknownRegion(String),
}

/// Convert a raw crossterm event to a canonical InputKey.
/// `region_for` is supplied by the renderer's hit-test layer (see Task 16).
pub fn canonicalise(ev: &CtEvent, region_for: impl FnOnce(u16, u16) -> Region) -> Option<InputKey> {
    match ev {
        CtEvent::Key(KeyEvent { code, modifiers, .. }) => {
            let ctrl  = modifiers.contains(KeyModifiers::CONTROL);
            let alt   = modifiers.contains(KeyModifiers::ALT);
            let shift = modifiers.contains(KeyModifiers::SHIFT);
            Some(match code {
                KeyCode::Char(c)    => InputKey::Char { c: *c, ctrl, alt, shift },
                KeyCode::Esc        => InputKey::Esc,
                KeyCode::Enter      => InputKey::Enter,
                KeyCode::Tab        => InputKey::Tab,
                KeyCode::Backspace  => InputKey::Backspace,
                KeyCode::Up         => InputKey::Up,
                KeyCode::Down       => InputKey::Down,
                KeyCode::Left       => InputKey::Left,
                KeyCode::Right      => InputKey::Right,
                _                   => return None,
            })
        }
        CtEvent::Mouse(MouseEvent { kind, column, row, .. }) => {
            let mk = match kind {
                MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
                MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
                MouseEventKind::ScrollUp                => MouseKind::ScrollUp,
                MouseEventKind::ScrollDown              => MouseKind::ScrollDown,
                _ => return None,
            };
            let region = region_for(*column, *row);
            Some(InputKey::Mouse { kind: mk, region, col: *column, row: *row })
        }
        _ => None,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChordResult {
    Pending,
    Resolved(String),
}

#[derive(Debug)]
pub struct ChordState {
    held: Vec<String>,
    last_press: Option<Instant>,
    timeout: Duration,
}

impl ChordState {
    pub fn new(timeout: Duration) -> Self {
        Self { held: Vec::new(), last_press: None, timeout }
    }

    /// Feed one canonical key. Returns Pending if the key extends a known
    /// prefix and we should wait for more; Resolved("g t") if the full chord
    /// (or single key) is now ready for keymap lookup.
    pub fn feed(&mut self, key: &InputKey, known_prefixes: &HashSet<String>) -> ChordResult {
        // Drop stale held keys past the timeout (caller checks before feeding).
        if let Some(t) = self.last_press {
            if t.elapsed() > self.timeout {
                self.held.clear();
            }
        }
        let key_s = key.canonical();
        let candidate = if self.held.is_empty() {
            key_s.clone()
        } else {
            format!("{} {}", self.held.join(" "), key_s)
        };

        // If candidate is itself a known *prefix* (not yet a complete binding),
        // hold. We treat any string in `known_prefixes` that has a longer
        // member starting with it as a prefix.
        let is_prefix = known_prefixes
            .iter()
            .any(|kb| kb != &candidate && kb.starts_with(&format!("{candidate} ")));

        self.last_press = Some(Instant::now());

        if is_prefix {
            self.held.push(key_s);
            ChordResult::Pending
        } else {
            self.held.clear();
            ChordResult::Resolved(candidate)
        }
    }
}

Add pub mod input; to lib.rs.

  • Step 4: Run tests — pass

Run: cargo test -p maestro-tui input:: Expected: 5 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): InputKey canonicalisation + chord state machine"

Task 4: Keymap table + resolver

Files:

  • Create: crates/maestro-tui/src/keymap.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing test

// crates/maestro-tui/src/keymap.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crate::action::{Action, Direction};

    fn km(entries: &[(Mode, &str, &str)]) -> Keymap {
        let mut k = Keymap::default();
        for (mode, input, action) in entries {
            k.set(*mode, input, action).unwrap();
        }
        k
    }

    #[test]
    fn resolves_single_key() {
        let k = km(&[(Mode::Normal, "i", "EnterInsert")]);
        assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
        assert_eq!(k.resolve(Mode::Insert, "i"), None);
    }

    #[test]
    fn resolves_chord() {
        let k = km(&[(Mode::Normal, "g t", "NextTab")]);
        assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
    }

    #[test]
    fn known_prefixes_set() {
        let k = km(&[
            (Mode::Normal, "g t", "NextTab"),
            (Mode::Normal, "ctrl-w h", "FocusPane(Left)"),
        ]);
        let pfx = k.known_prefixes(Mode::Normal);
        assert!(pfx.contains("g t"));
        assert!(pfx.contains("ctrl-w h"));
    }

    #[test]
    fn reports_lex_errors_at_load() {
        let mut k = Keymap::default();
        let err = k.set(Mode::Normal, "ctrl-glorb", "EnterInsert").unwrap_err();
        assert!(matches!(err, KeymapError::BadInput(_)));
    }

    #[test]
    fn reports_unknown_action() {
        let mut k = Keymap::default();
        let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
        assert!(matches!(err, KeymapError::BadAction(_)));
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui keymap::tests Expected: errors that Keymap, Mode, KeymapError don't exist.

  • Step 3: Implement
// crates/maestro-tui/src/keymap.rs
use std::collections::{HashMap, HashSet};

use crate::action::{Action, ActionParseError};
use crate::input::{InputKey, InputParseError};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Mode { Normal, Insert }

#[derive(Debug, thiserror::Error)]
pub enum KeymapError {
    #[error("bad input key: {0}")]
    BadInput(#[from] InputParseError),
    #[error("bad action: {0}")]
    BadAction(#[from] ActionParseError),
}

#[derive(Debug, Default)]
pub struct Keymap {
    normal: HashMap<String, Action>,
    insert: HashMap<String, Action>,
}

impl Keymap {
    /// Bind a canonical input string to an action under a mode. Validates both.
    pub fn set(&mut self, mode: Mode, input: &str, action: &str) -> Result<(), KeymapError> {
        // Validate input string parses to a sequence of canonical keys.
        for tok in input.split_whitespace() {
            InputKey::parse(tok)?;
        }
        let action = Action::parse(action)?;
        let table = match mode { Mode::Normal => &mut self.normal, Mode::Insert => &mut self.insert };
        table.insert(input.to_string(), action);
        Ok(())
    }

    pub fn resolve(&self, mode: Mode, canonical: &str) -> Option<Action> {
        let table = match mode { Mode::Normal => &self.normal, Mode::Insert => &self.insert };
        table.get(canonical).cloned()
    }

    /// All bound chord strings for the given mode (used by ChordState to
    /// decide whether to hold a key).
    pub fn known_prefixes(&self, mode: Mode) -> HashSet<String> {
        let table = match mode { Mode::Normal => &self.normal, Mode::Insert => &self.insert };
        table.keys().cloned().collect()
    }
}

Add pub mod keymap; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui keymap:: Expected: 5 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): Keymap table with input/action validation"

Task 5: TUI config section + keymap defaults

Files:

  • Modify: crates/maestro-config/src/lib.rs (add TuiSection)

  • Create: crates/maestro-tui/src/config.rs (loader + shipped defaults)

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Add TuiSection to the unified config

In crates/maestro-config/src/lib.rs, alongside the other section structs (after WebSection):

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TuiSection {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub leader: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chord_timeout_ms: Option<u64>,
    /// Normal-mode bindings, applied over the shipped defaults.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub keymap_normal: std::collections::BTreeMap<String, String>,
    /// Insert-mode bindings, applied over the shipped defaults.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub keymap_insert: std::collections::BTreeMap<String, String>,
}

impl TuiSection {
    pub fn is_empty(&self) -> bool {
        self.leader.is_none()
            && self.chord_timeout_ms.is_none()
            && self.keymap_normal.is_empty()
            && self.keymap_insert.is_empty()
    }
}

Then add pub tui: TuiSection to MaestroConfigFile with the same serde(default, skip_serializing_if) pattern as the other sections.

  • Step 2: Write the failing test for the loader
// crates/maestro-tui/src/config.rs
#[cfg(test)]
mod tests {
    use super::*;
    use maestro_config::TuiSection;

    #[test]
    fn loads_shipped_defaults() {
        let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
        // Sanity: a few bindings every TUI ships with.
        assert_eq!(
            cfg.keymap.resolve(crate::keymap::Mode::Normal, "i"),
            Some(crate::action::Action::EnterInsert)
        );
        assert_eq!(
            cfg.keymap.resolve(crate::keymap::Mode::Insert, &cfg.leader.canonical()),
            Some(crate::action::Action::LeaveInsert)
        );
    }

    #[test]
    fn user_overrides_merge_over_defaults() {
        let mut section = TuiSection::default();
        section.keymap_normal.insert("i".into(), "ShowHelp".into());
        let cfg = TuiConfig::load(&section).unwrap();
        assert_eq!(
            cfg.keymap.resolve(crate::keymap::Mode::Normal, "i"),
            Some(crate::action::Action::ShowHelp),
        );
    }

    #[test]
    fn invalid_user_binding_errors_with_diagnostic() {
        let mut section = TuiSection::default();
        section.keymap_normal.insert("ctrl-glorb".into(), "EnterInsert".into());
        let err = TuiConfig::load(&section).unwrap_err();
        assert!(format!("{err}").contains("ctrl-glorb"));
    }
}
  • Step 3: Implement TuiConfig with shipped defaults
// crates/maestro-tui/src/config.rs
use std::time::Duration;

use maestro_config::TuiSection;

use crate::action::Action;
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};

#[derive(Debug)]
pub struct TuiConfig {
    pub leader: InputKey,
    pub chord_timeout: Duration,
    pub keymap: Keymap,
}

#[derive(Debug, thiserror::Error)]
pub enum TuiConfigError {
    #[error("invalid leader key: {0}")]
    BadLeader(String),
    #[error("invalid keymap entry [{mode:?}] {input:?} = {action:?}: {source}")]
    BadKeymap { mode: Mode, input: String, action: String, source: KeymapError },
}

/// Bindings that ship by default. These are *defaults*, not contract — the
/// shipped defaults will be tuned during dogfooding. Every action in v1 is
/// reachable from this table.
const DEFAULT_NORMAL: &[(&str, &str)] = &[
    ("i",          "EnterInsert"),
    ("s",          "OpenSpawnModal"),
    ("x",          "KillHighlighted"),
    ("?",          "ShowHelp"),
    ("ctrl-t",     "NewTab"),
    ("ctrl-w c",   "CloseTab"),
    ("g t",        "NextTab"),
    ("g T",        "PrevTab"),
    ("ctrl-w h",   "FocusSidebar"),
    ("enter",      "AttachHighlighted"),
    (" ",          "ToggleAttachHighlighted"),
    ("ctrl-w b",   "ToggleSidebar"),
    ("ctrl-w q",   "Quit"),
    ("mouse:click(pane)",          "FocusSidebar"),
    ("mouse:dblclick(sidebar-row)","AttachHighlighted"),
    ("mouse:click(tab)",           "NextTab"),
];

const DEFAULT_INSERT: &[(&str, &str)] = &[
    // Insert mode only intercepts the leader; the rest passes through.
    // The leader binding is inserted dynamically in load() below.
];

impl TuiConfig {
    pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
        let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
        let leader = InputKey::parse(leader_str)
            .map_err(|_| TuiConfigError::BadLeader(leader_str.into()))?;
        let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));

        let mut keymap = Keymap::default();

        // Apply shipped defaults first.
        for (input, action) in DEFAULT_NORMAL {
            keymap
                .set(Mode::Normal, input, action)
                .map_err(|source| TuiConfigError::BadKeymap {
                    mode: Mode::Normal,
                    input: (*input).into(),
                    action: (*action).into(),
                    source,
                })?;
        }
        for (input, action) in DEFAULT_INSERT {
            keymap.set(Mode::Insert, input, action).map_err(|source| TuiConfigError::BadKeymap {
                mode: Mode::Insert,
                input: (*input).into(),
                action: (*action).into(),
                source,
            })?;
        }
        // Insert-mode default: leader → LeaveInsert.
        keymap
            .set(Mode::Insert, &leader.canonical(), "LeaveInsert")
            .expect("leader binding well-formed by construction");

        // Apply user overrides.
        for (input, action) in &section.keymap_normal {
            keymap
                .set(Mode::Normal, input, action)
                .map_err(|source| TuiConfigError::BadKeymap {
                    mode: Mode::Normal, input: input.clone(), action: action.clone(), source,
                })?;
        }
        for (input, action) in &section.keymap_insert {
            keymap
                .set(Mode::Insert, input, action)
                .map_err(|source| TuiConfigError::BadKeymap {
                    mode: Mode::Insert, input: input.clone(), action: action.clone(), source,
                })?;
        }

        Ok(TuiConfig { leader, chord_timeout, keymap })
    }
}

Add pub mod config; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui config::ts Expected: 3 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/ crates/maestro-config/src/lib.rs
git commit -m "feat(maestro-tui): TuiConfig with shipped defaults and merge-over-user-overrides"

Phase 2 — Catalog

Task 6: CatalogStore + REST bootstrap

Files:

  • Create: crates/maestro-tui/src/catalog.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing test using a mocked daemon

// crates/maestro-tui/src/catalog.rs
#[cfg(test)]
mod tests {
    use super::*;
    use axum::{routing::get, Router, Json};
    use maestro_protocol::{DaemonInfo, Session, SessionStatus};
    use std::net::SocketAddr;
    use uuid::Uuid;

    async fn spawn_fake_daemon() -> SocketAddr {
        let daemons = vec![DaemonInfo {
            daemon_id: "fake".into(),
            hostname: "fake-host".into(),
            version: "test".into(),
            daemon_url: "ws://127.0.0.1:0".into(),
        }];
        let sessions: Vec<Session> = vec![];
        let app = Router::new()
            .route("/api/daemons", get(move || async move { Json(daemons.clone()) }))
            .route("/api/sessions", get(move || async move { Json::<Vec<Session>>(sessions.clone()) }));
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
        addr
    }

    #[tokio::test]
    async fn bootstrap_loads_daemons_and_sessions() {
        let addr = spawn_fake_daemon().await;
        let base = format!("http://{addr}");
        let store = CatalogStore::default();
        store.bootstrap(&base).await.unwrap();
        let snap = store.snapshot();
        assert_eq!(snap.daemons.len(), 1);
        assert_eq!(snap.daemons[0].daemon_id, "fake");
        assert!(snap.sessions.is_empty());
        assert_eq!(snap.version, 1);
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui catalog::tests::bootstrap_loads_daemons_and_sessions Expected: errors that CatalogStore doesn't exist.

  • Step 3: Implement CatalogStore
// crates/maestro-tui/src/catalog.rs
use std::collections::HashMap;
use std::sync::Arc;

use maestro_protocol::{DaemonInfo, Session};
use parking_lot::RwLock; // add `parking_lot = "0.12"` to Cargo.toml deps in this same step
use tokio::sync::Notify;
use uuid::Uuid;

#[derive(Debug, Clone, Default)]
pub struct CatalogSnapshot {
    pub daemons: Vec<DaemonInfo>,
    pub sessions: Vec<Session>,
    pub version: u64,
}

#[derive(Debug, Default, Clone)]
pub struct CatalogStore {
    inner: Arc<Inner>,
}

#[derive(Debug, Default)]
struct Inner {
    state: RwLock<State>,
    notify: Notify,
}

#[derive(Debug, Default)]
struct State {
    daemons: HashMap<String, DaemonInfo>,
    sessions: HashMap<Uuid, Session>,
    version: u64,
}

#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
    #[error("http: {0}")]
    Http(#[from] reqwest::Error),
    #[error("bad response: {0}")]
    BadResponse(String),
}

impl CatalogStore {
    pub async fn bootstrap(&self, base_url: &str) -> Result<(), CatalogError> {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(5))
            .build()?;
        let daemons: Vec<DaemonInfo> = client
            .get(format!("{base_url}/api/daemons")).send().await?.error_for_status()?.json().await?;
        let sessions: Vec<Session> = client
            .get(format!("{base_url}/api/sessions")).send().await?.error_for_status()?.json().await?;
        let mut s = self.inner.state.write();
        s.daemons = daemons.into_iter().map(|d| (d.daemon_id.clone(), d)).collect();
        s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
        s.version += 1;
        drop(s);
        self.inner.notify.notify_waiters();
        Ok(())
    }

    pub fn snapshot(&self) -> CatalogSnapshot {
        let s = self.inner.state.read();
        let mut daemons: Vec<_> = s.daemons.values().cloned().collect();
        daemons.sort_by(|a, b| a.daemon_id.cmp(&b.daemon_id));
        let mut sessions: Vec<_> = s.sessions.values().cloned().collect();
        sessions.sort_by_key(|s| s.id);
        CatalogSnapshot { daemons, sessions, version: s.version }
    }

    pub fn changed_notify(&self) -> Arc<Notify> {
        Arc::new(Notify::new()) // placeholder; replaced by real handle in next task
    }
}

Add parking_lot = "0.12" to crates/maestro-tui/Cargo.toml [dependencies]. Add pub mod catalog; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui catalog::tests Expected: 1 passing.

  • Step 5: Commit
git add crates/maestro-tui/
git commit -m "feat(maestro-tui): CatalogStore with REST bootstrap"

Task 7: Events WS subscription + reconcile

Files:

  • Modify: crates/maestro-tui/src/catalog.rs

  • Step 1: Write the failing test for event application

// inside crates/maestro-tui/src/catalog.rs `#[cfg(test)] mod tests`
use maestro_protocol::{RegistryEvent, Session, SessionStatus};

#[test]
fn applies_session_created_then_ended() {
    let store = CatalogStore::default();
    let sess = Session {
        id: Uuid::from_u128(1),
        daemon_id: "fake".into(),
        label: Some("a".into()),
        command: "bash".into(),
        cwd: "/tmp".into(),
        env: Default::default(),
        status: SessionStatus::Running,
        created_at: chrono::Utc::now(),
    };
    store.apply_event(RegistryEvent::SessionCreated(sess.clone()));
    assert_eq!(store.snapshot().sessions.len(), 1);
    store.apply_event(RegistryEvent::SessionEnded { id: sess.id });
    assert!(store.snapshot().sessions.is_empty());
}

(Inspect crates/maestro-protocol/src/lib.rs to confirm the exact RegistryEvent variants; adjust the test's variant names to match. If the protocol uses different field names or SessionStatus shape, follow the current source — DO NOT invent fields.)

  • Step 2: Run — fail

Run: cargo test -p maestro-tui catalog::tests::applies_session_created_then_ended Expected: error that apply_event doesn't exist.

  • Step 3: Implement event application + WS receive loop

Append to crates/maestro-tui/src/catalog.rs:

use futures_util::StreamExt;
use maestro_protocol::RegistryEvent;
use tokio_tungstenite::tungstenite::Message;

impl CatalogStore {
    pub fn apply_event(&self, ev: RegistryEvent) {
        let mut s = self.inner.state.write();
        match ev {
            RegistryEvent::SessionCreated(sess) => { s.sessions.insert(sess.id, sess); }
            RegistryEvent::SessionUpdated(sess) => { s.sessions.insert(sess.id, sess); }
            RegistryEvent::SessionEnded { id }  => { s.sessions.remove(&id); }
            RegistryEvent::DaemonJoined(d)      => { s.daemons.insert(d.daemon_id.clone(), d); }
            RegistryEvent::DaemonLeft { id }    => { s.daemons.remove(&id); }
        }
        s.version += 1;
        drop(s);
        self.inner.notify.notify_waiters();
    }

    /// Long-running task: connect to `WS ws_url`, parse RegistryEvent frames,
    /// apply them to the store. Reconnects with exponential backoff on
    /// disconnect; rebootstraps via REST on reconnect.
    pub async fn run_events_loop(self, ws_url: String, rest_base: String) {
        let mut backoff = std::time::Duration::from_secs(1);
        loop {
            match tokio_tungstenite::connect_async(&ws_url).await {
                Ok((ws, _)) => {
                    backoff = std::time::Duration::from_secs(1);
                    let (_sink, mut stream) = ws.split();
                    while let Some(Ok(msg)) = stream.next().await {
                        if let Message::Text(t) = msg {
                            if let Ok(ev) = serde_json::from_str::<RegistryEvent>(&t) {
                                self.apply_event(ev);
                            }
                        }
                    }
                    // Disconnect: rebootstrap snapshot before reconnect.
                    let _ = self.bootstrap(&rest_base).await;
                }
                Err(_) => {
                    tokio::time::sleep(backoff).await;
                    backoff = std::cmp::min(backoff * 2, std::time::Duration::from_secs(30));
                    continue;
                }
            }
        }
    }
}
  • Step 4: Run — pass

Run: cargo test -p maestro-tui catalog::tests Expected: 2 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/catalog.rs
git commit -m "feat(maestro-tui): events WS subscription with reconnect-and-rebootstrap"

Phase 3 — Session liveness

Task 8: SessionTask (WS + vt100 parser)

Files:

  • Create: crates/maestro-tui/src/session_task.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing test using an in-process WS server

// crates/maestro-tui/src/session_task.rs
#[cfg(test)]
mod tests {
    use super::*;
    use futures_util::SinkExt;
    use tokio_tungstenite::tungstenite::Message;

    #[tokio::test]
    async fn parser_consumes_bytes_into_screen() {
        // Stand up a fake WS server that, on connect, sends "hello\r\n".
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move {
            let (stream, _) = listener.accept().await.unwrap();
            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
            ws.send(Message::Binary(b"hello\r\n".to_vec())).await.unwrap();
            // Keep the connection open until the client closes.
            while ws.next().await.is_some() {}
        });

        let url = format!("ws://{addr}/api/attach/dummy");
        let handle = SessionTask::spawn(url, 24, 80).await.unwrap();

        // Poll the screen for up to 500 ms looking for "hello".
        for _ in 0..50 {
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
            let scr = handle.screen.lock();
            let rendered: String = (0..1).map(|r| scr.row_contents(r).collect::<String>()).collect();
            if rendered.starts_with("hello") {
                return;
            }
        }
        panic!("screen never contained 'hello'");
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui session_task:: Expected: errors that SessionTask doesn't exist.

  • Step 3: Implement SessionTask
// crates/maestro-tui/src/session_task.rs
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};

use futures_util::{SinkExt, StreamExt};
use maestro_protocol::ClientControl;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;

#[derive(Debug)]
pub enum SessionInput {
    Bytes(Vec<u8>),
    Resize { cols: u16, rows: u16 },
}

#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState { Connecting = 0, Live = 1, Reconnecting = 2, Ended = 3 }

#[derive(Debug, Clone)]
pub struct SessionHandle {
    pub tx: mpsc::Sender<SessionInput>,
    pub screen: Arc<Mutex<vt100::Parser>>,
    pub state: Arc<AtomicU8>,
}

impl SessionHandle {
    pub fn state(&self) -> SessionState {
        match self.state.load(Ordering::Acquire) {
            0 => SessionState::Connecting,
            1 => SessionState::Live,
            2 => SessionState::Reconnecting,
            _ => SessionState::Ended,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SessionTaskError {
    #[error("ws: {0}")]
    Ws(#[from] tokio_tungstenite::tungstenite::Error),
}

/// Just the constructor — the actual long-running task is spawned on a Tokio
/// runtime via `spawn`.
#[derive(Debug)]
pub struct SessionTask;

impl SessionTask {
    pub async fn spawn(ws_url: String, rows: u16, cols: u16) -> Result<SessionHandle, SessionTaskError> {
        let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
        let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
        let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));

        let parser_for_task = parser.clone();
        let state_for_task  = state.clone();

        tokio::spawn(async move {
            let url_owned = ws_url;
            let mut backoff = std::time::Duration::from_secs(1);
            loop {
                state_for_task.store(SessionState::Connecting as u8, Ordering::Release);
                let (ws, _) = match tokio_tungstenite::connect_async(&url_owned).await {
                    Ok(x) => x,
                    Err(_) => {
                        state_for_task.store(SessionState::Reconnecting as u8, Ordering::Release);
                        tokio::time::sleep(backoff).await;
                        backoff = std::cmp::min(backoff * 2, std::time::Duration::from_secs(30));
                        continue;
                    }
                };
                backoff = std::time::Duration::from_secs(1);
                state_for_task.store(SessionState::Live as u8, Ordering::Release);
                let (mut sink, mut stream) = ws.split();

                let parser_in = parser_for_task.clone();
                let recv = async {
                    while let Some(m) = stream.next().await {
                        match m {
                            Ok(Message::Binary(bytes)) => {
                                parser_in.lock().process(&bytes);
                            }
                            Ok(Message::Text(t)) => {
                                if serde_json::from_str::<ClientControl>(&t)
                                    .map(|c| matches!(c, ClientControl::Ping))
                                    .unwrap_or(false)
                                {
                                    // Pong handled in the send half via a side-channel
                                    // if we add it. v1 leaves Ping unanswered — harmless.
                                }
                            }
                            Ok(Message::Close(_)) | Err(_) => return false,
                            _ => {}
                        }
                    }
                    false
                };

                let send = async {
                    while let Some(inp) = rx.recv().await {
                        match inp {
                            SessionInput::Bytes(b) => {
                                if sink.send(Message::Binary(b)).await.is_err() { return; }
                            }
                            SessionInput::Resize { cols, rows } => {
                                let frame = serde_json::to_string(&ClientControl::Resize { cols, rows })
                                    .expect("serialize resize");
                                if sink.send(Message::Text(frame)).await.is_err() { return; }
                            }
                        }
                    }
                };

                tokio::select! {
                    _ = recv => {},
                    _ = send => {},
                }

                // On disconnect, mark reconnecting and loop.
                state_for_task.store(SessionState::Reconnecting as u8, Ordering::Release);
            }
        });

        Ok(SessionHandle { tx, screen: parser, state })
    }
}

Add pub mod session_task; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui session_task:: Expected: 1 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): per-session WS+vt100 task with reconnect"

Task 9: SessionEmulators registry + min-size resize

Files:

  • Create: crates/maestro-tui/src/emulators.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing test

// crates/maestro-tui/src/emulators.rs
#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;

    #[test]
    fn per_session_min_picks_smallest_per_dim() {
        let mut tracker = ResizeTracker::default();
        let sid = Uuid::from_u128(1);
        tracker.set_pane_size(sid, "pane-a", (100, 30));
        tracker.set_pane_size(sid, "pane-b", (80, 40));
        assert_eq!(tracker.session_size(sid), Some((80, 30)));
        // Independent dimensions: cols from b, rows from a.
    }

    #[test]
    fn unset_session_has_no_size() {
        let tracker = ResizeTracker::default();
        assert_eq!(tracker.session_size(Uuid::from_u128(9)), None);
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui emulators::tests Expected: errors that ResizeTracker doesn't exist.

  • Step 3: Implement registry
// crates/maestro-tui/src/emulators.rs
use std::collections::HashMap;

use uuid::Uuid;

use crate::session_task::SessionHandle;

#[derive(Debug, Default)]
pub struct SessionEmulators {
    handles: HashMap<Uuid, SessionHandle>,
    resize: ResizeTracker,
}

impl SessionEmulators {
    pub fn get(&self, id: Uuid) -> Option<&SessionHandle> { self.handles.get(&id) }
    pub fn insert(&mut self, id: Uuid, h: SessionHandle) { self.handles.insert(id, h); }
    pub fn remove(&mut self, id: Uuid) { self.handles.remove(&id); self.resize.drop_session(id); }
    pub fn resize_tracker_mut(&mut self) -> &mut ResizeTracker { &mut self.resize }
}

#[derive(Debug, Default)]
pub struct ResizeTracker {
    // (SessionId, PaneId) -> (cols, rows)
    per_pane: HashMap<(Uuid, String), (u16, u16)>,
}

impl ResizeTracker {
    pub fn set_pane_size(&mut self, sid: Uuid, pane_id: &str, size: (u16, u16)) {
        self.per_pane.insert((sid, pane_id.to_string()), size);
    }

    pub fn session_size(&self, sid: Uuid) -> Option<(u16, u16)> {
        let mut cols: Option<u16> = None;
        let mut rows: Option<u16> = None;
        for ((s, _), (c, r)) in &self.per_pane {
            if *s == sid {
                cols = Some(cols.map_or(*c, |x| x.min(*c)));
                rows = Some(rows.map_or(*r, |x| x.min(*r)));
            }
        }
        match (cols, rows) {
            (Some(c), Some(r)) => Some((c, r)),
            _ => None,
        }
    }

    pub fn drop_session(&mut self, sid: Uuid) {
        self.per_pane.retain(|(s, _), _| *s != sid);
    }
}

Add pub mod emulators; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui emulators::tests Expected: 2 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): SessionEmulators registry with per-dimension min resize"

Phase 4 — Top-level state

Task 10: AppState (mode, tabs, focus)

Files:

  • Create: crates/maestro-tui/src/state.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing tests

// crates/maestro-tui/src/state.rs
#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;

    #[test]
    fn new_app_starts_with_one_empty_tab_focused_on_sidebar() {
        let state = AppState::new();
        assert_eq!(state.tabs.len(), 1);
        assert_eq!(state.tabs[0].pane, None);
        assert_eq!(state.focus, Focus::Sidebar);
        assert_eq!(state.mode, crate::keymap::Mode::Normal);
    }

    #[test]
    fn new_tab_extends_and_focuses() {
        let mut state = AppState::new();
        state.new_tab();
        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.active_tab_idx, 1);
    }

    #[test]
    fn close_tab_keeps_at_least_one() {
        let mut state = AppState::new();
        state.close_tab(); // attempting to close the only tab is a no-op
        assert_eq!(state.tabs.len(), 1);
        state.new_tab();
        state.close_tab();
        assert_eq!(state.tabs.len(), 1);
    }

    #[test]
    fn attach_binds_session_to_focused_pane() {
        let mut state = AppState::new();
        let sid = Uuid::from_u128(7);
        state.attach_to_focused(sid);
        assert_eq!(state.tabs[0].pane, Some(sid));
        // Focus moves into the pane after attach.
        assert_eq!(state.focus, Focus::Pane);
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui state::tests Expected: errors that AppState, Focus, etc. don't exist.

  • Step 3: Implement AppState
// crates/maestro-tui/src/state.rs
use uuid::Uuid;

use crate::keymap::Mode;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus { Sidebar, Pane }

#[derive(Debug, Clone)]
pub struct Tab {
    pub name: String,
    /// v1: each tab has exactly one pane bound to an optional session.
    /// v1.5 replaces this with a layout tree.
    pub pane: Option<Uuid>,
}

#[derive(Debug)]
pub struct AppState {
    pub tabs: Vec<Tab>,
    pub active_tab_idx: usize,
    pub focus: Focus,
    pub mode: Mode,
    pub sidebar_visible: bool,
    pub sidebar_highlight: Option<Uuid>,
    pub overlay: Option<OverlayKind>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
    Spawn,
    KillConfirm(Uuid),
    Help,
    Toast(String),
}

impl AppState {
    pub fn new() -> Self {
        Self {
            tabs: vec![Tab { name: "1".into(), pane: None }],
            active_tab_idx: 0,
            focus: Focus::Sidebar,
            mode: Mode::Normal,
            sidebar_visible: true,
            sidebar_highlight: None,
            overlay: None,
        }
    }

    pub fn active_tab(&self) -> &Tab { &self.tabs[self.active_tab_idx] }
    pub fn active_tab_mut(&mut self) -> &mut Tab { &mut self.tabs[self.active_tab_idx] }

    pub fn new_tab(&mut self) {
        let name = format!("{}", self.tabs.len() + 1);
        self.tabs.push(Tab { name, pane: None });
        self.active_tab_idx = self.tabs.len() - 1;
        self.focus = Focus::Sidebar;
    }

    pub fn close_tab(&mut self) {
        if self.tabs.len() <= 1 { return; }
        self.tabs.remove(self.active_tab_idx);
        if self.active_tab_idx >= self.tabs.len() {
            self.active_tab_idx = self.tabs.len() - 1;
        }
    }

    pub fn next_tab(&mut self) {
        if self.tabs.is_empty() { return; }
        self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
    }
    pub fn prev_tab(&mut self) {
        if self.tabs.is_empty() { return; }
        self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
    }
    pub fn goto_tab(&mut self, n: u8) {
        let idx = (n as usize).saturating_sub(1).min(self.tabs.len().saturating_sub(1));
        self.active_tab_idx = idx;
    }

    pub fn attach_to_focused(&mut self, session_id: Uuid) {
        match self.focus {
            Focus::Sidebar | Focus::Pane => {
                self.active_tab_mut().pane = Some(session_id);
                self.focus = Focus::Pane;
            }
        }
    }
}

Add pub mod state; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui state::tests Expected: 4 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): AppState with tab/focus/mode/overlay"

Phase 5 — Rendering

Task 11: Terminal RAII guard + panic hook

Files:

  • Create: crates/maestro-tui/src/terminal.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the smoke test

// crates/maestro-tui/src/terminal.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn install_panic_hook_is_idempotent() {
        install_panic_hook();
        install_panic_hook(); // re-installing must not panic.
    }
}
  • Step 2: Run — fail

Run: cargo test -p maestro-tui terminal::tests Expected: error that install_panic_hook doesn't exist.

  • Step 3: Implement RAII guard + panic hook
// crates/maestro-tui/src/terminal.rs
use std::io;

use crossterm::execute;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};

#[derive(Debug)]
pub struct TerminalGuard;

#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
    #[error("io: {0}")]
    Io(#[from] io::Error),
}

impl TerminalGuard {
    pub fn enter() -> Result<Self, TerminalError> {
        enable_raw_mode()?;
        let mut out = io::stdout();
        execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
        Ok(TerminalGuard)
    }

    fn restore() {
        let mut out = io::stdout();
        let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
        let _ = disable_raw_mode();
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) { Self::restore(); }
}

static HOOK_INSTALLED: std::sync::Once = std::sync::Once::new();

pub fn install_panic_hook() {
    HOOK_INSTALLED.call_once(|| {
        let prev = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |info| {
            TerminalGuard::restore();
            prev(info);
        }));
    });
}

Add pub mod terminal; to lib.rs.

  • Step 4: Run — pass

Run: cargo test -p maestro-tui terminal::tests Expected: 1 passing.

  • Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): TerminalGuard + idempotent panic hook"

Task 12: Sidebar widget + snapshot test

Files:

  • Create: crates/maestro-tui/src/sidebar.rs

  • Create: crates/maestro-tui/tests/snapshots/sidebar_basic.txt

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Write the failing snapshot test

// crates/maestro-tui/src/sidebar.rs
#[cfg(test)]
mod tests {
    use super::*;
    use maestro_protocol::{DaemonInfo, Session, SessionStatus};
    use ratatui::{backend::TestBackend, Terminal};
    use uuid::Uuid;

    fn make_snapshot(sb: &Sidebar) -> String {
        let backend = TestBackend::new(28, 12);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| sb.render(f, f.area())).unwrap();
        let buf = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                out.push_str(buf[(x, y)].symbol());
            }
            out.push('\n');
        }
        out
    }

    #[test]
    fn renders_one_daemon_two_sessions() {
        let daemons = vec![DaemonInfo {
            daemon_id: "konishi".into(), hostname: "konishi".into(),
            version: "0".into(), daemon_url: "ws://x".into(),
        }];
        let sessions = vec![
            Session {
                id: Uuid::from_u128(1), daemon_id: "konishi".into(),
                label: Some("claude".into()), command: "claude".into(), cwd: "/x".into(),
                env: Default::default(), status: SessionStatus::Running,
                created_at: chrono::Utc::now(),
            },
            Session {
                id: Uuid::from_u128(2), daemon_id: "konishi".into(),
                label: Some("bash".into()), command: "bash".into(), cwd: "/x".into(),
                env: Default::default(), status: SessionStatus::Running,
                created_at: chrono::Utc::now(),
            },
        ];
        let sb = Sidebar { daemons, sessions, highlight: None, attached: Default::default() };
        let rendered = make_snapshot(&sb);
        let expected = std::fs::read_to_string("tests/snapshots/sidebar_basic.txt").unwrap();
        assert_eq!(rendered, expected);
    }
}
  • Step 2: Implement Sidebar (so we can generate the first snapshot)
// crates/maestro-tui/src/sidebar.rs
use std::collections::HashSet;

use maestro_protocol::{DaemonInfo, Session};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Style, Modifier},
    widgets::{Block, Borders, Paragraph},
    text::{Line, Span},
};
use uuid::Uuid;

#[derive(Debug)]
pub struct Sidebar {
    pub daemons: Vec<DaemonInfo>,
    pub sessions: Vec<Session>,
    pub highlight: Option<Uuid>,
    pub attached: HashSet<Uuid>,
}

impl Sidebar {
    pub fn render(&self, f: &mut Frame, area: Rect) {
        let block = Block::default().title(" daemons ").borders(Borders::ALL);
        let inner = block.inner(area);
        f.render_widget(block, area);

        let mut lines: Vec<Line> = Vec::new();
        for d in &self.daemons {
            lines.push(Line::from(format!("● {}", d.hostname)));
            for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
                let glyph = if self.attached.contains(&s.id) { "●" } else { "○" };
                let label = s.label.clone().unwrap_or_else(|| s.command.clone());
                let style = if self.highlight == Some(s.id) {
                    Style::default().add_modifier(Modifier::REVERSED)
                } else {
                    Style::default()
                };
                lines.push(Line::from(vec![
                    Span::raw("  "),
                    Span::styled(format!("{glyph} {label}"), style),
                ]));
            }
        }
        f.render_widget(Paragraph::new(lines), inner);
    }
}

Add pub mod sidebar; to lib.rs.

  • Step 3: Generate the golden snapshot

Run the test once with the file missing — it'll fail; copy the actual rendered output into crates/maestro-tui/tests/snapshots/sidebar_basic.txt. Then re-run.

Run: cargo test -p maestro-tui sidebar::tests::renders_one_daemon_two_sessions Expected (first run): fails because the snapshot file doesn't exist.

Copy the printed output (visible by adding a dbg!(&rendered) in the test temporarily) into the snapshot file. Remove dbg!. Re-run. Expected (second run): pass.

  • Step 4: Commit
git add crates/maestro-tui/src/sidebar.rs crates/maestro-tui/tests/snapshots/sidebar_basic.txt
git commit -m "feat(maestro-tui): sidebar widget + golden snapshot"

Task 13: Tab bar widget + snapshot

Files:

  • Create: crates/maestro-tui/src/tabs.rs

  • Create: crates/maestro-tui/tests/snapshots/tabbar_basic.txt

  • Step 1: Write the failing snapshot test (analogous pattern to Task 12)

// crates/maestro-tui/src/tabs.rs
#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::{backend::TestBackend, Terminal};

    #[test]
    fn renders_three_tabs_with_active_highlighted() {
        let bar = TabBar { tab_names: vec!["1".into(), "2".into(), "3".into()], active_idx: 1 };
        let backend = TestBackend::new(40, 3);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| bar.render(f, f.area())).unwrap();
        let buf = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
            out.push('\n');
        }
        let expected = std::fs::read_to_string("tests/snapshots/tabbar_basic.txt").unwrap();
        assert_eq!(out, expected);
    }
}
  • Step 2: Implement TabBar
// crates/maestro-tui/src/tabs.rs
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    widgets::Tabs,
    text::Line,
};

#[derive(Debug)]
pub struct TabBar {
    pub tab_names: Vec<String>,
    pub active_idx: usize,
}

impl TabBar {
    pub fn render(&self, f: &mut Frame, area: Rect) {
        let titles: Vec<Line> = self.tab_names.iter().map(|n| Line::from(n.as_str())).collect();
        let tabs = Tabs::new(titles)
            .select(self.active_idx)
            .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
        f.render_widget(tabs, area);
    }
}

Add pub mod tabs; to lib.rs.

  • Step 3: Generate golden, run test until pass. Same pattern as Task 12.

  • Step 4: Commit

git add crates/maestro-tui/src/tabs.rs crates/maestro-tui/tests/snapshots/tabbar_basic.txt
git commit -m "feat(maestro-tui): tab bar widget + snapshot"

Task 14: Attach pane widget (vt100 → buffer) + snapshot

Files:

  • Create: crates/maestro-tui/src/pane.rs

  • Create: crates/maestro-tui/tests/snapshots/pane_hello.txt

  • Step 1: Write the failing snapshot test

// crates/maestro-tui/src/pane.rs
#[cfg(test)]
mod tests {
    use super::*;
    use parking_lot::Mutex;
    use ratatui::{backend::TestBackend, Terminal};
    use std::sync::Arc;

    #[test]
    fn renders_vt100_screen_with_hello() {
        let parser = Arc::new(Mutex::new(vt100::Parser::new(5, 20, 0)));
        parser.lock().process(b"hello world");
        let pane = Pane { screen: parser, status: PaneStatus::Live, focused: false };

        let backend = TestBackend::new(20, 5);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| pane.render(f, f.area())).unwrap();
        let buf = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
            out.push('\n');
        }
        let expected = std::fs::read_to_string("tests/snapshots/pane_hello.txt").unwrap();
        assert_eq!(out, expected);
    }
}
  • Step 2: Implement Pane
// crates/maestro-tui/src/pane.rs
use std::sync::Arc;

use parking_lot::Mutex;
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Style},
    buffer::Buffer,
    widgets::Widget,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneStatus { Connecting, Live, Reconnecting, Ended }

#[derive(Debug)]
pub struct Pane {
    pub screen: Arc<Mutex<vt100::Parser>>,
    pub status: PaneStatus,
    pub focused: bool,
}

impl Pane {
    pub fn render(&self, f: &mut Frame, area: Rect) {
        let parser = self.screen.lock();
        let scr = parser.screen();
        let buf = f.buffer_mut();
        let (rows, cols) = (area.height.min(scr.size().0), area.width.min(scr.size().1));
        for r in 0..rows {
            for c in 0..cols {
                if let Some(cell) = scr.cell(r, c) {
                    let s = cell.contents();
                    let s = if s.is_empty() { " " } else { s };
                    buf[(area.x + c, area.y + r)]
                        .set_symbol(s)
                        .set_style(translate_cell_style(cell));
                }
            }
        }
    }
}

fn translate_cell_style(cell: &vt100::Cell) -> Style {
    use vt100::Color::*;
    fn cvt(c: vt100::Color) -> Option<Color> {
        Some(match c {
            Default => return None,
            Idx(i) => Color::Indexed(i),
            Rgb(r, g, b) => Color::Rgb(r, g, b),
        })
    }
    let mut s = Style::default();
    if let Some(c) = cvt(cell.fgcolor()) { s = s.fg(c); }
    if let Some(c) = cvt(cell.bgcolor()) { s = s.bg(c); }
    s
}

Add pub mod pane; to lib.rs.

  • Step 3: Generate golden, run test until pass.

  • Step 4: Commit

git add crates/maestro-tui/src/pane.rs crates/maestro-tui/tests/snapshots/pane_hello.txt
git commit -m "feat(maestro-tui): pane widget mapping vt100 cells into ratatui buffer"

Task 15: Overlay widgets (Spawn modal, Kill confirm, Help, Toast)

Files:

  • Create: crates/maestro-tui/src/overlays.rs

  • Create: crates/maestro-tui/tests/snapshots/spawn_modal.txt

  • Create: crates/maestro-tui/tests/snapshots/help_overlay.txt

  • Step 1: Sketch each overlay's API

// crates/maestro-tui/src/overlays.rs
use maestro_protocol::DaemonInfo;
use ratatui::{
    Frame, layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
    text::{Line, Span},
};

#[derive(Debug, Default)]
pub struct SpawnModal {
    pub daemons: Vec<DaemonInfo>,
    pub daemon_idx: usize,
    pub cwd: String,
    pub command: String,
    pub label: String,
    pub focused_field: SpawnField,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
    #[default] Daemon, Cwd, Command, Label, Submit,
}

impl SpawnModal {
    pub fn next_field(&mut self) { /* … */ }
    pub fn prev_field(&mut self) { /* … */ }
    pub fn render(&self, f: &mut Frame, parent: Rect) {
        let area = centered(parent, 60, 14);
        f.render_widget(Clear, area);
        let block = Block::default().title(" spawn ").borders(Borders::ALL);
        let inner = block.inner(area);
        f.render_widget(block, area);
        let rows = Layout::vertical([
            Constraint::Length(2), Constraint::Length(2), Constraint::Length(2),
            Constraint::Length(2), Constraint::Length(1),
        ]).split(inner);
        let style = |f: SpawnField| if self.focused_field == f {
            Style::default().add_modifier(Modifier::REVERSED)
        } else { Style::default() };
        let daemon_label = self.daemons.get(self.daemon_idx).map(|d| d.hostname.as_str()).unwrap_or("(none)");
        f.render_widget(Paragraph::new(vec![Line::from(vec![
            Span::raw("daemon:  "), Span::styled(daemon_label, style(SpawnField::Daemon)),
        ])]), rows[0]);
        f.render_widget(Paragraph::new(vec![Line::from(vec![
            Span::raw("cwd:     "), Span::styled(self.cwd.as_str(), style(SpawnField::Cwd)),
        ])]), rows[1]);
        f.render_widget(Paragraph::new(vec![Line::from(vec![
            Span::raw("command: "), Span::styled(self.command.as_str(), style(SpawnField::Command)),
        ])]), rows[2]);
        f.render_widget(Paragraph::new(vec![Line::from(vec![
            Span::raw("label:   "), Span::styled(self.label.as_str(), style(SpawnField::Label)),
        ])]), rows[3]);
        f.render_widget(Paragraph::new(vec![Line::from(vec![
            Span::styled("[ Spawn ]", style(SpawnField::Submit)),
        ])]), rows[4]);
    }
}

#[derive(Debug)]
pub struct KillConfirm { pub label: String }
impl KillConfirm {
    pub fn render(&self, f: &mut Frame, parent: Rect) {
        let area = centered(parent, 50, 5);
        f.render_widget(Clear, area);
        let block = Block::default().title(" kill ").borders(Borders::ALL);
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(vec![
                Line::from(format!("Kill session {}?", self.label)),
                Line::from(""),
                Line::from("[y] yes   [n] no"),
            ]),
            inner,
        );
    }
}

#[derive(Debug)]
pub struct HelpOverlay { pub lines: Vec<String> }
impl HelpOverlay {
    pub fn render(&self, f: &mut Frame, parent: Rect) {
        let area = centered(parent, 70, parent.height.saturating_sub(4));
        f.render_widget(Clear, area);
        let block = Block::default().title(" help ").borders(Borders::ALL);
        let inner = block.inner(area);
        f.render_widget(block, area);
        let items: Vec<ListItem> = self.lines.iter().map(|s| ListItem::new(s.as_str())).collect();
        f.render_widget(List::new(items), inner);
    }
}

#[derive(Debug)]
pub struct Toast { pub text: String }
impl Toast {
    pub fn render(&self, f: &mut Frame, parent: Rect) {
        let h = 3u16; let w = parent.width.min(self.text.len() as u16 + 4);
        let area = Rect { x: parent.x + (parent.width - w) / 2, y: parent.y + parent.height - h - 1, width: w, height: h };
        f.render_widget(Clear, area);
        let block = Block::default().borders(Borders::ALL);
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(Paragraph::new(self.text.as_str()), inner);
    }
}

fn centered(parent: Rect, w: u16, h: u16) -> Rect {
    let w = w.min(parent.width);
    let h = h.min(parent.height);
    Rect {
        x: parent.x + (parent.width.saturating_sub(w)) / 2,
        y: parent.y + (parent.height.saturating_sub(h)) / 2,
        width: w, height: h,
    }
}

Add pub mod overlays; to lib.rs. Implement next_field/prev_field rotating Daemon → Cwd → Command → Label → Submit → Daemon:

impl SpawnModal {
    pub fn next_field(&mut self) {
        self.focused_field = match self.focused_field {
            SpawnField::Daemon  => SpawnField::Cwd,
            SpawnField::Cwd     => SpawnField::Command,
            SpawnField::Command => SpawnField::Label,
            SpawnField::Label   => SpawnField::Submit,
            SpawnField::Submit  => SpawnField::Daemon,
        };
    }
    pub fn prev_field(&mut self) {
        self.focused_field = match self.focused_field {
            SpawnField::Daemon  => SpawnField::Submit,
            SpawnField::Cwd     => SpawnField::Daemon,
            SpawnField::Command => SpawnField::Cwd,
            SpawnField::Label   => SpawnField::Command,
            SpawnField::Submit  => SpawnField::Label,
        };
    }

    /// Keyboard handling while the spawn modal owns input. Returns true if the
    /// modal should close (Submit pressed, caller fires the POST).
    pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
        use crate::input::InputKey;
        match key {
            InputKey::Tab    => { self.next_field(); SpawnInputResult::Consumed }
            InputKey::Esc    => SpawnInputResult::Cancel,
            InputKey::Enter  => match self.focused_field {
                SpawnField::Submit => SpawnInputResult::Submit,
                _                  => { self.next_field(); SpawnInputResult::Consumed }
            },
            InputKey::Char { c, .. } => {
                match self.focused_field {
                    SpawnField::Cwd     => { self.cwd.push(*c);     SpawnInputResult::Consumed }
                    SpawnField::Command => { self.command.push(*c); SpawnInputResult::Consumed }
                    SpawnField::Label   => { self.label.push(*c);   SpawnInputResult::Consumed }
                    SpawnField::Daemon  => {
                        // 'j'/'k' cycle through daemons.
                        if *c == 'j' && !self.daemons.is_empty() {
                            self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
                        } else if *c == 'k' && !self.daemons.is_empty() {
                            self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1) % self.daemons.len();
                        }
                        SpawnInputResult::Consumed
                    }
                    SpawnField::Submit  => SpawnInputResult::Consumed,
                }
            }
            InputKey::Backspace => {
                match self.focused_field {
                    SpawnField::Cwd     => { self.cwd.pop();     }
                    SpawnField::Command => { self.command.pop(); }
                    SpawnField::Label   => { self.label.pop();   }
                    _ => {}
                }
                SpawnInputResult::Consumed
            }
            _ => SpawnInputResult::Consumed,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult { Consumed, Submit, Cancel }
  • Step 2: Snapshot tests
#[cfg(test)]
mod tests {
    use super::*;
    use maestro_protocol::DaemonInfo;
    use ratatui::{backend::TestBackend, Terminal};

    fn capture<F: FnOnce(&mut ratatui::Frame)>(w: u16, h: u16, f: F) -> String {
        let backend = TestBackend::new(w, h);
        let mut t = Terminal::new(backend).unwrap();
        t.draw(|frame| f(frame)).unwrap();
        let buf = t.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
            out.push('\n');
        }
        out
    }

    #[test]
    fn spawn_modal_renders_with_one_daemon() {
        let modal = SpawnModal {
            daemons: vec![DaemonInfo {
                daemon_id: "k".into(), hostname: "konishi".into(),
                version: "0".into(), daemon_url: "ws://x".into(),
            }],
            daemon_idx: 0, cwd: "/x".into(), command: "bash".into(), label: "".into(),
            focused_field: SpawnField::Cwd,
        };
        let out = capture(80, 20, |f| modal.render(f, f.area()));
        let expected = std::fs::read_to_string("tests/snapshots/spawn_modal.txt").unwrap();
        assert_eq!(out, expected);
    }

    #[test]
    fn help_overlay_renders_lines() {
        let help = HelpOverlay { lines: vec![
            "i           EnterInsert".into(),
            "s           OpenSpawnModal".into(),
            "x           KillHighlighted".into(),
        ]};
        let out = capture(80, 12, |f| help.render(f, f.area()));
        let expected = std::fs::read_to_string("tests/snapshots/help_overlay.txt").unwrap();
        assert_eq!(out, expected);
    }
}

Generate goldens by running each test once with the file missing, capturing the output via a temporary dbg!(&out), copying to the snapshot file, removing the dbg!, re-running until green.

  • Step 3: Commit
git add crates/maestro-tui/src/overlays.rs crates/maestro-tui/tests/snapshots/spawn_modal.txt crates/maestro-tui/tests/snapshots/help_overlay.txt
git commit -m "feat(maestro-tui): overlay widgets — spawn modal, kill confirm, help, toast"

Task 16: Render orchestration + region hit-test

Files:

  • Create: crates/maestro-tui/src/render.rs

  • Modify: crates/maestro-tui/src/lib.rs

  • Step 1: Define the render entry point and region map

// crates/maestro-tui/src/render.rs
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    widgets::{Block, Borders},
};

use crate::input::Region;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::pane::{Pane, PaneStatus};
use crate::sidebar::Sidebar;
use crate::state::{AppState, OverlayKind};
use crate::tabs::TabBar;

/// Map of screen regions for the input router's hit-test step.
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
    pub sidebar: Option<Rect>,
    pub tab_bar: Option<Rect>,
    pub pane:    Option<Rect>,
    pub modal:   Option<Rect>,
}

impl RegionMap {
    pub fn region_at(&self, col: u16, row: u16) -> Region {
        if let Some(r) = self.modal   { if inside(r, col, row) { return Region::Modal; } }
        if let Some(r) = self.tab_bar { if inside(r, col, row) { return Region::Tab; } }
        if let Some(r) = self.sidebar { if inside(r, col, row) { return Region::SidebarRow; } }
        if let Some(r) = self.pane    { if inside(r, col, row) { return Region::Pane; } }
        Region::Unknown
    }
}

fn inside(r: Rect, col: u16, row: u16) -> bool {
    col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}

#[derive(Debug)]
pub struct RenderInputs<'a> {
    pub sidebar:    &'a Sidebar,
    pub tab_bar:    &'a TabBar,
    pub pane:       Option<&'a Pane>,
    pub state:      &'a AppState,
    pub spawn:      &'a SpawnModal,
    pub help:       &'a HelpOverlay,
}

pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
    let mut map = RegionMap::default();
    let area = f.area();
    let cols = if inputs.state.sidebar_visible {
        Layout::default().direction(Direction::Horizontal)
            .constraints([Constraint::Length(28), Constraint::Min(0)]).split(area)
    } else {
        Layout::default().direction(Direction::Horizontal)
            .constraints([Constraint::Length(0), Constraint::Min(0)]).split(area)
    };
    let sidebar_area = cols[0];
    let right        = cols[1];
    if inputs.state.sidebar_visible {
        inputs.sidebar.render(f, sidebar_area);
        map.sidebar = Some(sidebar_area);
    }

    let rows = Layout::default().direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0)]).split(right);
    let tab_area = rows[0];
    let pane_area = rows[1];
    inputs.tab_bar.render(f, tab_area);
    map.tab_bar = Some(tab_area);

    let pane_block = Block::default().borders(Borders::ALL);
    let pane_inner = pane_block.inner(pane_area);
    f.render_widget(pane_block, pane_area);
    if let Some(p) = inputs.pane { p.render(f, pane_inner); }
    map.pane = Some(pane_inner);

    match &inputs.state.overlay {
        Some(OverlayKind::Spawn) => {
            inputs.spawn.render(f, area);
            map.modal = Some(area);
        }
        Some(OverlayKind::KillConfirm(id)) => {
            let kc = KillConfirm { label: id.to_string() };
            kc.render(f, area);
            map.modal = Some(area);
        }
        Some(OverlayKind::Help) => {
            inputs.help.render(f, area);
            map.modal = Some(area);
        }
        Some(OverlayKind::Toast(text)) => {
            let t = Toast { text: text.clone() };
            t.render(f, area);
        }
        None => {}
    }
    map
}

Add pub mod render; to lib.rs.

  • Step 2: Smoke test the region map
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn region_map_resolves_known_areas() {
        let map = RegionMap {
            sidebar: Some(Rect { x: 0, y: 0, width: 10, height: 20 }),
            tab_bar: Some(Rect { x: 10, y: 0, width: 30, height: 1 }),
            pane:    Some(Rect { x: 10, y: 1, width: 30, height: 19 }),
            modal:   None,
        };
        assert_eq!(map.region_at(5, 5),  Region::SidebarRow);
        assert_eq!(map.region_at(15, 0), Region::Tab);
        assert_eq!(map.region_at(15, 5), Region::Pane);
        assert_eq!(map.region_at(50, 50), Region::Unknown);
    }
}

Run: cargo test -p maestro-tui render::tests Expected: 1 passing.

  • Step 3: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): render orchestration + region map for hit-testing"

Phase 6 — Event loop & lifecycle wiring

Task 17: App event loop (input → action → mutate → render)

Files:

  • Create: crates/maestro-tui/src/app.rs

  • Modify: crates/maestro-tui/src/lib.rs (re-export, wire run())

  • Step 1: Implement the loop skeleton

// crates/maestro-tui/src/app.rs
use std::sync::Arc;
use std::time::Duration;

use crossterm::event::EventStream;
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::Stdout;

use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, InputKey, Region};
use crate::keymap::Mode;
use crate::render::{draw, RegionMap, RenderInputs};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::pane::{Pane, PaneStatus};
use crate::session_task::{SessionInput, SessionTask};

pub struct App {
    pub cfg: TuiConfig,
    pub catalog: CatalogStore,
    pub emulators: SessionEmulators,
    pub state: AppState,
    pub chord: ChordState,
    pub daemon_base_url: String,
    pub region: RegionMap,
}

impl std::fmt::Debug for App {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("App").finish()
    }
}

impl App {
    pub async fn run(
        mut self,
        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    ) -> Result<(), crate::TuiError> {
        let mut events = EventStream::new();
        let notify = self.catalog.changed_notify();
        loop {
            self.redraw(terminal);

            tokio::select! {
                Some(Ok(ev)) = events.next() => {
                    let map = self.region.clone();
                    if let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) {
                        let known = self.cfg.keymap.known_prefixes(self.state.mode);
                        match self.chord.feed(&key, &known) {
                            ChordResult::Pending => {}
                            ChordResult::Resolved(canon) => {
                                if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
                                    if let Action::Quit = act { return Ok(()); }
                                    self.dispatch(act);
                                } else if self.state.mode == Mode::Insert
                                    && matches!(self.state.focus, Focus::Pane)
                                {
                                    self.forward_to_session(&key).await;
                                }
                            }
                        }
                    }
                }
                _ = notify.notified() => {
                    // Catalog change: next loop redraws.
                }
                _ = tokio::time::sleep(Duration::from_millis(33)) => {
                    // 30 Hz max; redraw on tick too.
                }
            }
        }
    }

    fn redraw(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) {
        let snap = self.catalog.snapshot();
        let attached: std::collections::HashSet<_> = self.state.tabs.iter()
            .filter_map(|t| t.pane).collect();
        let sidebar = Sidebar {
            daemons: snap.daemons.clone(),
            sessions: snap.sessions.clone(),
            highlight: self.state.sidebar_highlight,
            attached,
        };
        let tab_bar = TabBar {
            tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
            active_idx: self.state.active_tab_idx,
        };
        let active_session = self.state.active_tab().pane;
        let pane = active_session.and_then(|id| self.emulators.get(id).map(|h| Pane {
            screen: h.screen.clone(),
            status: pane_status_from(h.state()),
            focused: matches!(self.state.focus, Focus::Pane),
        }));
        let spawn = SpawnModal { daemons: snap.daemons.clone(), ..Default::default() };
        let help  = HelpOverlay { lines: render_help_lines(&self.cfg) };

        let inputs = RenderInputs {
            sidebar: &sidebar,
            tab_bar: &tab_bar,
            pane: pane.as_ref(),
            state: &self.state,
            spawn: &spawn,
            help: &help,
        };

        let mut new_region = RegionMap::default();
        let _ = terminal.draw(|f| {
            new_region = draw(f, inputs);
        });
        self.region = new_region;
    }

    fn dispatch(&mut self, act: Action) {
        match act {
            Action::EnterInsert         => self.state.mode = Mode::Insert,
            Action::LeaveInsert         => self.state.mode = Mode::Normal,
            Action::FocusSidebar        => self.state.focus = Focus::Sidebar,
            Action::NewTab              => self.state.new_tab(),
            Action::CloseTab            => self.state.close_tab(),
            Action::NextTab             => self.state.next_tab(),
            Action::PrevTab             => self.state.prev_tab(),
            Action::GotoTab(n)          => self.state.goto_tab(n),
            Action::ToggleSidebar       => self.state.sidebar_visible = !self.state.sidebar_visible,
            Action::ShowHelp            => self.state.overlay = Some(OverlayKind::Help),
            Action::OpenSpawnModal      => self.state.overlay = Some(OverlayKind::Spawn),
            Action::AttachHighlighted   => {
                // Real attach implementation lands in Task 18 (which makes `dispatch`
                // async). For Task 17 we record intent only — the action mutates
                // local UI state, the WS task is wired in next.
                if let Some(sid) = self.state.sidebar_highlight {
                    self.state.attach_to_focused(sid);
                }
            }
            Action::KillHighlighted => {
                if let Some(sid) = self.state.sidebar_highlight {
                    self.state.overlay = Some(OverlayKind::KillConfirm(sid));
                }
            }
            Action::FocusPane(_)
            | Action::ToggleAttachHighlighted
            | Action::Noop
            | Action::Quit              => {}
        }
    }

    async fn forward_to_session(&mut self, key: &InputKey) {
        if let Some(sid) = self.state.active_tab().pane {
            if let Some(h) = self.emulators.get(sid) {
                if let InputKey::Char { c, ctrl, alt, .. } = key {
                    let mut bytes = Vec::new();
                    if *alt { bytes.push(0x1b); }
                    let mut b = [0u8; 4];
                    let s = c.encode_utf8(&mut b);
                    if *ctrl {
                        // Map ctrl-<letter> to 0x01..0x1A (best-effort, ASCII).
                        if c.is_ascii_alphabetic() {
                            bytes.push((c.to_ascii_uppercase() as u8) - b'@');
                        } else {
                            bytes.extend_from_slice(s.as_bytes());
                        }
                    } else {
                        bytes.extend_from_slice(s.as_bytes());
                    }
                    let _ = h.tx.send(SessionInput::Bytes(bytes)).await;
                }
            }
        }
    }
}

fn pane_status_from(s: crate::session_task::SessionState) -> PaneStatus {
    use crate::session_task::SessionState::*;
    match s {
        Connecting   => PaneStatus::Connecting,
        Live         => PaneStatus::Live,
        Reconnecting => PaneStatus::Reconnecting,
        Ended        => PaneStatus::Ended,
    }
}

fn ws_base(http: &str) -> String {
    if let Some(rest) = http.strip_prefix("http://")  { format!("ws://{rest}") }
    else if let Some(rest) = http.strip_prefix("https://") { format!("wss://{rest}") }
    else { http.to_string() }
}

fn render_help_lines(_cfg: &TuiConfig) -> Vec<String> {
    // v1: enumerate the keymap. Cheap.
    vec!["? to close   q/Ctrl-w q to quit".into()]
}

Then wire lib::run:

// crates/maestro-tui/src/lib.rs (replace the stub `run`)
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
    terminal::install_panic_hook();
    let _guard = terminal::TerminalGuard::enter().map_err(|e| TuiError::Terminal(e.to_string()))?;

    let base_url = format!("http://{}", cfg.daemon_addr);

    // Health check.
    let probe = reqwest::Client::new()
        .get(format!("{base_url}/api/web/info"))
        .timeout(std::time::Duration::from_millis(500))
        .send().await;
    if probe.is_err() {
        eprintln!("maestro: cannot reach local daemon at {base_url}");
        eprintln!("hint: `maestro daemon configure` and `systemctl --user start maestro-daemon`");
        return Err(TuiError::DaemonUnreachable);
    }

    // v1 uses shipped defaults; the `--config` flag is already global on the
    // umbrella binary, so once a Task-25 follow-up plumbs the parsed file path
    // into RunConfig we just swap MaestroConfigFile::default() for the loaded one.
    let file = maestro_config::MaestroConfigFile::default();
    let tui_cfg = config::TuiConfig::load(&file.tui).map_err(|e| TuiError::Config(e.to_string()))?;

    let catalog = catalog::CatalogStore::default();
    catalog.bootstrap(&base_url).await.map_err(|e| TuiError::Catalog(e.to_string()))?;
    tokio::spawn(catalog.clone().run_events_loop(
        format!("{}/api/events", ws_base(&base_url)),
        base_url.clone(),
    ));

    let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout());
    let mut terminal = ratatui::Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;

    let app = app::App {
        cfg: tui_cfg,
        catalog,
        emulators: emulators::SessionEmulators::default(),
        state: state::AppState::new(),
        chord: input::ChordState::new(std::time::Duration::from_millis(1000)),
        daemon_base_url: base_url.clone(),
        region: render::RegionMap::default(),
    };
    app.run(&mut terminal).await
}

fn ws_base(http: &str) -> String {
    if let Some(rest) = http.strip_prefix("http://")  { format!("ws://{rest}") }
    else if let Some(rest) = http.strip_prefix("https://") { format!("wss://{rest}") }
    else { http.to_string() }
}

Extend TuiError:

#[derive(Debug, thiserror::Error)]
pub enum TuiError {
    #[error("not implemented")]
    NotImplemented,
    #[error("daemon unreachable")]
    DaemonUnreachable,
    #[error("config: {0}")]
    Config(String),
    #[error("catalog: {0}")]
    Catalog(String),
    #[error("terminal: {0}")]
    Terminal(String),
}
  • Step 2: Smoke build

Run: cargo build -p maestro-tui Expected: clean build.

  • Step 3: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): app event loop with input→action→mutate→render"

Task 18: AttachHighlighted spawns a real SessionTask

The placeholder in Task 17's dispatch doesn't actually open the WS. Real implementation.

Files:

  • Modify: crates/maestro-tui/src/app.rs

  • Step 1: Replace the placeholder branch

Action::AttachHighlighted => {
    if let Some(sid) = self.state.sidebar_highlight {
        if self.emulators.get(sid).is_none() {
            let url = format!("{}/api/attach/{}", ws_base(&self.daemon_base_url), sid);
            // Initial size: 80×24, will be corrected on next render's resize pass.
            match SessionTask::spawn(url, 24, 80).await {
                Ok(handle) => self.emulators.insert(sid, handle),
                Err(e) => {
                    self.state.overlay = Some(OverlayKind::Toast(format!("attach failed: {e}")));
                }
            }
        }
        self.state.attach_to_focused(sid);
    }
}

(dispatch becomes async fn dispatch; the call site in run switches to self.dispatch(act).await.)

  • Step 2: Smoke build + integration test

Run: cargo build -p maestro-tui Expected: clean.

(Integration test that drives this end-to-end comes in Task 22.)

  • Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): AttachHighlighted opens real SessionTask"

Task 19: Spawn flow — modal submit → POST /api/sessions

Files:

  • Modify: crates/maestro-tui/src/app.rs

  • Modify: crates/maestro-tui/src/overlays.rs

  • Step 1: Keyboard handling for the spawn modal

In App::run's event branch, intercept input while the spawn overlay is active. Add a handle_overlay_input helper:

// inside App
async fn handle_overlay_input(&mut self, key: &InputKey) -> bool {
    match (&self.state.overlay, key) {
        (Some(OverlayKind::Spawn), InputKey::Esc) => {
            self.state.overlay = None; true
        }
        (Some(OverlayKind::Spawn), InputKey::Enter) => {
            self.submit_spawn().await;
            true
        }
        (Some(OverlayKind::Spawn), key) => {
            match self.spawn.on_input(key) {
                crate::overlays::SpawnInputResult::Consumed => {}
                crate::overlays::SpawnInputResult::Cancel   => { self.state.overlay = None; }
                crate::overlays::SpawnInputResult::Submit   => { self.submit_spawn().await; }
            }
            true
        }
        _ => false,
    }
}

Add a spawn: SpawnModal field on App so its state persists while the overlay is open. The render path reads &self.spawn instead of constructing a fresh one each frame.

  • Step 2: Implement the POST
async fn submit_spawn(&mut self) {
    let body = serde_json::json!({
        "daemon_id": self.spawn.daemons.get(self.spawn.daemon_idx).map(|d| &d.daemon_id),
        "cwd": self.spawn.cwd,
        "command": self.spawn.command,
        "label": if self.spawn.label.is_empty() { serde_json::Value::Null }
                 else { serde_json::Value::String(self.spawn.label.clone()) },
    });
    let res = reqwest::Client::new()
        .post(format!("{}/api/sessions", self.daemon_base_url))
        .json(&body)
        .send().await;
    match res.and_then(|r| r.error_for_status()) {
        Ok(_)  => self.state.overlay = None,
        Err(e) => self.state.overlay = Some(OverlayKind::Toast(format!("spawn failed: {e}"))),
    }
}

(Inspect crates/maestro-protocol::CreateSessionRequest and serialize that struct directly instead of using json! — that's the canonical form; use json! only if the struct is private to the daemon.)

  • Step 3: Smoke build

Run: cargo build -p maestro-tui Expected: clean.

  • Step 4: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): spawn modal submit hits POST /api/sessions"

Task 20: Kill flow — confirm overlay → DELETE /api/sessions/:id

Files:

  • Modify: crates/maestro-tui/src/app.rs

  • Step 1: Confirm overlay key handling + DELETE

Extend handle_overlay_input:

(Some(OverlayKind::KillConfirm(id)), InputKey::Char { c: 'y', .. }) => {
    let sid = *id;
    self.state.overlay = None;
    let url = format!("{}/api/sessions/{sid}", self.daemon_base_url);
    let res = reqwest::Client::new().delete(&url).send().await
        .and_then(|r| r.error_for_status());
    if let Err(e) = res {
        self.state.overlay = Some(OverlayKind::Toast(format!("kill failed: {e}")));
    }
    true
}
(Some(OverlayKind::KillConfirm(_)), InputKey::Char { c: 'n', .. } | InputKey::Esc) => {
    self.state.overlay = None;
    true
}
  • Step 2: Smoke build

Run: cargo build -p maestro-tui Expected: clean.

  • Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): kill confirm overlay → DELETE /api/sessions/:id"

Phase 7 — Integration

Task 21: Resize propagation in the render loop

Files:

  • Modify: crates/maestro-tui/src/app.rs

  • Step 1: Track each pane's allocated rect after render, recompute min per session, send Resize when changed

At the end of redraw:

// In App, add:
//   last_sent: HashMap<Uuid, (u16, u16)>

let active_id = self.state.active_tab().pane;
if let Some(sid) = active_id {
    if let Some(rect) = self.region.pane {
        self.emulators.resize_tracker_mut().set_pane_size(
            sid,
            "active",
            (rect.width, rect.height),
        );
    }
}
let snapshot_sids: Vec<_> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
for sid in snapshot_sids {
    if let Some((cols, rows)) = self.emulators.resize_tracker_mut().session_size(sid) {
        let prev = self.last_sent.get(&sid).copied();
        if prev != Some((cols, rows)) {
            if let Some(h) = self.emulators.get(sid) {
                let _ = h.tx.try_send(SessionInput::Resize { cols, rows });
            }
            self.last_sent.insert(sid, (cols, rows));
        }
    }
}
  • Step 2: Smoke build

Run: cargo build -p maestro-tui Expected: clean.

  • Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): per-session resize propagation after render"

Task 22: Integration test against in-process daemon

Files:

  • Create: crates/maestro-tui/tests/integration.rs

  • Step 1: Read the existing daemon-spinning helper

Before writing, read crates/maestro-e2e/src/ (or wherever the harness lives — grep -rn "LocalApiState::" crates/maestro-e2e) to find the canonical helper that builds LocalApiState. Use it directly; do not re-derive the fields. The daemon's local_api::router(state) is pub, which is the seam.

  • Step 2: Write the test
// crates/maestro-tui/tests/integration.rs
use std::time::Duration;

use maestro_protocol::CreateSessionRequest;
use maestro_tui::catalog::CatalogStore;

// Pull in the canonical helper from maestro-e2e. If its module path differs,
// match what you found in Step 1 — DO NOT invent constructor fields.
use maestro_e2e::fixtures::spawn_in_process_daemon;

#[tokio::test(flavor = "multi_thread")]
async fn tui_catalog_reflects_session_created_via_events_ws() {
    let daemon = spawn_in_process_daemon().await; // returns { http_base, ws_base, _drop_guard }

    let store = CatalogStore::default();
    store.bootstrap(&daemon.http_base).await.unwrap();
    assert_eq!(store.snapshot().daemons.len(), 1);
    assert!(store.snapshot().sessions.is_empty());

    // Spawn the events loop in the background.
    let bg_store = store.clone();
    let ws_url = format!("{}/api/events", daemon.ws_base);
    let rest_base = daemon.http_base.clone();
    tokio::spawn(async move { bg_store.run_events_loop(ws_url, rest_base).await });

    // POST a session via the daemon's own HTTP surface.
    let req = CreateSessionRequest {
        daemon_id: store.snapshot().daemons[0].daemon_id.clone(),
        cwd: "/tmp".into(),
        command: "bash".into(),
        label: Some("integration".into()),
        env: Default::default(),
        worktree: None,
    };
    let res = reqwest::Client::new()
        .post(format!("{}/api/sessions", daemon.http_base))
        .json(&req).send().await.unwrap().error_for_status().unwrap();
    let _: serde_json::Value = res.json().await.unwrap();

    // Wait for the events WS to deliver SessionCreated. Poll for up to 2 s.
    for _ in 0..200 {
        if store.snapshot().sessions.len() == 1 { return; }
        tokio::time::sleep(Duration::from_millis(10)).await;
    }
    panic!("session never propagated through /api/events");
}

If CreateSessionRequest's field names differ from this listing, match the current source — open crates/maestro-protocol/src/lib.rs first.

  • Step 3: Run

Run: cargo test -p maestro-tui --test integration -- --nocapture Expected: passing after iterating on field-name mismatches surfaced by the compiler.

  • Step 2: Run — fail (until helper imports compile and assertions hold)

Run: cargo test -p maestro-tui --features integration --test integration Expected: red, then green as the test is fleshed out.

  • Step 3: Commit
git add crates/maestro-tui/tests/integration.rs crates/maestro-tui/Cargo.toml
git commit -m "test(maestro-tui): integration test against in-process daemon"

Task 23: E2E test in maestro-e2e

Files:

  • Create: crates/maestro-e2e/tests/tui_e2e.rs

  • Step 1: Drive the TUI from a substitutable event source

Add an internal crate::testing module to maestro-tui that exposes an alternate constructor:

// crates/maestro-tui/src/testing.rs (gated behind cfg(any(test, feature = "test-util")))
use tokio::sync::mpsc;
use crossterm::event::Event;

pub struct TestEventSource(pub mpsc::Receiver<Event>);

// Re-export from lib.rs behind the feature; provide an alternate App::run_with_events
// that accepts a custom event stream instead of crossterm::event::EventStream.

E2E test:

// crates/maestro-e2e/tests/tui_e2e.rs
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc;

use maestro_e2e::fixtures::two_daemon_topology; // existing fixture; match the real name
use maestro_tui::testing::TestEventSource;

fn key(c: char) -> Event {
    Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
fn special(code: KeyCode) -> Event {
    Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}

#[tokio::test(flavor = "multi_thread")]
async fn tui_spawn_attach_type_kill_across_two_daemons() {
    let topo = two_daemon_topology().await; // .registry, .daemon_a, .daemon_b, _drop_guards

    let (tx, rx) = mpsc::channel::<Event>(64);
    let events = TestEventSource(rx);
    let cfg = maestro_tui::RunConfig { daemon_addr: topo.daemon_a.local_addr };
    let tui_handle = tokio::spawn(async move {
        maestro_tui::run_with_events(cfg, events).await
    });

    // Allow the catalog bootstrap to populate.
    tokio::time::sleep(Duration::from_millis(200)).await;

    // s → spawn modal
    tx.send(key('s')).await.unwrap();
    // Tab to daemon field → 'j' to pick daemon B (defaults to A which is index 0)
    // Default focused field is Daemon, so 'j' switches the dropdown.
    tx.send(key('j')).await.unwrap();
    // Tab → cwd, type "/tmp"
    tx.send(special(KeyCode::Tab)).await.unwrap();
    for c in "/tmp".chars() { tx.send(key(c)).await.unwrap(); }
    // Tab → command, type "bash"
    tx.send(special(KeyCode::Tab)).await.unwrap();
    for c in "bash".chars() { tx.send(key(c)).await.unwrap(); }
    // Tab → label, type "e2e"
    tx.send(special(KeyCode::Tab)).await.unwrap();
    for c in "e2e".chars() { tx.send(key(c)).await.unwrap(); }
    // Tab → Submit, Enter
    tx.send(special(KeyCode::Tab)).await.unwrap();
    tx.send(special(KeyCode::Enter)).await.unwrap();

    // Wait for the session to land in daemon B's catalog (visible via daemon A's mirror).
    topo.wait_until_session("e2e", Duration::from_secs(2)).await.unwrap();

    // Enter → attach the highlighted session (sidebar highlight should be the new one).
    tx.send(special(KeyCode::Enter)).await.unwrap();

    // i → Insert; send "echo hi\n"; Ctrl-A (leader) → Normal.
    tx.send(key('i')).await.unwrap();
    for c in "echo hi\n".chars() { tx.send(key(c)).await.unwrap(); }
    tx.send(Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL))).await.unwrap();

    // Assert the session's vt100 screen contains "hi" within 2 s.
    topo.wait_until_session_screen_contains("e2e", "hi", Duration::from_secs(2)).await.unwrap();

    // x → kill-confirm; y → confirm.
    tx.send(key('x')).await.unwrap();
    tx.send(key('y')).await.unwrap();

    topo.wait_until_no_session("e2e", Duration::from_secs(2)).await.unwrap();

    // Ctrl-w q → quit.
    tx.send(Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL))).await.unwrap();
    tx.send(key('q')).await.unwrap();

    tui_handle.await.unwrap().unwrap();
}

The two_daemon_topology / wait_until_session* helpers may already exist in maestro-e2e under different names — grep for wait_until_session first. Add the missing ones inline in crates/maestro-e2e/src/fixtures.rs if they don't exist yet, before the test compiles.

  • Step 2: Run — green after iteration

Run: cargo test -p maestro-e2e tui_e2e --features e2e Expected: eventually green.

  • Step 3: Commit
git add crates/maestro-e2e/tests/tui_e2e.rs crates/maestro-tui/src/testing.rs crates/maestro-tui/src/lib.rs
git commit -m "test(maestro-tui): e2e through two daemons via maestro-e2e harness"

Phase 8 — Wrap-up

Task 24: Workspace-level CI verification

  • Step 1: Push the branch
git push -u origin <branch>
  • Step 2: Watch gitea CI

(Per user convention: heavy workspace builds run on gitea CI, not locally. Poll until green.)

  • Step 3: Address any cross-crate fallout — most likely cargo clippy --all-targets -- -D warnings lints in the new crate.

Task 25: Document the TUI in README + INSTALL

Files:

  • Modify: docs/INSTALL.md

  • Modify: README.md (if present)

  • Step 1: Add a section noting maestro tui is the new subcommand, that it ships with the default feature set, and that opt-out works via --no-default-features --features cli.

  • Step 2: Add a paragraph on [tui] config — leader, chord timeout, normal/insert keymap tables — pointing at the spec for the action vocabulary.

  • Step 3: Commit

git add docs/INSTALL.md README.md
git commit -m "docs(maestro): document \`maestro tui\` subcommand and [tui] config"

Self-review

A note for the engineer executing this plan: the spec deliberately defers a few things to "during implementation". These are not gaps — they are scope edges where the right answer depends on what vt100 and ratatui look like once we touch them. Re-read §7 of the spec when in doubt.

Spec coverage notes:

  • Sidebar / tab bar / pane / overlays — Tasks 12–15.
  • Layout tree — v1 uses Option<SessionId>; the binary split tree the spec describes lands in v1.5 (out of this plan).
  • Configurable keymap + mouse — Tasks 3–5 + 16.
  • Catalog bootstrap + events WS reconnect — Tasks 6–7.
  • Per-session liveness + reconnect — Task 8.
  • Per-dimension min resize — Tasks 9 + 21.
  • Daemon-unreachable startup check — wired in Task 17 lib::run.
  • Terminal restore on panic — Task 11.
  • Integration + e2e — Tasks 22–23.

What the plan deliberately omits vs the spec:

  • Split panes (SplitHorizontal/SplitVertical/etc.) — v1.5.
  • Vim-: command palette — v1.5+.
  • POST /api/sessions/:id/refresh daemon endpoint — added if/when a glitch demands it; not blocking v1.
  • Mouse passthrough heuristic to the inner program — heuristic is a v1.5 task; v1 always consumes mouse.

Plan complete and saved to docs/superpowers/plans/2026-05-28-maestro-tui.md.