diff --git a/CLAUDE.md b/CLAUDE.md index f5388ea..3e8cb2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,6 @@ crates/ ├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245) ├── maestro-protocol/ # shared wire types (NAP-211) ├── maestro-config/ # shared on-disk config schema (NAP-233) -├── maestro-names/ # orchestra-themed session-handle generator (#23) ├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216) ├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214) ├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249) @@ -63,9 +62,6 @@ nix/ # Home Manager module (`services.maestro`) See `docs/INSTALL.md` for end-user install instructions. -The dated design docs under `docs/superpowers/specs/` predate the single-host -refactor; they carry banners marking what no longer applies. - ## Conventions - **TDD by default.** Failing test first, then minimal impl. Commit test + impl together. diff --git a/Cargo.lock b/Cargo.lock index 605a736..12d1764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,7 +1077,6 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "urlencoding", "uuid", ] @@ -1147,13 +1146,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "maestro-names" -version = "0.1.0" -dependencies = [ - "rand 0.8.6", -] - [[package]] name = "maestro-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 87454a4..b33b5bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,11 @@ dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } libc = "0.2" maestro-config = { path = "crates/maestro-config" } -maestro-names = { path = "crates/maestro-names" } maestro-protocol = { path = "crates/maestro-protocol" } maestro-tui = { path = "crates/maestro-tui" } nix = { version = "0.29", default-features = false, features = ["term", "signal"] } rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } -rust-embed = "8" include_dir = "0.7" mime_guess = "2" bytes = "1" diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml index ae360a4..54a6a7a 100644 --- a/crates/maestro-cli/Cargo.toml +++ b/crates/maestro-cli/Cargo.toml @@ -28,7 +28,6 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -urlencoding = "2" uuid.workspace = true [dev-dependencies] diff --git a/crates/maestro-cli/src/config.rs b/crates/maestro-cli/src/config.rs index 4a112ec..7eaeba4 100644 --- a/crates/maestro-cli/src/config.rs +++ b/crates/maestro-cli/src/config.rs @@ -45,7 +45,7 @@ impl CliConfig { /// Load the subset of the unified file the CLI consumes. A missing file is /// fine — every field has a default. pub fn load(path: &Path) -> Result { - let file = MaestroConfigFile::load_or_default(path)?; + let file = MaestroConfigFile::load(path)?; Ok(Self { daemon_url: file.cli.daemon_url, daemon_bind_addr: file.daemon.bind_addr, diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 4aed6eb..2e3c888 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -41,12 +41,6 @@ pub enum ConfigError { TomlEncode(#[from] toml::ser::Error), #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] NoConfigDir, - #[error( - "maestro config not found at {}\n\ - Run `maestro daemon configure` to create it.", - path.display() - )] - MissingFile { path: PathBuf }, #[error("invalid {env} ({input:?}): {source}")] InvalidBindAddr { env: &'static str, @@ -314,27 +308,17 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> { } impl MaestroConfigFile { - /// Strict load — error with [`ConfigError::MissingFile`] if absent. + /// Load the file, or return an empty config if it doesn't exist. Every + /// field has a default, so a missing file is never an error. pub fn load(path: &Path) -> Result { if !path.exists() { - return Err(ConfigError::MissingFile { - path: path.to_path_buf(), - }); + return Ok(Self::default()); } let bytes = std::fs::read(path)?; let s = std::str::from_utf8(&bytes).unwrap_or(""); Ok(toml::from_str(s)?) } - /// Load if present, else return an empty config. Use for callers that - /// want a default-on-missing read. - pub fn load_or_default(path: &Path) -> Result { - if !path.exists() { - return Ok(Self::default()); - } - Self::load(path) - } - /// Atomic write (tmp + rename). Creates parent dirs on demand. pub fn save(&self, path: &Path) -> Result<(), ConfigError> { if let Some(parent) = path.parent() { @@ -447,20 +431,10 @@ mod tests { } #[test] - fn missing_file_strict_load_errors_with_pointer_to_configure() { + fn missing_file_loads_as_empty_config() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - let err = MaestroConfigFile::load(&path).unwrap_err(); - let msg = err.to_string(); - assert!(matches!(err, ConfigError::MissingFile { .. })); - assert!(msg.contains("maestro daemon configure"), "msg was: {msg}"); - } - - #[test] - fn missing_file_load_or_default_returns_empty() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let cfg = MaestroConfigFile::load_or_default(&path).unwrap(); + let cfg = MaestroConfigFile::load(&path).unwrap(); assert_eq!(cfg, MaestroConfigFile::default()); } diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index a0068ad..6678479 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -111,7 +111,7 @@ impl EnvOverrides { /// Resolve the runtime configuration: env overrides on top of the unified /// file, then defaults. A missing file is not an error. pub fn load_config(path: &Path, env: EnvOverrides) -> Result { - let file = MaestroConfigFile::load_or_default(path)?; + let file = MaestroConfigFile::load(path)?; let daemon = file.daemon; // Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id, diff --git a/crates/maestro-names/Cargo.toml b/crates/maestro-names/Cargo.toml deleted file mode 100644 index 011cd23..0000000 --- a/crates/maestro-names/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "maestro-names" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish.workspace = true -description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro" - -[dependencies] -rand.workspace = true - -[dev-dependencies] -rand = { workspace = true, features = ["small_rng"] } diff --git a/crates/maestro-names/src/lib.rs b/crates/maestro-names/src/lib.rs deleted file mode 100644 index 6bc7330..0000000 --- a/crates/maestro-names/src/lib.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! `maestro-names` — the shared session-handle generator. -//! -//! Maestro is a conductor's podium for every shell you care about, so the -//! handles it hands out are drawn from the orchestra pit: a musical adjective -//! (tempo, dynamics, articulation) paired with an instrument or musical form. -//! The result reads like a marking on a score — `allegro-cello`, -//! `crescendo-oboe`, `staccato-viola`. -//! -//! ## Determinism -//! -//! Every pure path takes an **injected** [`rand::Rng`]; nothing here reaches -//! for `thread_rng()` or the clock, so seeded tests are reproducible (a repo -//! invariant). [`generate_thread`] is the one impure convenience wrapper for -//! production callers that just want a name and don't care about the seed. -//! -//! The Rust generator is the source of truth. If the web UI wants an instant -//! client-side reroll it can mirror [`ADJECTIVES`] / [`NOUNS`] verbatim, but -//! the wordlists live here. - -use rand::seq::SliceRandom; -use rand::Rng; - -/// Musical adjectives — tempo, dynamics, and articulation markings. -pub const ADJECTIVES: &[&str] = &[ - "allegro", - "andante", - "adagio", - "presto", - "vivace", - "largo", - "grave", - "lento", - "moderato", - "crescendo", - "staccato", - "legato", - "forte", - "dolce", - "agitato", - "cantabile", - "sostenuto", - "tremolo", - "rubato", - "marcato", - "brillante", - "maestoso", - "grazioso", - "espressivo", - "tranquillo", - "animato", - "spirito", - "sotto", - "vivo", - "feroce", -]; - -/// Musical nouns — instruments and forms from the concert hall. -pub const NOUNS: &[&str] = &[ - "cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone", - "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata", - "octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda", - "motif", "overture", -]; - -/// Generate a single `adjective-noun` handle from an injected RNG. -/// -/// ``` -/// use rand::SeedableRng; -/// let mut rng = rand::rngs::SmallRng::seed_from_u64(7); -/// let name = maestro_names::generate(&mut rng); -/// assert!(name.contains('-')); -/// ``` -pub fn generate(rng: &mut R) -> String { - // Both lists are compile-time non-empty (asserted in tests), so the - // `choose` calls cannot return `None`. - let adj = ADJECTIVES.choose(rng).expect("ADJECTIVES is non-empty"); - let noun = NOUNS.choose(rng).expect("NOUNS is non-empty"); - format!("{adj}-{noun}") -} - -/// Generate a handle that isn't already taken. -/// -/// Draws `adjective-noun` candidates from `rng`; if `is_taken` rejects one, a -/// numeric uniqueness suffix is appended and incremented (`-2`, `-3`, …) until -/// a free handle is found. The bare form is preferred — the suffix only -/// appears on collision, matching the score-marking aesthetic. -/// -/// `is_taken` is the single source of "already used"; given the same RNG and -/// the same predicate the output is fully deterministic. -pub fn generate_unique(rng: &mut R, is_taken: F) -> String -where - R: Rng + ?Sized, - F: Fn(&str) -> bool, -{ - let base = generate(rng); - if !is_taken(&base) { - return base; - } - // Start at 2 so the first duplicate reads `name`, `name-2`, `name-3` … - for suffix in 2u64.. { - let candidate = format!("{base}-{suffix}"); - if !is_taken(&candidate) { - return candidate; - } - } - unreachable!("u64 suffix space is effectively inexhaustible") -} - -/// Impure convenience wrapper: generate a handle from the thread RNG. -/// -/// For production callers that just want a name. Tests and any deterministic -/// path should use [`generate`] / [`generate_unique`] with a seeded RNG. -pub fn generate_thread() -> String { - generate(&mut rand::thread_rng()) -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::rngs::SmallRng; - use rand::SeedableRng; - use std::collections::HashSet; - - fn seeded(seed: u64) -> SmallRng { - SmallRng::seed_from_u64(seed) - } - - #[test] - fn wordlists_are_non_empty() { - assert!(!ADJECTIVES.is_empty()); - assert!(!NOUNS.is_empty()); - } - - #[test] - fn wordlists_have_no_duplicates() { - assert_eq!( - ADJECTIVES.iter().collect::>().len(), - ADJECTIVES.len(), - "duplicate adjective" - ); - assert_eq!( - NOUNS.iter().collect::>().len(), - NOUNS.len(), - "duplicate noun" - ); - } - - #[test] - fn generate_is_adjective_dash_noun() { - let mut rng = seeded(1); - let name = generate(&mut rng); - let (adj, noun) = name.split_once('-').expect("must contain a single dash"); - assert!(ADJECTIVES.contains(&adj), "{adj} not an adjective"); - assert!(NOUNS.contains(&noun), "{noun} not a noun"); - } - - #[test] - fn generation_is_deterministic_under_seed() { - // Same seed → identical sequence; this is the core repo invariant. - let mut a = seeded(42); - let mut b = seeded(42); - for _ in 0..50 { - assert_eq!(generate(&mut a), generate(&mut b)); - } - } - - #[test] - fn different_seeds_diverge() { - // Not a guarantee for a single draw, but across a run the sequences - // must differ — otherwise the RNG isn't actually being threaded. - let mut a = seeded(1); - let mut b = seeded(2); - let seq_a: Vec<_> = (0..30).map(|_| generate(&mut a)).collect(); - let seq_b: Vec<_> = (0..30).map(|_| generate(&mut b)).collect(); - assert_ne!(seq_a, seq_b); - } - - #[test] - fn unique_returns_bare_name_when_free() { - let mut rng = seeded(3); - let name = generate_unique(&mut rng, |_| false); - assert!( - !name.ends_with("-2"), - "no suffix expected when nothing taken" - ); - assert!(name.contains('-')); - } - - #[test] - fn unique_appends_incrementing_suffix_on_collision() { - // Reserve the bare name and the first two suffixed variants; the - // generator must walk past them to `-4`. - let mut probe = seeded(9); - let base = generate(&mut probe); - let taken: HashSet = [base.clone(), format!("{base}-2"), format!("{base}-3")] - .into_iter() - .collect(); - - let mut rng = seeded(9); - let name = generate_unique(&mut rng, |n| taken.contains(n)); - assert_eq!(name, format!("{base}-4")); - } - - #[test] - fn unique_suffix_starts_at_two() { - let mut probe = seeded(11); - let base = generate(&mut probe); - let taken: HashSet = [base.clone()].into_iter().collect(); - - let mut rng = seeded(11); - let name = generate_unique(&mut rng, |n| taken.contains(n)); - assert_eq!(name, format!("{base}-2")); - } -} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 6f4a9c0..c0220bb 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -1,7 +1,4 @@ //! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface. -//! -//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md` -//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.md` #![deny(missing_debug_implementations)] @@ -74,7 +71,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string())) }); let cfg_file = match cfg_path { - Ok(path) => maestro_config::MaestroConfigFile::load_or_default(&path) + Ok(path) => maestro_config::MaestroConfigFile::load(&path) .map_err(|e| TuiError::Config(e.to_string()))?, Err(_) => maestro_config::MaestroConfigFile::default(), }; diff --git a/crates/maestro-web/README.md b/crates/maestro-web/README.md index 99edd2c..dfd1a47 100644 --- a/crates/maestro-web/README.md +++ b/crates/maestro-web/README.md @@ -79,5 +79,5 @@ pnpm dev ``` Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at -`127.0.0.1:7070` (override with `MAESTRO_REGISTRY=...`). You don't need to +`127.0.0.1:7070` (override with `MAESTRO_DAEMON_URL=...`). You don't need to rebuild the Rust binary while iterating on the frontend. diff --git a/crates/maestro-web/src/config.rs b/crates/maestro-web/src/config.rs index b7050fb..15be39c 100644 --- a/crates/maestro-web/src/config.rs +++ b/crates/maestro-web/src/config.rs @@ -59,11 +59,7 @@ impl EnvOverrides { /// Resolve the runtime configuration. Missing-file is fine: every field has /// a default and the binary is happy to run with no config.toml at all. pub fn load_config(path: &Path, env: EnvOverrides) -> Result { - let file = if path.exists() { - MaestroConfigFile::load(path)? - } else { - MaestroConfigFile::default() - }; + let file = MaestroConfigFile::load(path)?; resolve_with(file.web, env) } diff --git a/crates/maestro-web/src/lib.rs b/crates/maestro-web/src/lib.rs index 837423b..ba075b8 100644 --- a/crates/maestro-web/src/lib.rs +++ b/crates/maestro-web/src/lib.rs @@ -1,8 +1,5 @@ //! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI //! and reverse-proxies API calls to a co-located `maestrod`. -//! -//! See `docs/superpowers/specs/2026-05-26-connection-orchestration.md` §UI -//! hosting for the architectural decisions. #![deny(missing_debug_implementations)] diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 8056970..ad02b5f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -149,8 +149,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g` # want extra Insert-mode bindings) ``` -The full action vocabulary lives in -`docs/superpowers/specs/2026-05-28-maestro-tui-design.md` §3.8. +The full action vocabulary is the `Action` enum in +`crates/maestro-tui/src/action.rs`. ## Adding the browser UI (optional) diff --git a/docs/superpowers/plans/2026-05-28-maestro-tui.md b/docs/superpowers/plans/2026-05-28-maestro-tui.md deleted file mode 100644 index 98e80bb..0000000 --- a/docs/superpowers/plans/2026-05-28-maestro-tui.md +++ /dev/null @@ -1,3305 +0,0 @@ -# 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`. diff --git a/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md b/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md deleted file mode 100644 index 8f6b58f..0000000 --- a/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md +++ /dev/null @@ -1,403 +0,0 @@ -# maestro-web Bundle Build Implementation Plan - -> **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:** Make every compiled `maestro-web` binary embed a real SPA bundle by building the frontend in `build.rs`, so no install method ever serves the "bundle not built" placeholder. - -**Architecture:** A new `crates/maestro-web/build.rs` runs `pnpm install --frozen-lockfile && pnpm build` in `web/` before `include_dir!` embeds `web/dist`; missing pnpm/node hard-fails the build. The installer wizard preflights the toolchain at its opt-in web step and prompts abort/skip when it's absent. CI gains the Node + pnpm toolchain. - -**Tech Stack:** Rust (cargo build scripts, `std::process::Command`), pnpm 9.15.4 + Node, Gitea Actions CI. - -**Spec:** `docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md` - ---- - -## File Structure - -- **Create** `crates/maestro-web/build.rs` — builds the SPA bundle and declares frontend rerun triggers. Sole responsibility: guarantee `web/dist` is fresh before compile. -- **Modify** `crates/maestro-installer/src/main.rs` — add a pure `classify_web_prereq` decision + `frontend_toolchain_present` probe, and wire the abort/skip prompt into `handle_web`. -- **Modify** `.gitea/workflows/ci.yml` — install Node + pnpm so the workspace compiles `maestro-web`. - -No changes to `static_bundle.rs` (the placeholder stays as unreachable defense-in-depth). - ---- - -## Task 1: build.rs builds the SPA bundle at compile time - -**Files:** -- Create: `crates/maestro-web/build.rs` -- Test: `crates/maestro-web/tests/embedded_bundle.rs` - -- [ ] **Step 1: Write the failing test** - -Create `crates/maestro-web/tests/embedded_bundle.rs`. It asserts the embedded -index is the real SPA (which mounts `
`), not the placeholder -(whose `

