diff --git a/docs/superpowers/plans/2026-05-28-maestro-tui.md b/docs/superpowers/plans/2026-05-28-maestro-tui.md new file mode 100644 index 0000000..98e80bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-maestro-tui.md @@ -0,0 +1,3305 @@ +# 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>`. 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** + +```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** + +```rust +// 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** + +```toml +# 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** + +```rust +// 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, +} +``` + +- [ ] **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")]`: + +```rust +#[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** + +```bash +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** + +```rust +// 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** + +```rust +// 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 { + 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 { + 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** + +```bash +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** + +```rust +// 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 = ["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** + +```rust +// 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 { + 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 { + 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, + last_press: Option, + 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) -> 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** + +```bash +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** + +```rust +// 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** + +```rust +// 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, + insert: HashMap, +} + +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 { + 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 { + 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** + +```bash +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`): + +```rust +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TuiSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub leader: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chord_timeout_ms: Option, + /// Normal-mode bindings, applied over the shipped defaults. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub keymap_normal: std::collections::BTreeMap, + /// Insert-mode bindings, applied over the shipped defaults. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub keymap_insert: std::collections::BTreeMap, +} + +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** + +```rust +// 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(§ion).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(§ion).unwrap_err(); + assert!(format!("{err}").contains("ctrl-glorb")); + } +} +``` + +- [ ] **Step 3: Implement TuiConfig with shipped defaults** + +```rust +// 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 { + 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 §ion.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 §ion.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** + +```bash +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** + +```rust +// 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 = vec![]; + let app = Router::new() + .route("/api/daemons", get(move || async move { Json(daemons.clone()) })) + .route("/api/sessions", get(move || async move { Json::>(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** + +```rust +// 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, + pub sessions: Vec, + pub version: u64, +} + +#[derive(Debug, Default, Clone)] +pub struct CatalogStore { + inner: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + state: RwLock, + notify: Notify, +} + +#[derive(Debug, Default)] +struct State { + daemons: HashMap, + sessions: HashMap, + 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 = client + .get(format!("{base_url}/api/daemons")).send().await?.error_for_status()?.json().await?; + let sessions: Vec = 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 { + 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** + +```bash +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** + +```rust +// 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`: + +```rust +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::(&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** + +```bash +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** + +```rust +// 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::()).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** + +```rust +// 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), + 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, + pub screen: Arc>, + pub state: Arc, +} + +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 { + let (tx, mut rx) = mpsc::channel::(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::(&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** + +```bash +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** + +```rust +// 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** + +```rust +// 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, + 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 = None; + let mut rows: Option = 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** + +```bash +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** + +```rust +// 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** + +```rust +// 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, +} + +#[derive(Debug)] +pub struct AppState { + pub tabs: Vec, + pub active_tab_idx: usize, + pub focus: Focus, + pub mode: Mode, + pub sidebar_visible: bool, + pub sidebar_highlight: Option, + pub overlay: Option, +} + +#[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** + +```bash +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** + +```rust +// 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** + +```rust +// 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 { + 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** + +```bash +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** + +```rust +// 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)** + +```rust +// 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, + pub sessions: Vec, + pub highlight: Option, + pub attached: HashSet, +} + +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 = 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** + +```bash +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)** + +```rust +// 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** + +```rust +// 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, + pub active_idx: usize, +} + +impl TabBar { + pub fn render(&self, f: &mut Frame, area: Rect) { + let titles: Vec = 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** + +```bash +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** + +```rust +// 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** + +```rust +// 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>, + 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 { + 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** + +```bash +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** + +```rust +// 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, + 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 } +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 = 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: + +```rust +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** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use maestro_protocol::DaemonInfo; + use ratatui::{backend::TestBackend, Terminal}; + + fn capture(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** + +```bash +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** + +```rust +// 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, + pub tab_bar: Option, + pub pane: Option, + pub modal: Option, +} + +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** + +```rust +#[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** + +```bash +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** + +```rust +// 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>, + ) -> 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>) { + 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- 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 { + // v1: enumerate the keymap. Cheap. + vec!["? to close q/Ctrl-w q to quit".into()] +} +``` + +Then wire `lib::run`: + +```rust +// 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`: + +```rust +#[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** + +```bash +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** + +```rust +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** + +```bash +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: + +```rust +// 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** + +```rust +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** + +```bash +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`: + +```rust +(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** + +```bash +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`: + +```rust +// In App, add: +// last_sent: HashMap + +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** + +```bash +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** + +```rust +// 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** + +```bash +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: + +```rust +// 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); + +// 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: + +```rust +// 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::(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** + +```bash +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** + +```bash +git push -u origin +``` + +- [ ] **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** + +```bash +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`; 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`.