` reads "maestro-web is running"). `serve_index` is public in -`maestro_web::static_bundle` via `lib.rs`. - -```rust -//! The compiled binary must embed the real SPA bundle, never the placeholder. -//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs. - -use axum::body::to_bytes; -use maestro_web::static_bundle::serve_index; - -#[tokio::test] -async fn embedded_index_is_real_spa_not_placeholder() { - let resp = serve_index(); - let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); - let html = String::from_utf8(body.to_vec()).unwrap(); - - assert!( - html.contains(r#"id="app""#), - "served index is missing the SPA mount point; got:\n{html}" - ); - assert!( - !html.contains("maestro-web is running"), - "served index is the placeholder, not the built bundle" - ); -} -``` - -`static_bundle` is already `pub mod` in `crates/maestro-web/src/lib.rs` and `serve_index` is public, so no `lib.rs` change is needed. The `axum` and `tokio` dev-deps are already in `Cargo.toml`. - -- [ ] **Step 2: Run the test and verify it fails** - -First force an empty bundle so the test has a true red (this machine already has a built `web/dist`): - -```bash -rm -rf web/dist && mkdir -p web/dist && touch web/dist/.gitkeep -cargo test -p maestro-web --test embedded_bundle -``` - -Expected: FAIL — `serve_index()` returns the placeholder, so the `id="app"` assertion fails. (Without `build.rs` yet, nothing rebuilds the bundle.) - -- [ ] **Step 3: Write the build script** - -Create `crates/maestro-web/build.rs`: - -```rust -//! Build the embedded SPA bundle before the crate compiles. -//! -//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`. -//! This script guarantees that directory holds a freshly built bundle by -//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and -//! node are HARD requirements — a missing toolchain fails the build with an -//! actionable message rather than silently shipping the placeholder. - -use std::env; -use std::path::PathBuf; -use std::process::Command; - -fn main() { - let manifest_dir = - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo")); - let web_dir = manifest_dir.join("../../web"); - - // Rebuild the bundle only when a frontend input changes — not on every - // unrelated Rust recompile. - for rel in ["src", "index.html", "package.json", "pnpm-lock.yaml", "vite.config.ts"] { - println!("cargo:rerun-if-changed={}", web_dir.join(rel).display()); - } - - require_tool("pnpm"); - require_tool("node"); - - run( - Command::new("pnpm") - .args(["install", "--frozen-lockfile"]) - .current_dir(&web_dir), - "pnpm install --frozen-lockfile", - ); - run( - Command::new("pnpm") - .arg("build") - .current_dir(&web_dir), - "pnpm build", - ); -} - -fn require_tool(tool: &str) { - let ok = Command::new(tool) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !ok { - panic!( - "maestro-web requires `{tool}` to build its embedded SPA bundle. \ - Install pnpm + node (https://pnpm.io) or use a prebuilt binary." - ); - } -} - -fn run(cmd: &mut Command, label: &str) { - let status = cmd - .status() - .unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}")); - if !status.success() { - panic!("`{label}` failed with {status}"); - } -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -cargo test -p maestro-web --test embedded_bundle -``` - -Expected: PASS — `build.rs` ran `pnpm build`, repopulating `web/dist`, so the -embedded index now contains `id="app"`. Confirm `web/dist/assets/` has the -built JS/CSS again. - -- [ ] **Step 5: Lint** - -```bash -cargo clippy -p maestro-web --all-targets -- -D warnings -cargo fmt --all -``` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add crates/maestro-web/build.rs crates/maestro-web/tests/embedded_bundle.rs -git commit -m "feat(maestro): build maestro-web SPA bundle in build.rs - -include_dir! now embeds a freshly built web/dist on every compile; pnpm + -node are hard build requirements. Fixes the placeholder served by -cargo install --git. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Installer preflights the frontend toolchain - -**Files:** -- Modify: `crates/maestro-installer/src/main.rs` (add `WebPrereq` enum + `classify_web_prereq` + `frontend_toolchain_present`; wire into `handle_web` at the opt-in point, ~line 389–396) -- Test: `crates/maestro-installer/src/main.rs` (new `#[cfg(test)] mod tests`) - -- [ ] **Step 1: Write the failing test** - -Append to the end of `crates/maestro-installer/src/main.rs` (no test module exists yet): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prereq_ok_when_toolchain_present() { - assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok); - assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok); - } - - #[test] - fn prereq_prompts_when_missing_and_interactive() { - assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing); - } - - #[test] - fn prereq_skips_when_missing_and_noninteractive() { - assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing); - } -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -cargo test -p maestro-installer classify_web_prereq -``` - -Expected: FAIL to compile — `WebPrereq` and `classify_web_prereq` don't exist yet. - -- [ ] **Step 3: Add the enum, decision function, and toolchain probe** - -In `crates/maestro-installer/src/main.rs`, just above `fn handle_web(` (line 350), add: - -```rust -/// What to do at the (opt-in) web step given whether pnpm + node are present. -#[derive(Debug, PartialEq, Eq)] -enum WebPrereq { - /// Toolchain present — install maestro-web normally. - Ok, - /// Missing and we can ask: prompt the operator to abort or skip. - PromptMissing, - /// Missing and we can't prompt (non-interactive refresh): warn + skip. - SkipMissing, -} - -fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq { - if toolchain_present { - WebPrereq::Ok - } else if interactive { - WebPrereq::PromptMissing - } else { - WebPrereq::SkipMissing - } -} - -/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's -/// build.rs needs them to build the embedded SPA bundle. -fn frontend_toolchain_present() -> bool { - ["pnpm", "node"].iter().all(|tool| { - Command::new(tool) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }) -} -``` - -- [ ] **Step 4: Run the unit test and verify it passes** - -```bash -cargo test -p maestro-installer classify_web_prereq -``` - -Expected: PASS (3 tests). - -- [ ] **Step 5: Wire the preflight into `handle_web`** - -In `handle_web`, the existing flow is: - -```rust - if !want_web { - return Ok(false); - } - - // Install the binary. Even if cargo-install fails we keep going so the - // operator can fix the cause and re-run; the existing daemon work - // doesn't need to be undone. - let source = build_source(args); -``` - -Insert the preflight between the `if !want_web` block and `let source =`: - -```rust - if !want_web { - return Ok(false); - } - - // maestro-web's build.rs requires pnpm + node. Check before cargo install so - // a missing toolchain surfaces a clear choice instead of a raw build panic. - match classify_web_prereq(frontend_toolchain_present(), interactive) { - WebPrereq::Ok => {} - WebPrereq::PromptMissing => { - eprintln!("\nmaestro-web needs pnpm + node, which aren't installed."); - if confirm("Abort the whole install? (No = skip web UI)", false)? { - return Err( - "aborted: maestro-web prerequisites (pnpm + node) are missing".into(), - ); - } - eprintln!("Skipping maestro-web install."); - return Ok(false); - } - WebPrereq::SkipMissing => { - eprintln!( - "\nSkipping maestro-web refresh: pnpm + node not installed. \ - Existing unit left in place." - ); - return Ok(false); - } - } - - // Install the binary. Even if cargo-install fails we keep going so the - // operator can fix the cause and re-run; the existing daemon work - // doesn't need to be undone. - let source = build_source(args); -``` - -- [ ] **Step 6: Verify the crate builds, lints, and tests pass** - -```bash -cargo build -p maestro-installer -cargo clippy -p maestro-installer --all-targets -- -D warnings -cargo test -p maestro-installer -cargo fmt --all -``` - -Expected: all clean/pass. (`Command` and `confirm` are already imported/defined in the file.) - -- [ ] **Step 7: Commit** - -```bash -git add crates/maestro-installer/src/main.rs -git commit -m "feat(maestro): preflight pnpm + node at installer web step - -The opt-in maestro-web step now checks for the frontend toolchain its -build.rs requires; missing tools prompt abort/skip (interactive) or skip -the refresh (non-interactive) instead of failing cargo with a raw panic. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: CI installs Node + pnpm - -**Files:** -- Modify: `.gitea/workflows/ci.yml` - -- [ ] **Step 1: Add a Node + pnpm step** - -`clippy` and `test` compile `maestro-web`, which now needs the toolchain. After -the "Install rustup + stable toolchain" step (line 27) and before "Cache -cargo", add: - -```yaml - - name: Install Node.js + pnpm - run: | - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y --no-install-recommends nodejs - corepack enable - corepack prepare pnpm@9.15.4 --activate -``` - -`corepack prepare pnpm@9.15.4` matches the `packageManager` pin in -`web/package.json`, keeping CI deterministic. - -- [ ] **Step 2: Verify locally that the affected jobs compile** - -CI can't be run locally, but confirm the commands the runner executes still -work on a machine with the toolchain: - -```bash -cargo clippy --all-targets -- -D warnings -cargo test --all -``` - -Expected: clean / all pass (these now exercise `maestro-web`'s `build.rs`). - -- [ ] **Step 3: Commit and push** - -```bash -git add .gitea/workflows/ci.yml -git commit -m "ci(maestro): install node + pnpm for maestro-web build.rs - -Compiling maestro-web now requires the frontend toolchain to build the -embedded SPA bundle. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -git push -``` - -- [ ] **Step 4: Confirm CI is green** - -Poll the Gitea CI run for this push (per project preference: rely on CI rather -than long local builds). Expected: `ci / rust` job passes including the new -Node/pnpm step. - ---- - -## Self-Review Notes - -- **Spec coverage:** §1 build.rs → Task 1; §2 static_bundle unchanged → no task (intentional); §3 installer preflight (prompt abort/skip; non-interactive skip) → Task 2; §4 CI → Task 3; §5 Justfile unchanged → no task (intentional). -- **Type consistency:** `WebPrereq::{Ok,PromptMissing,SkipMissing}`, `classify_web_prereq(bool, bool)`, and `frontend_toolchain_present()` are used identically in the definition (Task 2 Step 3), the wiring (Step 5), and the tests (Step 1). -- **Placeholders:** none — every code/command step is concrete. diff --git a/docs/superpowers/specs/2026-05-24-maestro-design.md b/docs/superpowers/specs/2026-05-24-maestro-design.md deleted file mode 100644 index 947f828..0000000 --- a/docs/superpowers/specs/2026-05-24-maestro-design.md +++ /dev/null @@ -1,323 +0,0 @@ -# Maestro — Prototype Design - -**Date**: 2026-05-24 -**Status**: Approved (brainstorm) -**Scope**: First-pass prototype. No production polish. - -> **Superseded (2026-08-22).** Maestro is now single-host: one daemon per -> machine, clients (CLI, TUI, web) talking straight to it, no registry, no -> peer-to-peer attach, and no authentication layer — reaching the daemon's bind -> address is the authorization. Everything below about the registry, the -> daemon↔registry control link, JWTs, cookies, and cross-host transports is -> historical. `CLAUDE.md` describes the current architecture. - -## What this is - -A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about: workstation, desktop, dev VM, always-on VMs, and small LXC containers. - -The interface stays a terminal (xterm.js in the browser for the prototype). Sessions persist via tmux on whichever host owns them. Maestro is a thin coordination layer on top, not a terminal-emulator rewrite. - -## Why - -Today: Claude Code runs locally for editing the host, and inside a dev VM for project work in dangerous mode. The VM uses agent-deck to keep sessions alive. Reaching those sessions means SSH-ing in first, no unified view, no side-by-side, and no phone access. Maestro removes the SSH-in step and adds a phone client. - -## Success criterion for the prototype - -A working demo where: - -1. Laptop and phone both load the maestro web UI. -2. The session list shows tmux sessions from at least two hosts (laptop + dev VM). -3. A new session can be spawned from the UI on a chosen host, optionally backed by a fresh git worktree. -4. Both clients can attach to the same session concurrently and see live output. -5. Closing a client tab doesn't kill the session; reattaching resumes it. - -UI polish, layout/tiling, edge-case terminals — all out of scope. - -## Architecture - -``` - ┌──────────────────────┐ - │ Registry │ - │ (always-on VM) │ - │ │ - │ • presence index │ - │ • session catalog │ - │ • serves web client │ - └──────┬───────────────┘ - │ WS (control only, - │ no PTY bytes) - ┌──────────────────────────┼──────────────────────────┐ - │ │ │ -┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ -│ Daemon │ │ Daemon │ │ Daemon │ -│ (laptop) │ │ (dev VM) │ │ (LXC) │ -│ │ │ │ │ │ -│ tmux ↔ WS │ │ tmux ↔ WS │ │ tmux ↔ WS │ -└──────▲──────┘ └──────▲──────┘ └──────▲──────┘ - │ │ │ - │ WS streams (PTY bytes, peer-to-peer over the operator's network) - │ │ │ - └──────────────┬───────────┴──────────────┬───────────┘ - │ │ - ┌───────▼───────┐ ┌───────▼───────┐ - │ Web client │ │ Web client │ - │ (browser) │ │ (phone) │ - └───────────────┘ └───────────────┘ -``` - -Three components, all reachable over an operator-managed network (LAN, VPN, mesh — maestro is network-agnostic; the operator configures bind addresses and ensures clients can route to them): - -- **Registry** — one always-on instance. Knows who's online and what sessions exist. Serves the web client bundle. Never touches PTY bytes. -- **Daemon (`maestro daemon`)** — one per host. Wraps local tmux. Streams sessions directly to clients over WebSocket. -- **Web client** — one TS bundle. Connects to the registry for the catalog, then directly to whichever daemon owns the session being attached. - -The registry is a phone book, not a relay. This keeps it tiny and pushes byte traffic to a direct daemon ↔ client link. Confidentiality of that link is the operator's responsibility (Wireguard, a private LAN, mTLS in a future iteration — maestro itself does not encrypt at the transport layer beyond what the operator's network provides). - -## Components - -### Registry - -Responsibilities: - -- Accept persistent WS connections from daemons (presence + session-list updates). -- Issue short-lived JWTs to authenticated clients for daemon access. -- Serve the web client bundle and a small HTTP API. -- Broadcast catalog changes to connected web clients via a `/api/events` WS. - -Non-responsibilities: - -- No PTY bytes through this process. -- No persistent storage in the prototype. State is in-memory; daemons re-register on reconnect. -- No multi-user model. One shared secret, one effective user. - -Runs as a single Rust binary on a designated VM. Listens on a configured `bind_addr` (default `127.0.0.1:8080`; operator sets the actual reachable address — e.g. a Wireguard interface IP — in `[registry].bind_addr` of the unified config at `~/.config/maestro/config.toml`). - -### Daemon (`maestro daemon`) - -Responsibilities: - -- Connect to the registry on startup, identify with the shared token, advertise its current sessions. -- Maintain a tmux session list. Spawn / kill tmux sessions on demand. -- Expose a per-session WS endpoint streaming PTY bytes both directions. -- Handle git worktree creation on session create, when requested. -- Recover existing sessions on restart by scanning tmux for names matching its naming pattern. - -Non-responsibilities: - -- Doesn't adopt foreign tmux sessions (only the ones it created — names prefixed `maestro-`). -- Doesn't relay to other daemons. It only knows about local tmux. -- Doesn't manage its own auth tokens; the registry issues them. - -Runs as a single Rust binary on every host that holds sessions. Small footprint matters because some hosts are LXC containers. Listens on a configured `bind_addr` (default `127.0.0.1:8081`; operator sets the actual reachable address in `[daemon].bind_addr` of the unified config at `~/.config/maestro/config.toml`). Also requires `[shared].registry_url` so it knows where to register on startup. - -### Web client - -Responsibilities: - -- Authenticate to the registry (cookie set on first visit; assumes the operator restricts who can reach the registry's bind address). -- Render the session catalog as a tree, using each session's `metadata.label` (e.g. `personal/maestro`) to derive grouping. -- Provide search across session names and labels. -- Open a WS to a daemon to attach to a chosen session; render via xterm.js. -- Support concurrent attaches (multiple tabs to the same session work). -- Spawn a new session through a small form: pick host, working dir, command, optional worktree. - -Non-responsibilities (prototype): - -- No split-pane / side-by-side. One session at a time per tab — but you can open two browser tabs to two sessions, which gets you side-by-side via the OS. -- No theming / settings UI. -- No mobile-specific UX beyond "works in mobile Chrome at a reasonable touch size." - -TypeScript + xterm.js. Build tool TBD in implementation (Vite is the default); frontend framework choice is non-architectural and deferred to the plan. - -## Wire protocol - -Three pairs of conversations. All JSON unless otherwise noted. - -### Daemon ↔ Registry — single persistent WS, daemon-initiated - -| Direction | Message | Purpose | -|---|---|---| -| D→R | `Hello { daemon_id, hostname, version, token }` | identify, auth | -| D→R | `SessionsSnapshot { sessions: [Session] }` | full list at startup | -| D→R | `SessionAdded { session }` | diff | -| D→R | `SessionRemoved { id }` | diff | -| D→R | `SessionUpdated { session }` | diff | -| D→R | `Heartbeat` | every ~10s | -| D→R | `CreateSessionResult { reply_to, result }` | reply to a `CreateSession` request, carries the new `Session` or an error | -| D→R | `KillSessionResult { reply_to, result }` | reply to a `KillSession` request | -| R→D | `Ack` / `Error { code, message }` | acks, faults | -| R→D | `CreateSession { request, reply_to }` | registry asks daemon to spawn a session; `reply_to` correlates with the result | -| R→D | `KillSession { id, reply_to }` | registry asks daemon to kill; `reply_to` correlates with the result | - -The HTTP `POST /api/sessions` handler in the registry holds the client request open while it sends `CreateSession` to the chosen daemon over the WS, then completes the response when it receives the matching `CreateSessionResult`. Same pattern for `DELETE`. Each request gets a fresh `reply_to` UUID. Timeout is 10s; after that the registry returns 504 to the client. - -### Client ↔ Registry — HTTP for queries, one WS for live updates - -| Endpoint | Purpose | -|---|---| -| `GET /` | static web client bundle | -| `GET /api/daemons` | list connected daemons | -| `GET /api/sessions` | flat session list | -| `POST /api/sessions` | spawn (request forwarded to chosen daemon) | -| `DELETE /api/sessions/:id` | kill (forwarded to owning daemon) | -| `GET /api/sessions/:id/connect-info` | returns `{ daemon_url, jwt }` for direct attach | -| `WS /api/events` | live catalog + presence deltas | - -Auth: the registry binds its listener to the operator-configured `bind_addr` only, so packets from elsewhere never reach the process. The operator is expected to point this at a private interface (Wireguard, LAN, etc.) — maestro does not police the network itself. On first request, the registry sets an httpOnly cookie signed with the shared secret; all subsequent API calls require it. The cookie is the second factor against anyone who happens to have routing to the bind address. - -### Client ↔ Daemon — one WS per attached session - -`WS /sessions/:id/stream?token=` - -- **Text frames**: control JSON — `Resize { cols, rows }`, `Ping`, `Pong`, `Error { ... }`. -- **Binary frames**: raw PTY bytes, bidirectional. - -JWT is short-lived (~5 min) and validated by the daemon using the shared secret. No callback to registry. - -Daemon broadcasts incoming PTY output to all currently-attached client WS connections for that session. - -## Session model - -A `Session` is the only first-class entity. The daemon is the source of truth for its own sessions. - -```rust -struct Session { - id: Uuid, // maestro's id; tmux session name is `maestro-` - daemon_id: String, - cwd: PathBuf, // resolved at create time (may be a worktree path) - command: String, - env: HashMap, - metadata: HashMap, // opaque; common keys: name, label - status: SessionStatus, // Running | Dead - created_at: DateTime, -} -``` - -`metadata` is intentionally opaque to the daemon and registry. Clients use convention — e.g. `label: "personal/maestro"` becomes a tree path. The protocol doesn't enforce shape. - -### Create options - -`POST /api/sessions` body (forwarded to daemon as `CreateSession`): - -```json -{ - "daemon_id": "...", - "cwd": "/home/naps62/projects/maestro", - "command": "claude", - "env": { "FOO": "bar" }, - "metadata": { "name": "maestro-impl", "label": "personal/maestro" }, - "worktree": { - "base_repo": "/home/naps62/projects/maestro", - "branch": "feat/foo", - "cleanup_on_kill": false - } -} -``` - -`worktree` is optional. When present, the daemon runs `git worktree add -b ` rooted at `base_repo`, then uses the generated path as `cwd`. If `cleanup_on_kill` is true, the daemon prunes the worktree on `KillSession`. Default is false — the worktree stays on disk. - -### Lifecycle - -``` - Create Attach Detach Kill - │ │ │ │ - ▼ ▼ ▼ ▼ - client→registry client→daemon close WS client→daemon - POST /sessions WS /sessions/:id (session DELETE /sessions/:id - │ │ keeps │ - ▼ ▼ running) ▼ - registry→daemon daemon attaches daemon kills - CreateSession via tmux pipe-pane tmux session, - │ │ optionally - ▼ ▼ prunes worktree - daemon: (optional) bidirectional stream - git worktree add - │ - ▼ - daemon: tmux new-session - -d -s maestro- -c - │ - ▼ - daemon→registry - SessionAdded -``` - -### Crash and restart recovery - -- **Daemon restart**: on boot, the daemon lists tmux sessions matching `maestro-*`. For each, it reads a sidecar JSON in `~/.local/state/maestrod/sessions/.json` (written at create time) to recover metadata. Sessions whose sidecars are missing are still advertised but with empty metadata. The daemon re-establishes the WS to the registry and sends a fresh `SessionsSnapshot`. -- **Registry restart**: in-memory state is lost. Daemons hold an outbound WS with exponential-backoff retry; within seconds of registry coming back, every daemon has re-registered and the catalog is rebuilt. Clients holding direct daemon WS streams are unaffected — only catalog-listing breaks temporarily. -- **Client disconnect**: just closes the WS. Session keeps running. Reconnecting re-attaches. -- **Daemon dies / unreachable**: registry marks it offline after a missed heartbeat (~30s). Its sessions disappear from the catalog. When it comes back, they reappear. - -## Auth - -- Registry generates the shared token at first run, printed once, stored as `[shared].token` in `~/.config/maestro/config.toml` (env var name: `MAESTRO_TOKEN`). -- Same token deployed to each daemon (config file or env var) and used in `Hello`. -- Web client gets a cookie from the registry on first visit. All registry API calls use it. -- Per-attach JWT issued by registry, signed with the shared secret, validated locally by the daemon. ~5 min expiry; client refreshes by re-requesting `GET /api/sessions/:id/connect-info`. - -Single-user trust model. The operator's network (Wireguard / private LAN / etc.) is the perimeter; the token is the second factor against anyone who happens to have routing to the bind addresses. - -## Repo layout - -Cargo workspace. NAP-245 collapsed the three binaries (`maestro`, `maestrod`, `maestro-registry`) into a single `maestro` binary with feature-gated subcommands. Library crates kept their existing shape; per-binary `main.rs` files are gone. - -``` -maestro/ -├── Cargo.toml # workspace -├── crates/ -│ ├── maestro/ # umbrella binary: `maestro ` (features: cli/daemon/registry) -│ ├── maestro-protocol/ # message types, shared between subsystems -│ ├── maestro-config/ # unified on-disk config schema -│ ├── maestro-cli/ # CLI subcommand types + dispatch (library) -│ ├── maestro-daemon/ # daemon subsystem (library + `maestro daemon` dispatch) -│ ├── maestro-registry/ # registry subsystem (library + `maestro registry` dispatch) -│ ├── maestro-web/ # standalone `maestro-web` browser-UI binary -│ ├── maestro-installer/ # `maestro-install` wizard -│ └── maestro-e2e/ # cross-process integration tests -├── web/ -│ ├── src/ -│ ├── package.json -│ └── vite.config.ts -├── docs/superpowers/specs/ # this file, future specs -├── .claude/ -│ └── linear.json -└── CLAUDE.md -``` - -Thin builds for single-role hosts: `cargo install --no-default-features --features {cli|daemon|registry} maestro` produces a binary with only that subsystem's dependencies compiled in. - -## Out of scope (prototype) - -| Deferred | Why | -|---|---| -| UI polish, themes, animations | prove it works first | -| Side-by-side / split-pane layout in one tab | protocol supports concurrent attaches; two browser tabs is the workaround | -| Native TUI client, Leptos/WASM client, native Android | protocol is stable; addable later | -| Public internet exposure / TLS on the wire | maestro doesn't terminate TLS or police the network; operator runs it on a private network | -| Multi-user / roles / RBAC | single user, single token | -| Persistent registry storage | in-memory, re-derived from daemon reconnects | -| Auto-adopting foreign tmux sessions | daemon only owns sessions it created | -| Auto-restart of dead inner processes | tmux keeps the session; if the inner command exits, it exits | -| Recording / replay | future | -| File transfer / drag-drop | future | - -## Testing strategy - -- **`maestro-protocol`**: unit tests on every message variant, JSON round-trip, schema stability. -- **`maestro-daemon`**: integration tests against a real `tmux` binary in a temp `$HOME` (with `TMUX_TMPDIR` set). Worktree creation tested against a tmpdir git repo (`git init`, then exercise `worktree add` + cleanup). -- **`maestro-registry`**: tests with a mock daemon WS client. Cover register, heartbeat-timeout, session diff propagation, JWT issuance. -- **End-to-end**: harness that spawns one registry + two daemons as subprocesses in temp dirs, then a programmatic client (using `tokio-tungstenite`) that lists, spawns, attaches, sends bytes, kills. Asserts on both daemons' tmux state and on registry catalog deltas. -- **Web client**: smoke-only for the prototype. Manual verification in desktop Chrome and Android Chrome. A single Playwright test if it doesn't slow CI. - -CI runs the Rust workspace; `npm run build` is gated on it succeeding. - -## Open questions to resolve during implementation - -These don't block the design but should be settled early in the plan: - -- **Frontend framework**: vanilla TS, Preact, SolidJS, or Leptos-WASM from day one? Pick during the implementation plan; doesn't affect the protocol. -- **Sidecar persistence file format**: JSON is fine; only flagged because if it grows, we'll want a single sqlite per-daemon instead. -- **PTY backend details**: the prototype uses `tmux pipe-pane` for output capture and `tmux send-keys` for input. If pipe-pane latency or framing turns out to be unworkable, fallback is `portable-pty` wrapping `tmux attach`. This is implementation-only — no protocol impact. -- **Registry HTTP framework**: `axum` is the default Rust choice; reconfirm at plan time. -- **CLI scope**: keep it minimal (`maestro list`, `maestro spawn`, `maestro attach`) or fuller. Probably minimal. diff --git a/docs/superpowers/specs/2026-05-28-maestro-tui-design.md b/docs/superpowers/specs/2026-05-28-maestro-tui-design.md deleted file mode 100644 index b34581d..0000000 --- a/docs/superpowers/specs/2026-05-28-maestro-tui-design.md +++ /dev/null @@ -1,410 +0,0 @@ -# Maestro TUI — design - -**Status:** draft (brainstormed 2026-05-28) -**Scope:** new `maestro tui` subcommand on the umbrella `maestro` binary, providing a multi-session terminal UI that talks to the local daemon. -**Related:** -- `docs/superpowers/specs/2026-05-24-maestro-design.md` — overall maestro architecture -- `docs/superpowers/specs/2026-05-26-connection-orchestration.md` — broker / catalog-mirror surface this TUI rides on -- NAP-230 (CLI attach footer) — adjacent, separate; NAP-244 (CLI through broker) — orthogonal - -> **Partly superseded (2026-08-22).** Maestro is now single-host — the registry and cross-host catalog it references no longer exist. See `CLAUDE.md`. - -## 1. Goals & non-goals - -### Goals - -- A keyboard-and-mouse driven terminal UI that exposes the same surface as `maestro-web`: list daemons + sessions, spawn, attach, kill. -- Multi-session viewing: attach to several sessions at once, switch between them as tabs, split tabs into side-by-side panes — mental model is vim's buffers/windows/tabs. -- Sessions remain live in the background even when not visible (their PTY bytes are consumed continuously into a maintained screen state). -- Configurable keymap: every action is bindable, the shipped defaults are just defaults. -- First-class mouse support (click sidebar / tabs / panes, drag split dividers). - -### Non-goals (v1) - -- A vim-style `:` command palette. Parked as v1.5 candidate once direct bindings prove their reach. -- Persisting tabs/splits across TUI restarts. -- Saving named split layouts ("workspaces") to disk. -- Pixel-perfect rendering across exotic terminal emulators — we trust ratatui + crossterm. - -### Phasing - -The full goal set above is what the design supports — but for delivery the work is staged so we ship something useful before the split-tree work is done: - -- **v1** — sidebar + tab bar + one pane per tab, full multi-session liveness, configurable keymap, mouse. No splits. The layout tree (§3.3) is implemented but every tab has exactly one leaf. -- **v1.5** — splits enabled. The pane-tree code path is already there; v1.5 unlocks `SplitHorizontal` / `SplitVertical` actions and the focus-motion / resize bindings. - -Same crate, same implementation plan, just two milestones inside it. - -### Out of scope (forever) - -- Adding persistence to maestro. Sessions persist because tmux owns them on the daemon side. The TUI is a multi-attach *client*; closing it closes its WS connections, the sessions keep running. - -## 2. Architecture - -``` - ┌──────────── maestro tui (process) ─────────────┐ - │ │ - │ ┌──────────┐ ┌──────────────────────────┐ │ -catalog WS ───► │ │ Catalog │ │ Render loop │ │ -HTTP REST ────► │ │ store │◄──►│ (ratatui, 30 Hz max) │ │ - │ └──────────┘ │ sidebar │ tabs │ panes │ │ - │ └──────────────────────────┘ │ - │ ▲ ▲ │ - │ ┌────────────────────┐ │ │ │ - │ │ Session emulators │──┘ │ │ - │ │ (1 task / session) │ │ │ - │ │ vt100 ↔ WS ↔ buf │ │ │ - │ └────────────────────┘ │ │ - │ │ │ - │ ┌────────────────────┐ │ │ - │ │ Input router │──────────┘ │ - │ │ (crossterm events) │ │ - │ └────────────────────┘ │ - └────────────────────────────────────────────────┘ - │ ▲ ▲ - ▼ │ │ - ┌──────── local maestro daemon ────────┐ - │ GET /api/sessions /api/daemons │ - │ POST /api/sessions │ - │ DEL /api/sessions/:id │ - │ WS /api/events │ - │ WS /api/attach/:id ×N │ - └──────────────────────────────────────┘ -``` - -Four concurrent concerns inside the TUI process, each its own Tokio task / unit: - -1. **Catalog store** — single source of truth for the daemons-and-sessions tree. Bootstrapped via `GET /api/daemons` + `GET /api/sessions`, then kept live by `WS /api/events`. -2. **Session emulators** — one task per *attached* session, owning a `tokio_tungstenite` WS to `/api/attach/:id` and a `vt100::Parser`. Stays alive even when the session is not visible — that is how background liveness works. -3. **Input router** — owns the crossterm event stream. Distinguishes between TUI-mode keys (sidebar nav, tab/split keys, action keys, mouse events) and pass-through keys (anything else when a session pane is focused, forwarded as binary frames to that session's WS sink). -4. **Render loop** — at most ~30 Hz, redraws on (a) any emulator screen-dirty bit, (b) catalog change, (c) input that changes focus/layout. Composes: sidebar (left), tab bar (top), pane tree (rest). - -The TUI talks **only** to the local daemon's `/api/*` surface — same contract `maestro-web` uses. Peer sessions reach us via the catalog mirror (NAP-246); attaching to a peer session goes through the broker (NAP-247) without any TUI-side awareness of "remote". - -The new crate is `crates/maestro-tui/`. It lives behind a `tui` cargo feature on the `maestro` umbrella binary, included in the default feature set. Registry-only / daemon-only hosts opt out per the established NAP-245 pattern (`--no-default-features --features registry`). - -## 3. Components - -### 3.1 Sidebar (`sidebar::Sidebar`) - -Left column, default 28 cols wide, togglable. Two-level tree: daemons → sessions, ordered (self first, peers alphabetical). Each session row shows label, command, age, and a status glyph: - -- ● attached in current TUI -- ○ live but not attached -- ✗ ended (kept visible briefly, then aged out by catalog events) - -Sidebar is navigation-only. Possible actions: highlight motion, attach highlighted, toggle-attach highlighted, collapse/expand daemon. It never enters Insert mode and never forwards keystrokes to a session. - -### 3.2 Tab bar (`tabs::TabBar`) - -Top row of the main area. A tab is a named workspace owning a layout tree. Tabs are TUI-local — closing the TUI does not persist them. - -### 3.3 Layout tree (`layout::Layout`) - -A binary tree per tab. Leaves are `PaneId`s; internal nodes are horizontal or vertical splits with a ratio in `[0.1, 0.9]`. Operations: - -- `split(direction)` — replace the focused leaf with a split. The new pane inherits the same session by default (vim semantics: `:split` shows the current buffer in both). -- `close()` — remove the focused leaf, collapse the parent. -- `move_focus(direction)` — geometric: find the nearest leaf in `h/j/k/l` direction. -- `resize(direction, delta)` — adjust the nearest ancestor split's ratio. - -Each leaf binds to a `SessionId`. Multiple leaves *can* bind to the same session — viewing the same session in two splits, identical to vim opening a buffer in two windows. The session's emulator owns one canonical screen; multiple panes render that same screen. - -### 3.4 Spawn modal (`spawn_modal::SpawnModal`) - -Standalone overlay form for creating a session. Fields: daemon (dropdown sourced from the catalog), cwd (text), command (text), label (text, optional), env entries (repeatable `KEY=VALUE`). Submit constructs a `CreateSessionRequest` and `POST /api/sessions`; the new session appears in the sidebar via the events WS, not by waiting on the POST response. Field set matches the existing `SpawnArgs` in `crates/maestro-cli/src/cli.rs`. - -### 3.5 Attach pane (`pane::Pane`) - -Renders the bound session's emulator screen via `vt100::Screen` into a `ratatui` `Buffer`. When focused in Insert mode, all keystrokes that aren't the leader are forwarded to that session's WS as binary frames. On every render, if the pane's allocated `(cols, rows)` differs from the last value sent for that session, the TUI sends a `ClientControl::Resize`. When multiple panes show the same session at different sizes, the per-dimension minimum wins: `cols = min(p.cols)` and `rows = min(p.rows)` across all panes bound to the session, computed independently. Matches tmux's convention when a session is attached at two sizes. - -### 3.6 Help overlay (`help::Help`) - -Transient overlay listing all current bindings (resolved through the active keymap). Closes on any key. This is the discoverability mechanism in lieu of a command palette. - -### 3.7 Modal model - -Three mutually-exclusive modes: - -- **Normal** — sidebar nav, tab/split keys, action keys (spawn, kill, help). Default. -- **Insert** — pane has full passthrough; the only intercepted key is the **leader**. Enter from Normal when a pane is focused; leave with the configured `LeaveInsert` binding. -- **Modal overlay** — Spawn modal, Kill-confirm, or Help. Captures all input until dismissed. - -The leader is the one byte the TUI consumes in Insert mode — same idea as tmux's prefix. Default `Ctrl-a`, configurable. - -### 3.8 Action set (the verbs the TUI knows about) - -This is the closed list of things the user can ask the TUI to do. Bindings map input → action; the action surface itself is the stable contract. - -| Action | Effect | -|---|---| -| `EnterInsert` | Active pane begins forwarding all input (except leader) to the session WS | -| `LeaveInsert` | Return to Normal mode | -| `FocusSidebar` | Move focus to the sidebar tree | -| `FocusPane(Left/Right/Up/Down)` | Geometric focus motion across the current tab's layout | -| `AttachHighlighted` | Attach to the sidebar's highlighted session: if a pane is focused (in the main area), bind the session to that pane (replacing its previous binding); if focus is on the sidebar, open the session in a new tab | -| `ToggleAttachHighlighted` | Attach/detach the highlighted session without changing focus | -| `OpenSpawnModal` | Open the spawn form | -| `KillHighlighted` | Kill highlighted session (always followed by a confirm overlay; not bindable to bypass) | -| `NewTab` / `CloseTab` / `NextTab` / `PrevTab` / `GotoTab(n)` | Tab management | -| `SplitHorizontal` / `SplitVertical` / `ClosePane` | Split-tree management | -| `ResizePane(dir, delta)` | Adjust nearest split ratio | -| `ToggleSidebar` | Hide / show sidebar | -| `ShowHelp` | Open the help overlay | -| `Quit` | Quit (confirm if any pane has an active attach) | - -### 3.9 Keymap (`keymap::Keymap`) - -A two-level table loaded from config: `mode → (input → action)`, with separate Normal and Insert tables (Insert is sparse — usually only the leader is bound). Input lives in a canonical form: `"ctrl-w h"`, `"g t"`, `"mouse:click(pane)"`, `"mouse:dblclick(sidebar-row)"`, `"mouse:drag(split-divider)"`. - -Maestro ships a default keymap that covers every action — recorded as the **shipped default**, not a hardcoded contract. Defaults to be finalized during implementation; the configuration surface is the part being committed to here. - -Config lives at `[tui.keymap]` in `~/.config/maestro/config.toml`: - -```toml -[tui.keymap] -leader = "ctrl-a" -chord_timeout_ms = 1000 - -[tui.keymap.normal] -# (illustrative — not contractual) -"i" = "EnterInsert" -"s" = "OpenSpawnModal" -"x" = "KillHighlighted" -"ctrl-t" = "NewTab" -"ctrl-w h" = "FocusPane(Left)" -"mouse:click(pane)" = "FocusPaneAtCursor" -"mouse:drag(split-divider)" = "ResizePaneByDrag" -"mouse:click(sidebar-row)" = "HighlightSidebarRow" -"mouse:dblclick(sidebar-row)" = "AttachHighlighted" -"mouse:click(tab)" = "GotoTabAtCursor" - -[tui.keymap.insert] -"" = "LeaveInsert" -``` - -User can rebind, unbind (`= "Noop"`), or supply a partial table that merges over the shipped defaults. - -**Chord handling.** Prefixes (`g t`, `ctrl-w h`) are handled by a small state machine inside the resolver: if the current key extends a known prefix, hold it for one cycle waiting for the next key; if the next key doesn't complete a binding, drop the chord. Timeout (default 1s, configurable via `chord_timeout_ms`) flushes pending chords. - -**Validation.** Parsed at TUI startup in two passes: - -1. **Lex pass** — canonicalise each key string. Bad input (`"ctrl-glorb"`) collected with line numbers. -2. **Resolve pass** — every action name resolved against the closed `Action` enum. Unknown actions collected. - -If either pass produces errors, the TUI prints the diagnostic list and exits 2. A `--keymap-check` flag (validate without launching) makes this scriptable. Prefix conflicts (`g` shadows `g t`) are warnings, not errors — last-load-wins. - -### 3.10 Mouse support - -Crossterm exposes mouse events; ratatui forwards them. The TUI consumes them in two layers: - -1. **Region hit-test** — the render layer tags regions (sidebar rows, tab cells, split dividers, panes) with stable identifiers; the input router resolves a mouse coordinate to one tagged region. -2. **Action binding** — mouse events become canonical input strings; the same keymap table handles them as keyboard input. - -Mouse passthrough to the session (for tmux-aware programs that want their own mouse) is enabled when a pane is in Insert mode and the underlying session indicates mouse support; otherwise the TUI consumes the click. The exact heuristic for "underlying session indicates mouse support" is to be validated during implementation — `TERM` inspection is the obvious starting point. - -## 4. Data flow - -### 4.1 Catalog bootstrap & live updates - -On startup the TUI hits `GET /api/daemons` and `GET /api/sessions` against the local daemon (`http://127.0.0.1:7070` by default, configurable via `[shared].daemon_url`/`MAESTRO_DAEMON_URL`). The two responses populate `CatalogStore`: - -```rust -struct CatalogStore { - daemons: HashMap, - sessions: HashMap, - version: u64, // bumped on every change -} -``` - -Then it opens `WS /api/events`. The receive task deserialises `RegistryEvent` frames (already the protocol surface emitted by `LocalEventBus`, NAP-248) and applies them: - -- `SessionCreated` → insert -- `SessionUpdated` → replace -- `SessionEnded` → remove and mark any attached emulator as `Ended` -- `DaemonJoined` / `DaemonLeft` → likewise - -Each apply bumps `version` and wakes the render loop via a `Notify`. - -### 4.2 Attach lifecycle - -Triggered by the `AttachHighlighted` action: - -1. Look up the highlighted session in the catalog. -2. Open `WS /api/attach/:id` against the **local** daemon. No client auth — the local-bind trust model is "whoever can reach `bind_addr`" (matches `local_api.rs` v1). The broker handles JWT minting for cross-daemon hops internally; that's transparent to the TUI. -3. Spawn a `SessionTask`: - - Owns `(ws_sink, ws_stream, parser: vt100::Parser, screen_arc: Arc>)`. - - Receive side: `Message::Binary(bytes)` → `parser.process(&bytes)`; on dirty screen, notify the render loop. - - Receive side, text frames: route `ClientControl::Ping` back as `Pong`; other control frames are no-ops in v1. - - Send side: receives `SessionInput` enum from the input router — `Bytes(Vec)` becomes a binary frame; `Resize { cols, rows }` becomes a `ClientControl::Resize` text frame. - - On WS close: state transitions to `Ended`; the screen remains visible (frozen last frame) until the pane is closed. - -```rust -struct SessionHandle { - tx: mpsc::Sender, - screen: Arc>, - state: Arc, // Connecting | Live | Reconnecting | Ended -} - -// Lives in: -struct SessionEmulators(HashMap); -``` - -Panes hold a `SessionId` and look up the handle on every render — cheap and avoids stale references when a session ends and a new pane binds to a new one. - -### 4.3 Resize propagation - -Each pane, on render, computes its `(cols, rows)` from the ratatui `Rect` it is given. It compares with `last_sent_size: HashMap`. After the render pass, the per-session minimum across all panes bound to that session is recomputed; if it changed, one `Resize` is sent. Window-level SIGWINCH triggers a full re-layout; the same per-session min-size recompute handles propagation. - -### 4.4 Input routing - -Per frame the input router pulls events from the crossterm stream and runs: - -``` -Event → canonicalise(Event) # "ctrl-a", "g t", "mouse:click(pane)" - → Keymap.resolve(mode, key) # → Option - → if Some(action): dispatch(action) - → if None: if mode == Insert and focus is a pane → forward bytes to that pane's SessionTask - else: ignore -``` - -Bytes forwarded to a session in Insert mode go straight onto the session task's input channel without TUI interpretation — that is how `Ctrl-C`/`Ctrl-Z`/`Esc` reach the remote shell. The leader is the **only** key the TUI consumes in Insert mode. - -### 4.5 Spawn & kill - -Spawn: `OpenSpawnModal` → form → submit constructs a `CreateSessionRequest` and `POST /api/sessions`. On 201, the catalog WS will emit a `SessionCreated` that adds it to the sidebar; the modal closes on submit, doesn't wait for the event. - -Kill: `KillHighlighted` → confirm overlay → on confirm `DELETE /api/sessions/:id`. Same pattern — the event stream is the source of truth for the disappearance; the request just kicks it off. - -If either request fails (4xx / 5xx / network), a status-bar toast surfaces the error for ~5s; catalog state is unchanged. - -## 5. Error handling & resilience - -### 5.1 Daemon unreachable at startup - -If the initial `GET /api/web/info` (health probe) fails after a short retry (3× 500ms backoff), the TUI does not enter the main loop. It prints a single line on the inherited terminal explaining what it tried to reach and how to fix it (`maestro daemon configure`, `systemctl --user status maestro-daemon`), then exits 1. - -### 5.2 Events WS reconnect - -On disconnect: exponential backoff (1s base, 30s cap, ±25% jitter, infinite retries). Sidebar shows `△ catalog offline` in its footer until reconnect. On reconnect, re-fetch `/api/daemons` + `/api/sessions` and diff against `CatalogStore` — sessions that disappeared while disconnected become `Ended`, new ones appear. This avoids drift across reconnects without needing the events WS to replay missed events. - -### 5.3 Attach WS drops (per-session) - -A session's WS dropping does *not* end the session — only signals our client lost contact. The `SessionTask` transitions to `Reconnecting`, posts a status overlay on every pane bound to it (`reconnecting…`), and retries with the same backoff. On reconnect: - -- Re-open `WS /api/attach/:id` on the local daemon. -- Send a fresh `ClientControl::Resize`. -- Do **not** clear the vt100 screen — last-known frame stays visible (frozen) until new bytes arrive. - -If the underlying session has actually ended (daemon emits `SessionEnded` on the events WS while we're reconnecting), the task aborts reconnect and transitions to `Ended`. - -### 5.4 vt100 parser glitches - -`vt100::Parser::process` is infallible on malformed input — it discards unrecognized sequences. The user-visible failure mode is rendering glitches (wrong colour cells, wrap math off). Recovery: a per-pane `RedrawSession` action (default unbound, available to keymap) re-initialises the parser at the current size and asks the remote `tmux refresh-client -t maestro-` via a daemon endpoint (`POST /api/sessions/:id/refresh`, **new in v1**, shared with `maestro-web`). - -If `vt100` actually panics on input (shouldn't, but: parser bugs happen), the panic hook catches it, logs, and ends that one `SessionTask` cleanly — the rest of the TUI stays up. - -### 5.5 Terminal restore on panic / abrupt exit - -The TUI installs `panic::set_hook` and a `SIGTERM`/`SIGINT` handler that: - -- Disable mouse capture -- Leave the alternate screen buffer -- Restore the original termios (saved at startup) -- Print the panic message / signal name -- Re-raise the signal so the parent shell sees the correct exit reason - -A `Drop` impl on the same RAII guard handles the normal-exit path. Pattern matches the existing `RawTty` guard in `crates/maestro-cli/src/attach.rs`. - -## 6. Testing strategy - -### 6.1 Unit tests — pure logic, no I/O - -- `keymap::parse` — table tests over the canonical form: valid key/chord strings parse, bad strings surface the right diagnostic, conflicting bindings produce the expected warning. -- `layout::Layout` — algebraic: split-then-close is identity, focus motion under arbitrary trees finds the geometrically-nearest leaf, ratio bounds are enforced. Property tests via `proptest`: "focus is always on a real leaf", "ratios sum to 1.0 within each split", "close-on-singleton collapses to empty". -- `action::dispatch` — given a `(CatalogStore, Layout, Mode)` snapshot and an `Action`, the post-state is deterministic. Covers edge cases (kill-while-focused-pane-shows-it, attach-when-already-attached, close-pane-when-only-one-left). -- `vt100` framing — bytes-in, screen-out. We don't test `vt100` itself, but we do test that our `SessionTask`'s parser correctly accumulates a partial CSI across `process` calls (WS frames have no guarantee of sequence alignment). Fixtures from real bash/vim/claude byte streams. - -### 6.2 Integration tests — against a real daemon (in-process) - -`crates/maestro-tui/tests/integration.rs` spins up a `SessionManager` + `LocalApiState` + axum router on a random port — same seam `maestro-e2e` uses. The TUI's networking layer is pointed at that port. - -Coverage: - -- Catalog bootstrap loads both REST snapshots and reaches steady state. -- Events WS receives `SessionCreated` after `POST /api/sessions` and updates the sidebar state. -- Attach against a daemon-owned bash session round-trips bytes (test feeds keystrokes through the input router, asserts the bash session sees them via `echo`'d output through the same WS into the parser). -- Reconnect: forcibly drop the events WS, assert backoff fires, assert state reconciles on reconnect. - -Real `tmux`, isolated `$TMUX_TMPDIR`. Behind `cargo test --features integration`; CI runs them, default `cargo test` does not (slow). - -### 6.3 Render snapshot tests - -`ratatui::backend::TestBackend` for known-buffer assertions: - -- Sidebar tree rendering (collapsed, expanded, with status glyphs). -- Tab bar at varying counts, with truncation. -- Split layouts (h-split, v-split, nested). -- Help overlay. -- Spawn modal. - -Goldens stored under `tests/snapshots/`. Choice between `insta` or a small home-grown matcher made during implementation. - -### 6.4 End-to-end via `maestro-e2e` - -`maestro-e2e` (NAP-217) already spins registry + two daemons. We add a `tui` e2e target that drives the TUI by piping crossterm events into a substitutable Tokio source. - -Coverage: - -- "Spawn → catalog updates → attach → type → see output → kill" round-trip across two daemons. -- Sidebar shows peer sessions (catalog mirror); attach against a peer session goes through the broker (NAP-247) without TUI-side awareness of "remote". - -Behind the same `--features e2e` gate. - -### 6.5 What we explicitly don't test - -- Pixel-perfect rendering across exotic terminals — trust `ratatui` + `crossterm`. Manual smoke per release. -- Mouse behaviour in CI — non-interactive runs don't expose mouse. Mouse hit-test logic (region tagging → resolved region) is covered by unit tests; the actual event plumbing is exercised manually. -- `vt100` correctness on weird ANSI — if a program breaks rendering, file a follow-up with the byte stream as a fixture. - -### 6.6 Acceptance bar for v1 sign-off - -Rough target: ~30 unit, ~6 integration, ~10 snapshot, ~2 e2e. Not a contract — a sanity check that we've covered each layer. - -## 7. Open questions / follow-ups parked for after v1 - -- **Command palette** — vim-`:` style ex-line. To revisit once direct bindings prove their reach in real use. -- **Tab persistence** — saving the tab/split layout across TUI restarts. Probably wanted, but only after the layout primitives are stable. -- **Named workspaces** — saving and restoring multiple layouts. -- **Mouse passthrough heuristic** — finalize how we detect that the inner program wants mouse events so the TUI yields its mouse handling. -- **`wezterm-term` swap** — if `vt100` proves insufficient for some real program (vim/claude with exotic sequences), the emulator behind the `SessionTask` is swappable behind a thin trait. -- **`POST /api/sessions/:id/refresh`** — small new daemon endpoint behind the per-pane redraw recovery; share with `maestro-web`. - -## 8. Repo layout - -``` -crates/ -├── maestro/ # umbrella binary; `tui` feature (default-on) wires TUI subcommand -└── maestro-tui/ # new crate — all TUI logic - ├── src/ - │ ├── lib.rs # `run(Config) -> Result<()>` - │ ├── cli.rs # clap subcommand definition - │ ├── catalog.rs # CatalogStore, REST bootstrap, events WS - │ ├── session_task.rs # one task per attached session - │ ├── emulators.rs # SessionEmulators registry - │ ├── input.rs # crossterm event canonicalisation + router - │ ├── keymap.rs # config parsing, resolver, chord state machine - │ ├── action.rs # Action enum + dispatch - │ ├── layout.rs # binary split tree - │ ├── sidebar.rs # sidebar widget - │ ├── tabs.rs # tab bar widget - │ ├── pane.rs # attach pane widget - │ ├── spawn_modal.rs # spawn form - │ ├── help.rs # help overlay - │ ├── render.rs # top-level draw orchestration - │ └── terminal.rs # RAII setup/restore, panic hook - └── tests/ - ├── integration.rs # in-process daemon - ├── snapshots/ # ratatui TestBackend goldens - └── ... -``` diff --git a/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md b/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md deleted file mode 100644 index a4672e2..0000000 --- a/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md +++ /dev/null @@ -1,133 +0,0 @@ -# maestro-web: build the SPA bundle at compile time - -**Date:** 2026-05-28 -**Status:** Approved (design) - -> **Partly superseded (2026-08-22).** Maestro is now single-host — the bundle is embedded in `maestro-web`, not a registry binary. See `CLAUDE.md`. - -## Problem - -`maestro-web` embeds the SPA bundle at **compile time** via -`include_dir!("$CARGO_MANIFEST_DIR/../../web/dist")` (`crates/maestro-web/src/static_bundle.rs`). -Only `web/dist/.gitkeep` is checked into git — the real bundle is gitignored and -must be produced by `pnpm -C web build`. - -The installer's default source is `cargo install --git` (`maestro-installer` -`build_source`). A fresh git clone has `web/dist` containing *only* `.gitkeep`, -so `include_dir!` embeds an empty bundle and the running binary serves the -"bundle not built" placeholder. **Every git-based install hits this** — it is -guaranteed, not bad luck. - -Secondary problems found while diagnosing: - -- The placeholder (and `Justfile`'s `web:` target) tell the operator to run - `cargo build -p maestro-web --release`, which writes `target/release/...`, but - the systemd unit runs `~/.cargo/bin/maestro-web` (from `cargo install`). So - following the placeholder's own advice never updates the running service. -- `include_dir!` does not emit `cargo:rerun-if-changed` for the embedded - directory. Once `maestro-web` is compiled with an empty `web/dist`, rebuilding - after `pnpm build` does **not** re-embed the bundle — cargo sees no source - change and reuses the stale object. - -## Goal - -A compiled `maestro-web` binary always embeds a real SPA bundle, regardless of -install method (`cargo install --git`, `--path`, or the wizard). No placeholder -for installed users. - -## Design - -### 1. `crates/maestro-web/build.rs` (new) - -Runs during every compile of the crate that cargo deems necessary (gated by the -`rerun-if-changed` set below). - -- Locate `web/` relative to `CARGO_MANIFEST_DIR` (`../../web`). -- Resolve `pnpm` on `PATH`, and confirm `node` is present. - **If either is missing → fail the build** (`panic!` from `build.rs`, which - cargo surfaces as a build error) with an actionable message, e.g.: - > `maestro-web requires pnpm + node to build its embedded SPA bundle. Install - > them (https://pnpm.io) or use a prebuilt binary.` -- Run, in `web/`: - 1. `pnpm install --frozen-lockfile` (a fresh `--git` clone has no - `node_modules`, so install is required). - 2. `pnpm build`. - If either exits non-zero, fail the build and surface stderr. -- Emit `cargo:rerun-if-changed=` for the frontend inputs so the bundle rebuilds - exactly when those change (and not on every Rust compile): - - `../../web/src` - - `../../web/index.html` - - `../../web/package.json` - - `../../web/pnpm-lock.yaml` - - `../../web/vite.config.ts` - - This also fixes the staleness bug: changing the frontend now reliably - re-embeds. - -### 2. `crates/maestro-web/src/static_bundle.rs` — unchanged - -`include_dir!` still embeds `web/dist`, now guaranteed populated by `build.rs`. -The placeholder path stays as harmless defense-in-depth; in a successful build -it is unreachable. Existing tests remain valid. - -### 3. Installer wizard — `handle_web` (`crates/maestro-installer/src/main.rs`) - -The web step is already opt-in (interactive prompt, or silent refresh when a -unit already exists). After the user opts in, **preflight pnpm + node** before -`install_crate(WEB_CRATE)`: - -- **Interactive, prerequisite missing:** prompt - > `maestro-web needs pnpm + node, which aren't installed. [A]bort install or [S]kip web UI?` - - *Abort* → exit the installer non-zero. - - *Skip* → continue without the web binary; daemon/registry work already done - is left intact. Return `Ok(false)` from `handle_web`. -- **Non-interactive refresh path** (existing unit, no TTY): cannot prompt — - print a clear warning that the web refresh is skipped because pnpm/node are - missing, and leave the existing unit in place. Return `Ok(false)`. -- **Both present:** proceed exactly as today. The existing `cargo install --git` - now Just Works because `build.rs` builds the bundle inside the temp clone. - -This keeps the daemon/registry install unaffected by a missing frontend -toolchain, and never lets a raw `build.rs` panic leak to the user — the wizard -catches the missing-prereq case first. - -### 4. CI — `.gitea/workflows/ci.yml` - -`clippy` and `test` compile `maestro-web`, which now requires the frontend -toolchain. Add a step (before `clippy`) that installs Node.js + pnpm and runs -`pnpm -C web install --frozen-lockfile`, so the workspace builds on the runner. -Node/pnpm install goes in the existing "Install … build deps" step or a new -adjacent step; `corepack enable` (or a pinned pnpm install) provides `pnpm`. - -### 5. `Justfile` — unchanged - -The `web:` target stays as a `--release` convenience and as the documented -local build command. With `build.rs` in place its `pnpm build` step is partly -redundant but harmless. - -## Consequences accepted - -- The `maestro-web` crate cannot be compiled without node + pnpm. This is - intentional (hard fail). It affects: workspace CI (addressed in §4), any - Rust-only contributor building the full workspace, and `cargo install --git` - on a machine without node. -- `cargo install --git` becomes slower (runs `pnpm install` + `pnpm build`), but - produces a correct binary — the whole point. - -## Out of scope - -- Shipping a prebuilt release binary (considered, deferred — the build.rs + - wizard path covers the reported problem). -- Changing the data-plane / proxy behavior of `maestro-web`. - -## Testing - -- `build.rs` is exercised implicitly by every compile; a green - `cargo build -p maestro-web` on a clean checkout (no prior `pnpm build`) that - yields a binary whose `/` serves the real `index.html` is the acceptance - signal. An integration check can assert the served root contains the SPA - mount (`
`) rather than the placeholder string. -- Installer: unit-test the preflight decision (missing-prereq → abort/skip - branch selection) without invoking cargo, following existing - `maestro-installer` test patterns. -- CI itself is the regression test for the toolchain requirement. diff --git a/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md b/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md deleted file mode 100644 index 07eb701..0000000 --- a/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# Spawn form: home-dir prefill + live directory autocomplete (NAP-329) - -> **Partly superseded (2026-08-22).** Maestro is now single-host — the endpoint is now `GET /api/dirs` with no daemon id in the path. See `CLAUDE.md`. - -## Problem - -The web spawn form's **Working directory** field is `required` but offers no -help filling it: no reliable prefill, no completion. The user wants - -1. the field prefilled with the **selected host's home dir**, updating when the - host changes; and -2. **shell-style directory completion** (`cd dir/sub`) driven by **real-time - queries to each host's daemon**, so it works across every federated host. - -Applies to both the **cwd** field and the worktree **Base repo** field. - -## Architecture context - -The web SPA talks to a single **home daemon** (served by `maestro-web`, which -reverse-proxies `/api/*`) — or, in dev, directly to the **registry** (vite -proxy). The home daemon owns its sessions and mirrors peers' catalogs; it -**brokers** cross-daemon operations (`create`/`kill`) to the owning daemon over -the registry control link (NAP-327). The registry can also drive a daemon RPC -directly (`create_session`/`kill_session`) for its own `client_api` front door. - -Directory listing must reach **the daemon whose host you're spawning on**, which -may be the home daemon itself or any peer. So we mirror the create/kill plumbing -end to end rather than only the registry leg. - -## Design - -### Listing strategy - -The daemon lists **one directory's immediate subdirectories**; the web caches -the result per `(daemonId, dir)` and filters the trailing path segment -client-side, re-querying only when the user crosses a `/`. Fewer round-trips, -instant filtering, still live. Directories only (cwd / base-repo are always -dirs); hidden dirs excluded; sorted case-insensitively; capped. - -### Wire protocol (`maestro-protocol`) - -- `DirListing { path: PathBuf, entries: Vec, truncated: bool }` — `path` - is the **resolved absolute** dir (after `~`/relative expansion); `entries` are - immediate subdirectory names. -- `DaemonInbound::ListDir { reply_to, path: String }` — registry → owner daemon. -- `DaemonOutbound::ListDirResult { reply_to, result: RpcResult }` — - owner → registry. -- `DaemonOutbound::ForwardListDir { reply_to, daemon_id, path }` — home daemon → - registry (relay to owner). -- `DaemonInbound::ForwardListDirResult { reply_to, result: RpcResult }` - — registry → home daemon. - -Carried by the existing `RpcResult` + `#[serde(other)] Unknown` forward-compat -machinery, so older peers degrade gracefully. - -### Daemon (`maestro-daemon`) - -- New pure module `dirlist`: `list_dir(home_dir, requested) -> Result`. - Expansion: empty → home; leading `~` → home; relative → joined onto home. - Reads the dir, keeps directories (resolving symlinks), sorts, caps (`MAX_ENTRIES`). - Error codes: `not_found`, `permission_denied`, `not_a_directory`, `io_error`. -- `registry_client` handles `ListDir` (calls `dirlist::list_dir`, replies - `ListDirResult`) and `ForwardListDirResult` (routes to broker). -- `broker` gains `forward_list_dir(daemon_id, path)` + `complete_forward_list_dir`, - mirroring `forward_kill` (fail-fast while disconnected, RAII pending cleanup). -- `local_api`: `GET /api/daemons/:id/dirs?path=

` — self → `dirlist::list_dir`; - peer → `broker.forward_list_dir`. Maps error codes to HTTP status. - -### Registry (`maestro-registry`) - -- `Registry::list_dir(daemon_id, path)` mirroring `create_session` (new - `pending_list_dir` map + `PendingGuard`, `complete_list_dir`); no catalog - side-effects. -- `forward_list_dir(home, reply_to, target, path)` mirroring `forward_kill`. -- `handler` dispatches `ListDirResult` → `complete_list_dir` and `ForwardListDir` - → `forward_list_dir`. -- `client_api`: `GET /api/daemons/:id/dirs?path=

` → `registry.list_dir`, same - timeout/error mapping as `create_session`. - -### Web (`web/src`) - -- `types.ts`: `DirListing`. -- `api.ts`: `listDir(daemonId, path) -> Promise` → - `GET /api/daemons/:id/dirs?path=…`. -- New `dirAutocomplete.ts`: attaches to an input + a daemon-id getter. Debounced - fetch of the current dir, `(daemon,dir)` cache, fixed-position dropdown with - arrow-nav; **Tab** completes the longest common prefix / accepts the highlighted - entry; **Enter** accepts (suppressing submit) or submits when closed; **`/`** - drills into the next level; **Esc** closes. Failures degrade to no suggestions. -- `spawn.ts`: keep host-change home-dir prefill; wire the widget onto cwd + - worktree base repo. - -## Testing - -- Protocol: serde round-trip for the four new variants + `DirListing`, and an - unknown-variant decode (already covered generically). -- Daemon `dirlist`: temp-dir listing (dirs-only, hidden excluded, sorted), `~` - and relative + empty expansion, and `not_found` / `not_a_directory` codes. -- Daemon `broker`: `forward_list_dir` round-trip + fail-fast-while-disconnected, - mirroring the existing forward_create/kill tests. -- Registry: `list_dir` RPC pairing + `complete_list_dir`; `forward_list_dir` - relay, reusing the create/kill in-process channel harness. -- Web: no test runner present; rely on `tsc` typecheck + manual verification. - -## Scope - -In: the full web→(home daemon|registry)→owner daemon path so completion works -for any host. Out: listing files (dirs only); fuzzy matching (prefix only); -persisting recently-used paths. diff --git a/docs/superpowers/specs/2026-06-18-maestro-updates-design.md b/docs/superpowers/specs/2026-06-18-maestro-updates-design.md deleted file mode 100644 index 87ac48a..0000000 --- a/docs/superpowers/specs/2026-06-18-maestro-updates-design.md +++ /dev/null @@ -1,316 +0,0 @@ -# Maestro — In-app Update Notifications + Optional Auto-update - -**Date**: 2026-06-18 -**Status**: Design spike (NAP-331). Produces decisions, not implementation. -**Depends on**: NAP-330 (`KillMode=process` — restart without session loss). **Done.** -**Touches**: NAP-225 (installer / systemd unit), the "prebuilt binaries for thin hosts" idea. - -> **Partly superseded (2026-08-22).** Maestro is now single-host — the registry it references no longer exists. See `CLAUDE.md`. - -## What this is - -Turn today's yolo-local update hack into a first-class, cross-host maestro -capability: every client surface ("a newer version is available, X→Y") and an -opt-in mechanism to apply the update without dropping sessions. - -This is a design spike. It answers the six questions on NAP-331, then proposes -an implementation ticket breakdown. No code ships from this ticket beyond this -document. - -## Prior art (the hack we're replacing) - -A yolo-local prototype (out of repo, intentionally uncommitted): a gitea push -webhook → a tiny `--user` systemd listener on a private ip:port → worker that -hard-resets a dedicated checkout to `origin/main`, `cargo install`s, restarts -the daemon (stamping the last-built commit). Works, but it is per-host -shell+systemd with no in-app surface and no policy. We keep its shape (separate -supervised updater, dedicated checkout, systemd restart) and add: build-commit -stamping in the binary, a registry-mediated "latest seen" signal, client -surfaces, a config policy block, and a prebuilt-binary path for thin hosts. - -## Key existing infrastructure we build on - -Grounding the design in what already exists in the repo today: - -- **`DaemonInfo.version: String`** (`maestro-protocol`) is *already* advertised - by each daemon in `Hello` and fanned out by the registry in - `RegistryEvent::Snapshot` / `RegistryEvent::DaemonAdded`. The registry already - knows every connected daemon's version. Cross-host version awareness is - therefore an *extension of an existing field*, not new plumbing. -- **`RegistryEvent` fan-out WS** (`/api/events`, `maestro-registry/src/events.rs`) - already server-pushes catalog/presence deltas to every client. A - "newest-version-seen" signal rides this same channel. -- **`MaestroConfigFile`** (`maestro-config`) is sectioned TOML - (`[shared]`, `[daemon]`, `[registry]`, `[web]`, `[tui]`, `[cli]`). An - `[autoupdate]` section drops in with the same `skip_serializing_if = is_empty` - pattern. -- **systemd unit** (`maestro-installer/src/systemd.rs`): `Type=simple`, - `ExecStart={exec} daemon`, `KillMode=process` (NAP-330), `Restart=on-failure`, - `RestartSec=5`. `systemctl restart maestro` already preserves tmux sessions. -- **Version is `0.1.0` workspace-wide, no git hash.** `version.workspace = true`; - the only consumer today is a `user_agent` string in `maestro-cli/src/http.rs`. - There is **no `build.rs`** in the umbrella crate yet. Stamping the commit is - net-new and is the hard prerequisite for everything else. - ---- - -## Q1 — Version detection - -**Decision: stamp the build commit at compile time and carry it as a structured -`BuildVersion`, with `origin/main` HEAD as the source of truth for "latest" in -the self-hosted (cargo-install) path, and a CI-published `manifest.json` for the -prebuilt path.** - -### Stamping the binary - -Add a `build.rs` to `crates/maestro/` that emits the git commit (and dirty flag) -as compile-time env vars. Two viable approaches: - -- **`vergen`** (or `vergen-gix`): mature, emits `VERGEN_GIT_SHA`, - `VERGEN_GIT_COMMIT_TIMESTAMP`, dirty, etc. Heavier dep tree. -- **Hand-rolled `build.rs`** running `git rev-parse HEAD` + `git status - --porcelain`, with `cargo:rerun-if-changed=.git/HEAD`. Zero deps, ~20 lines. - -Recommend the **hand-rolled build.rs** — the repo already favors minimal deps and -real-tool integration tests; we only need three values. Critically, the build -script must degrade gracefully: when built from a tarball / crates.io / a -checkout with no `.git` (thin host installing a prebuilt), fall back to -`option_env!` and emit `unknown`. Never fail the build for a missing git. - -Expose a structured version everywhere `--version` is printed: - -``` -maestro 0.1.0 (a1b2c3d 2026-06-18, dirty) -``` - -Add a `BuildVersion { semver, commit, commit_time, dirty }` type -(`maestro-protocol`, since it crosses the wire) and a `maestro version --json` -subcommand for machine consumers (the updater, tests). - -`DaemonInfo.version` is currently a bare `String` (`"0.1.0"`). **Migrate it to -carry the commit** — either widen it to `BuildVersion` (clean, breaks wire compat -with older daemons) or keep the `String` and adopt the `"0.1.0 (a1b2c3d)"` -display form (compat-friendly, parse on read). Recommend **widening to a struct -with `#[serde(default)]`-friendly fields** so old daemons that send a bare semver -still deserialize (commit ⇒ `None`/`unknown`). This is the only wire change. - -### Source of truth for "latest" - -Two sources, matching the two apply mechanisms (Q3): - -- **Self-hosted / cargo-install hosts** → `origin/main` HEAD commit. The updater - component (Q6) is the only thing that talks to git; it resolves - `git ls-remote origin main` (or reads from its dedicated checkout) to learn the - latest commit. "Newer" = `latest_commit != my_build_commit` **and** latest is a - descendant (avoid offering downgrades on a force-push; `git merge-base - --is-ancestor`). -- **Thin / prebuilt hosts** → a CI-published `manifest.json` - (`{ semver, commit, commit_time, artifacts: { "": { url, sha256 } } }`) - at a stable URL. The updater polls/fetches it; "newer" = manifest commit_time > - my commit_time. - -Semver (`0.1.0`) alone is **not** sufficient — it is bumped rarely and the same -semver spans many commits. Commit identity is the real version key; semver stays -the human-facing coarse label. - ---- - -## Q2 — Surfaces - -**Decision: the registry computes a `latest_version` across all daemons it has -ever seen and broadcasts it on the existing `RegistryEvent` channel; every client -compares its local daemon's `BuildVersion` against that and renders a passive -"update available X→Y" affordance.** - -### Registry as the version oracle - -The registry already receives every daemon's version. Extend its state with -`latest_seen: BuildVersion` = max-by-commit-time across connected daemons, -optionally bumped by the external "latest" source (Q1) if the registry is -configured to poll it. Add to `RegistryEvent`: - -- `RegistryEvent::Snapshot` gains a `latest_version: Option` field. -- A new `RegistryEvent::LatestVersion { version }` delta pushed when the max - advances (a daemon with a newer build connects, or the poll finds a new commit). - -This means **a phone that only ever talks to the registry still learns that a -newer version exists** the moment any single host upgrades — exactly the -"broadcast the newest version it has seen" idea from the ticket. No client needs -to talk to git. - -### Per-surface rendering - -| Surface | Affordance | Notes | -|---|---|---| -| CLI `maestro list` | One-line footer: `update available: 0.1.0 (a1b2c3d) → 0.1.0 (e4f5a6b) on 2/3 hosts` | Only when at least one host is behind `latest`. `--quiet` / `MAESTRO_NO_UPDATE_NOTICE` suppresses. | -| TUI | Dismissable banner row / status-bar segment | Color-coded; click/hotkey opens a per-host update list. | -| Web | Header badge (dot + tooltip) | Subscribes to `RegistryEvent`; badge lists which hosts are behind. | - -All three are **read-only notices** in the notify path. Applying is a separate, -explicit action (a CLI subcommand / a confirmed button), never a side effect of -viewing the list. Per-host granularity matters: in a federated fleet some hosts -upgrade before others, so the surface shows *which* hosts are behind, not a single -global flag. - ---- - -## Q3 — Apply mechanism - -**Decision: support both, selected per-host by a config/auto-detected -`method`. Default `cargo-install` where a toolchain exists, `prebuilt` on thin -hosts.** - -- **`cargo-install` (build-on-host).** Mirrors the prototype: dedicated checkout - (NOT the user's working tree), `git fetch` + hard-reset to the target commit, - `cargo install --path crates/maestro --locked`, then restart. Needs a Rust - toolchain; ~tens of seconds; produces a binary matching the host exactly. -- **`prebuilt` (CI binaries).** Fetch the artifact for the host's target triple - from the Q1 manifest, verify `sha256`, atomically replace `~/.cargo/bin/maestro` - (write to temp + `rename`), then restart. Works on a Pi / LXC with no compiler. - Requires CI to cross-compile and publish per-triple artifacts + manifest (new CI - work, called out as its own ticket). - -`method` resolution order: explicit `[autoupdate].method` → auto-detect (is -`cargo` on `PATH` and a source checkout present? → `cargo-install`, else -`prebuilt`). Both paths converge on the same **stamp-then-restart** epilogue: -record the newly-installed commit where the updater can read it back (so a failed -restart doesn't claim success), then `systemctl --user restart maestro`. - ---- - -## Q4 — Auto-run policy - -**Decision: opt-in `[autoupdate]` config section, default `off`. Three modes, -three trigger styles.** - -```toml -[autoupdate] -mode = "notify-only" # off | notify-only | auto (default: off) -method = "cargo-install" # cargo-install | prebuilt | auto (default: auto) -trigger = "poll" # webhook | poll | schedule (default: poll) -source = "origin/main" # git ref, or a manifest URL for prebuilt -poll_interval = "15m" # for trigger = poll -window = "*-*-* 04:00:00" # for trigger = schedule (systemd OnCalendar) -``` - -- **`off`** — no detection, no notice, no apply. (Detection cost is near-zero, but - `off` honors users who want maestro to never phone-home about versions.) -- **`notify-only`** — detect + surface (Q2), never apply. The safe default to - *recommend* once stamping lands. -- **`auto`** — detect + apply via the updater (Q6), respecting `trigger`/`window`. - -Fits `MaestroConfigFile` as a new section with the existing -`skip_serializing_if = AutoupdateSection::is_empty` pattern; lives logically near -`[daemon]` (the daemon is what gets restarted) but is consumed by the **updater**, -not the daemon process (Q6). - -**Triggers:** -- `webhook` — the prototype's model: gitea push → updater's tiny HTTP listener. - Lowest latency; needs an exposed port on the perimeter. -- `poll` — updater wakes on `poll_interval`, checks the Q1 source. No inbound - port. **Default** — simplest, works everywhere. -- `schedule` — systemd timer (`OnCalendar=window`); apply only inside a quiet - window. Best for `auto` mode so restarts don't interrupt active work. - ---- - -## Q5 — Restart safety - -**Decision: rely on NAP-330's `KillMode=process` + `systemctl restart`; the -updater never kills the daemon directly. Add a pre-restart drain check.** - -NAP-330 already shipped `KillMode=process` on the unit, so `systemctl --user -restart maestro` SIGTERMs only the daemon's main process — the tmux server and -its sessions (separate process tree) survive, and the fresh daemon re-adopts them -on startup. This is the load-bearing guarantee; the spike confirms it is already -in place. - -Additions for safety: -- The updater calls `systemctl restart` (a supervised swap), **never** `kill`. The - binary is replaced on disk first; systemd execs the new one. -- **Atomic binary replace** (`prebuilt` path) so a half-written binary can't be - exec'd: write to `maestro.new`, `fsync`, `rename` over the target. -- **Re-adoption is already the daemon's normal startup behavior** (it lists - existing tmux sessions on boot). Verify post-restart the daemon re-appears in the - registry with the new `BuildVersion` within a timeout; if not, the updater logs a - failure (and, future, could roll back to the prior binary — out of scope here, - flagged as a follow-up). -- `auto` mode should prefer the `schedule` trigger / a quiet window so a restart - never lands mid-keystroke for an attached client. A restart is invisible to - persistence but *does* blip live WS streams (clients reconnect); doing it at - 04:00 is kinder than at noon. - ---- - -## Q6 — Trust / security - -**Decision: the self-modifying build+exec logic lives in a separate, minimal -`maestro-updater` component supervised by systemd — never inside the daemon -process.** - -Rationale: a daemon that can `git reset`, `cargo install`, and exec arbitrary -freshly-built code is a remote-code-execution surface. The daemon is the -network-facing, multi-client process; it must stay incapable of rewriting its own -binary. A supervised restart via systemd is needed anyway (the running process -can't replace the file it's executing cleanly), so the separation is free. - -Shape: -- **`maestro-updater`** — a small binary (new crate `crates/maestro-updater/`, or a - feature-gated subcommand `maestro update`). Owns the dedicated checkout, the - fetch/build/fetch-artifact, the atomic replace, and `systemctl restart`. Run as - its own `--user` systemd unit/timer, NOT a child of the daemon. -- **Trust boundary on the source.** `cargo-install` path trusts whatever - `origin/main` resolves to — acceptable only because the operator controls the - repo and the perimeter (consistent with maestro's network-agnostic threat model: - the perimeter is the VPN/LAN). `prebuilt` path **must** verify `sha256` from the - manifest against the downloaded artifact, and the manifest should be fetched over - TLS from a trusted origin. Document that turning on `auto` = trusting that - source to run code on the host. -- **No webhook auth shortcuts.** If `trigger = webhook`, the listener must - authenticate the caller (shared secret / HMAC of the gitea payload), bind to the - perimeter interface, and only ever *trigger* an update of the configured source — - it must not accept a commit/url from the request body (that would let any caller - pick the code to run). -- **Least privilege.** The updater needs write to `~/.cargo/bin` and the dedicated - checkout, and `systemctl --user restart` rights. Nothing more. It does not need - the daemon's token or registry credentials for the apply itself (it may read the - config for policy only). - ---- - -## Proposed implementation ticket breakdown - -In dependency order (each is a separate impl ticket spun off from this spike): - -1. **Build-commit stamping.** `build.rs` in `crates/maestro/`, `BuildVersion` - type, `--version` long form + `maestro version --json`. Graceful no-git - fallback. *(Prerequisite for everything.)* -2. **Wire the commit through `DaemonInfo`.** Widen `DaemonInfo.version` to carry - the commit with backward-compatible deserialization; daemon advertises real - `BuildVersion` in `Hello`. -3. **Registry "latest seen" + `RegistryEvent`.** Compute `latest_version`, - add `Snapshot.latest_version` + `RegistryEvent::LatestVersion`. -4. **Client surfaces.** CLI `list` footer, TUI banner, web header badge. (Can be - three sub-tickets; share the registry signal from #3.) -5. **`[autoupdate]` config section.** Schema + parsing + defaults in - `maestro-config`. Notify-only behavior end-to-end (no apply yet). -6. **`maestro-updater` component — cargo-install path.** Dedicated checkout, - fetch/reset/install, atomic stamp, `systemctl restart`. systemd unit/timer via - the installer (NAP-225). -7. **Prebuilt path + CI manifest.** CI cross-compiles per-triple artifacts, - publishes `manifest.json` + `sha256`; updater `prebuilt` method with - verification. -8. **`auto` mode + triggers.** Wire `webhook` / `poll` / `schedule` to the - updater; quiet-window scheduling; post-restart version-confirm check. - -#5 and #6 can proceed in parallel after #1–#4. #7 is the heaviest (new CI) and -gates only thin-host auto-update; #1–#6 deliver the full cross-host *notify* story -plus auto-update on build-capable hosts. - -## Scope boundary - -- **In scope (this spike):** the decisions above + the ticket breakdown. -- **Out of scope:** rollback-on-failed-restart (flagged in Q5), multi-arch CI - setup details (lands in #7), and any auth design for a hosted/public manifest - CDN — maestro stays network-agnostic; the operator's perimeter is the trust - boundary (spec §Auth). diff --git a/web/src/attach.ts b/web/src/attach.ts index 39c0310..0348740 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import type { ClientControl, Uuid } from "./types.js"; /** - * A single live attach: an xterm terminal bound to a daemon-broker WebSocket. + * A single live attach: an xterm terminal bound to the daemon's attach WebSocket. * * The connection is **decoupled from the UI**: it owns a long-lived WS that * keeps streaming PTY output into the terminal buffer whether or not the view @@ -56,7 +56,7 @@ function esc(s: string): string { * reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id` * endpoint. */ -function brokerStreamUrl(sessionId: Uuid): string { +function attachStreamUrl(sessionId: Uuid): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/api/attach/${encodeURIComponent(sessionId)}`; } @@ -264,7 +264,7 @@ export function createAttachConnection( } }; - const ws = new WebSocket(brokerStreamUrl(sessionId)); + const ws = new WebSocket(attachStreamUrl(sessionId)); ws.binaryType = "arraybuffer"; const sendControl = (msg: ClientControl) => { diff --git a/web/src/names.ts b/web/src/names.ts index 1fcf15a..9de5d70 100644 --- a/web/src/names.ts +++ b/web/src/names.ts @@ -1,7 +1,6 @@ // Client-side session-handle generator for the spawn form's instant reroll -// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim -// — the Rust generator is the source of truth, but the web wants a zero-round- -// trip reroll, so the lists are duplicated here. Keep them in sync. +// (#23/#27). The spawn form rerolls without a round trip, so the wordlists +// live here; nothing server-side generates handles. /** Musical adjectives — tempo, dynamics, and articulation markings. */ const ADJECTIVES = [ diff --git a/web/src/persistence.ts b/web/src/persistence.ts index d64e181..fb80c37 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -4,7 +4,7 @@ // anything malformed — a corrupt or stale blob falls back to a single empty tab. // // Sessions that no longer exist when a layout is restored are not pruned here -// (the session list isn't known yet at boot); their panes attach, the broker +// (the session list isn't known yet at boot); their panes attach, the daemon // closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed). import { freshId, type Layout, type Node, type Tab } from "./layout.js";