Phases: scaffolding, pure-logic units (Action / InputKey / Keymap / TuiConfig), catalog (REST + events WS), session liveness (per-session vt100+WS task with reconnect), widgets (sidebar / tab / pane / overlays) with golden-snapshot tests, top-level event loop, lifecycle wiring (spawn / kill), integration test against in-process daemon, e2e through two daemons via the maestro-e2e harness. v1 ships tabs-only; splits and command palette deferred to a follow-up plan.
108 KiB
Maestro TUI — implementation plan (v1)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship maestro tui v1 — a ratatui-based terminal UI with persistent sidebar, multi-tab attach-to-multiple-sessions UX, configurable keymap, and mouse support. Sessions stay live in the background even when not visible.
Architecture: New crates/maestro-tui/ library, gated behind a tui cargo feature on the umbrella maestro binary (default-on). Talks only to the local daemon's /api/* surface. Each attached session owns a Tokio task + vt100::Parser; the render loop reads Arc<Mutex<vt100::Screen>>. Per-session WS reconnect, no client-side JWT (local-bind trust model). v1 = one pane per tab; v1.5 adds splits inside the same layout primitive.
Tech Stack: ratatui (TUI lib), crossterm (terminal + mouse events), vt100 (PTY-byte → screen emulator), tokio + tokio-tungstenite (existing), serde/toml (config), proptest (layout/keymap property tests), axum::Router from existing maestro-daemon::local_api for in-process integration tests.
Reference spec: docs/superpowers/specs/2026-05-28-maestro-tui-design.md
Out of scope for this plan (filed as v1.5 follow-up): split panes, command palette (:), tab persistence, named workspace save/restore.
Workflow note: Per project convention, after each phase (groups of related tasks), push the branch and let gitea CI run the workspace-wide checks before continuing. Per-task TDD loops run locally inside crates/maestro-tui/.
Phase 0 — Scaffolding
Task 1: Create maestro-tui crate skeleton + clap subcommand stub
Files:
-
Create:
crates/maestro-tui/Cargo.toml -
Create:
crates/maestro-tui/src/lib.rs -
Modify:
Cargo.toml(workspacememberslist) -
Modify:
crates/maestro/Cargo.toml(addtuifeature + dep) -
Modify:
crates/maestro/src/main.rs(dispatchTuisubcommand) -
Modify:
crates/maestro-cli/src/cli.rs(addTuivariant) -
Step 1: Create crate Cargo.toml
# crates/maestro-tui/Cargo.toml
[package]
name = "maestro-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
maestro-protocol.workspace = true
maestro-config.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "signal", "time"] }
tokio-tungstenite.workspace = true
futures-util.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid = { workspace = true, features = ["v4", "serde"] }
ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
vt100 = "0.15"
[dev-dependencies]
proptest = "1"
tokio = { workspace = true, features = ["test-util"] }
maestro-daemon.workspace = true
- Step 2: Create lib.rs entry with a run() stub
// crates/maestro-tui/src/lib.rs
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
//!
//! See `docs/superpowers/specs/2026-05-28-maestro-tui-design.md` for design.
#![deny(missing_debug_implementations)]
use std::net::SocketAddr;
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("not implemented")]
NotImplemented,
}
#[derive(Debug, Clone)]
pub struct RunConfig {
pub daemon_addr: SocketAddr,
}
pub async fn run(_cfg: RunConfig) -> Result<(), TuiError> {
Err(TuiError::NotImplemented)
}
- Step 3: Add to workspace + umbrella binary
# Cargo.toml (workspace, members)
members = [
# … existing entries …
"crates/maestro-tui",
]
# crates/maestro/Cargo.toml
[features]
default = ["cli", "daemon", "registry", "tui"]
tui = ["dep:maestro-tui"]
[dependencies]
maestro-tui = { workspace = true, optional = true }
# … existing entries …
Add maestro-tui.workspace = true to [workspace.dependencies] in the root Cargo.toml and point at path = "crates/maestro-tui".
- Step 4: Add
Tuisubcommand variant
// crates/maestro-cli/src/cli.rs — inside enum Command
/// Open the terminal UI.
Tui(TuiArgs),
// At the bottom of the file
#[derive(Debug, clap::Args)]
pub struct TuiArgs {
/// Override the local daemon's HTTP base URL (default `http://127.0.0.1:7070`).
#[arg(long, env = "MAESTRO_DAEMON_URL")]
pub daemon_url: Option<String>,
}
- Step 5: Wire dispatch in
crates/maestro/src/main.rs
Find the match over Command::* (or wherever the umbrella binary routes subcommands today — see crates/maestro/src/main.rs); add the Tui arm behind #[cfg(feature = "tui")]:
#[cfg(feature = "tui")]
Command::Tui(args) => {
let daemon_addr = args
.daemon_url
.as_deref()
.unwrap_or("http://127.0.0.1:7070")
.parse_addr()?; // helper that strips scheme + parses SocketAddr
maestro_tui::run(maestro_tui::RunConfig { daemon_addr }).await?;
Ok(())
}
#[cfg(not(feature = "tui"))]
Command::Tui(_) => {
eprintln!("maestro was built without the `tui` feature");
std::process::exit(2);
}
If no parsing helper exists in the umbrella binary yet, inline a quick url::Url::parse + socket_addrs() instead — kept self-contained, no new module.
- Step 6: Smoke-test it builds and dispatches
Run: cargo build -p maestro --features tui
Expected: clean build.
Run: cargo run -p maestro --features tui -- tui
Expected: exits with the TuiError::NotImplemented error message (Error: not implemented).
- Step 7: Commit
git add crates/maestro-tui/ crates/maestro/Cargo.toml crates/maestro-cli/src/cli.rs crates/maestro/src/main.rs Cargo.toml
git commit -m "feat(maestro-tui): scaffold crate and wire \`maestro tui\` subcommand"
Phase 1 — Pure-logic units
These tasks are unit-testable without I/O or rendering. Build them first so the rest of the TUI sits on a tested core.
Task 2: Action enum
Files:
-
Create:
crates/maestro-tui/src/action.rs -
Modify:
crates/maestro-tui/src/lib.rs(addpub mod action) -
Step 1: Write the failing test
// crates/maestro-tui/src/action.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_parses_from_canonical_string() {
assert_eq!(Action::parse("EnterInsert").unwrap(), Action::EnterInsert);
assert_eq!(
Action::parse("FocusPane(Left)").unwrap(),
Action::FocusPane(Direction::Left),
);
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
}
#[test]
fn action_rejects_unknown_names() {
assert!(matches!(
Action::parse("Frobnicate").unwrap_err(),
ActionParseError::Unknown(name) if name == "Frobnicate"
));
}
#[test]
fn action_rejects_bad_arg() {
assert!(matches!(
Action::parse("GotoTab(abc)").unwrap_err(),
ActionParseError::BadArg { .. }
));
}
}
- Step 2: Run the test — verify it fails
Run: cargo test -p maestro-tui action::tests
Expected: errors that Action, Direction, ActionParseError don't exist.
- Step 3: Implement Action + Direction + parser
// crates/maestro-tui/src/action.rs
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
pub enum Direction {
Left,
Right,
Up,
Down,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Action {
Noop,
EnterInsert,
LeaveInsert,
FocusSidebar,
FocusPane(Direction),
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
KillHighlighted,
NewTab,
CloseTab,
NextTab,
PrevTab,
GotoTab(u8),
ToggleSidebar,
ShowHelp,
Quit,
}
#[derive(Debug, thiserror::Error)]
pub enum ActionParseError {
#[error("unknown action {0}")]
Unknown(String),
#[error("bad arg for {action}: {detail}")]
BadArg { action: String, detail: String },
#[error("malformed action string {0:?}")]
Malformed(String),
}
impl Action {
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
let s = s.trim();
// No-arg actions
match s {
"Noop" => return Ok(Action::Noop),
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
"PrevTab" => return Ok(Action::PrevTab),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
_ => {}
}
// Arg actions: `Name(arg)`
let (name, rest) = s
.split_once('(')
.ok_or_else(|| ActionParseError::Malformed(s.into()))?;
let arg = rest
.strip_suffix(')')
.ok_or_else(|| ActionParseError::Malformed(s.into()))?;
match name {
"FocusPane" => {
let dir = parse_direction(arg).map_err(|d| ActionParseError::BadArg {
action: name.into(),
detail: d,
})?;
Ok(Action::FocusPane(dir))
}
"GotoTab" => {
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
action: name.into(),
detail: format!("expected u8, got {arg:?}"),
})?;
Ok(Action::GotoTab(n))
}
_ => Err(ActionParseError::Unknown(name.into())),
}
}
}
fn parse_direction(s: &str) -> Result<Direction, String> {
match s {
"Left" => Ok(Direction::Left),
"Right" => Ok(Direction::Right),
"Up" => Ok(Direction::Up),
"Down" => Ok(Direction::Down),
other => Err(format!("expected Left/Right/Up/Down, got {other:?}")),
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
other => write!(f, "{other:?}"),
}
}
}
Add pub mod action; to lib.rs.
- Step 4: Run tests — verify pass
Run: cargo test -p maestro-tui action::
Expected: 3 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): Action enum with canonical parser"
Task 3: InputKey + canonicalisation + chord state
Files:
-
Create:
crates/maestro-tui/src/input.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing tests
// crates/maestro-tui/src/input.rs
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
#[test]
fn parse_canonical_simple_keys() {
assert_eq!(InputKey::parse("a").unwrap(), InputKey::char('a'));
assert_eq!(InputKey::parse("ctrl-a").unwrap(), InputKey::ctrl_char('a'));
assert_eq!(InputKey::parse("ctrl-w").unwrap(), InputKey::ctrl_char('w'));
assert_eq!(InputKey::parse("esc").unwrap(), InputKey::Esc);
}
#[test]
fn parse_canonical_mouse() {
assert!(matches!(
InputKey::parse("mouse:click(pane)").unwrap(),
InputKey::Mouse { kind: MouseKind::Click, region: Region::Pane, .. }
));
}
#[test]
fn parse_canonical_rejects_garbage() {
assert!(InputKey::parse("ctrl-glorb").is_err());
assert!(InputKey::parse("mouse:zoom(pane)").is_err());
}
#[test]
fn chord_state_holds_known_prefix() {
let mut chord = ChordState::new(std::time::Duration::from_millis(1000));
let key_g = InputKey::char('g');
let key_t = InputKey::char('t');
let known_prefixes = ["g t".to_string()].into_iter().collect();
// First press: prefix-held, no resolution yet
let r = chord.feed(&key_g, &known_prefixes);
assert_eq!(r, ChordResult::Pending);
// Second press: resolves to "g t"
let r = chord.feed(&key_t, &known_prefixes);
assert_eq!(r, ChordResult::Resolved("g t".into()));
}
#[test]
fn chord_state_drops_unknown_continuation() {
let mut chord = ChordState::new(std::time::Duration::from_millis(1000));
let known: std::collections::HashSet<String> = ["g t".into()].into_iter().collect();
chord.feed(&InputKey::char('g'), &known);
let r = chord.feed(&InputKey::char('x'), &known);
// 'g x' isn't a binding; should resolve as a lone 'x' after dropping the held 'g'
assert_eq!(r, ChordResult::Resolved("x".into()));
}
}
- Step 2: Run tests — fail
Run: cargo test -p maestro-tui input::tests
Expected: errors that InputKey, MouseKind, Region, ChordState, ChordResult don't exist.
- Step 3: Implement input canonicalisation
// crates/maestro-tui/src/input.rs
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crossterm::event::{
Event as CtEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputKey {
Char { c: char, ctrl: bool, alt: bool, shift: bool },
Esc,
Enter,
Tab,
Backspace,
Up, Down, Left, Right,
Mouse { kind: MouseKind, region: Region, col: u16, row: u16 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseKind { Click, DblClick, Drag, ScrollUp, ScrollDown }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region { Sidebar, SidebarRow, Tab, Pane, SplitDivider, Modal, Unknown }
impl InputKey {
pub fn char(c: char) -> Self {
Self::Char { c, ctrl: false, alt: false, shift: false }
}
pub fn ctrl_char(c: char) -> Self {
Self::Char { c, ctrl: true, alt: false, shift: false }
}
/// Canonical-string form used by the keymap table. Round-trips with `parse`.
pub fn canonical(&self) -> String {
match self {
InputKey::Char { c, ctrl, alt, shift } => {
let mut out = String::new();
if *ctrl { out.push_str("ctrl-"); }
if *alt { out.push_str("alt-"); }
if *shift { out.push_str("shift-"); }
out.push(*c);
out
}
InputKey::Esc => "esc".into(),
InputKey::Enter => "enter".into(),
InputKey::Tab => "tab".into(),
InputKey::Backspace => "backspace".into(),
InputKey::Up => "up".into(),
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
let r = match region {
Region::Sidebar => "sidebar",
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::SplitDivider => "split-divider",
Region::Modal => "modal",
Region::Unknown => "unknown",
};
format!("mouse:{k}({r})")
}
}
}
pub fn parse(s: &str) -> Result<Self, InputParseError> {
if let Some(rest) = s.strip_prefix("mouse:") {
let (k, rest) = rest.split_once('(').ok_or_else(|| InputParseError::Malformed(s.into()))?;
let r = rest.strip_suffix(')').ok_or_else(|| InputParseError::Malformed(s.into()))?;
let kind = match k {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
_ => return Err(InputParseError::UnknownMouseKind(k.into())),
};
let region = match r {
"sidebar" => Region::Sidebar,
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"split-divider" => Region::SplitDivider,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
_ => return Err(InputParseError::UnknownRegion(r.into())),
};
return Ok(InputKey::Mouse { kind, region, col: 0, row: 0 });
}
match s {
"esc" => return Ok(InputKey::Esc),
"enter" => return Ok(InputKey::Enter),
"tab" => return Ok(InputKey::Tab),
"backspace" => return Ok(InputKey::Backspace),
"up" => return Ok(InputKey::Up),
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
let mut ctrl = false; let mut alt = false; let mut shift = false;
let mut rest = s;
loop {
if let Some(r) = rest.strip_prefix("ctrl-") { ctrl = true; rest = r; continue; }
if let Some(r) = rest.strip_prefix("alt-") { alt = true; rest = r; continue; }
if let Some(r) = rest.strip_prefix("shift-") { shift = true; rest = r; continue; }
break;
}
let mut chars = rest.chars();
let c = chars.next().ok_or_else(|| InputParseError::Malformed(s.into()))?;
if chars.next().is_some() {
return Err(InputParseError::Malformed(s.into()));
}
Ok(InputKey::Char { c, ctrl, alt, shift })
}
}
#[derive(Debug, thiserror::Error)]
pub enum InputParseError {
#[error("malformed input key {0:?}")]
Malformed(String),
#[error("unknown mouse kind {0:?}")]
UnknownMouseKind(String),
#[error("unknown region {0:?}")]
UnknownRegion(String),
}
/// Convert a raw crossterm event to a canonical InputKey.
/// `region_for` is supplied by the renderer's hit-test layer (see Task 16).
pub fn canonicalise(ev: &CtEvent, region_for: impl FnOnce(u16, u16) -> Region) -> Option<InputKey> {
match ev {
CtEvent::Key(KeyEvent { code, modifiers, .. }) => {
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
let alt = modifiers.contains(KeyModifiers::ALT);
let shift = modifiers.contains(KeyModifiers::SHIFT);
Some(match code {
KeyCode::Char(c) => InputKey::Char { c: *c, ctrl, alt, shift },
KeyCode::Esc => InputKey::Esc,
KeyCode::Enter => InputKey::Enter,
KeyCode::Tab => InputKey::Tab,
KeyCode::Backspace => InputKey::Backspace,
KeyCode::Up => InputKey::Up,
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
_ => return None,
})
}
CtEvent::Mouse(MouseEvent { kind, column, row, .. }) => {
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
};
let region = region_for(*column, *row);
Some(InputKey::Mouse { kind: mk, region, col: *column, row: *row })
}
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChordResult {
Pending,
Resolved(String),
}
#[derive(Debug)]
pub struct ChordState {
held: Vec<String>,
last_press: Option<Instant>,
timeout: Duration,
}
impl ChordState {
pub fn new(timeout: Duration) -> Self {
Self { held: Vec::new(), last_press: None, timeout }
}
/// Feed one canonical key. Returns Pending if the key extends a known
/// prefix and we should wait for more; Resolved("g t") if the full chord
/// (or single key) is now ready for keymap lookup.
pub fn feed(&mut self, key: &InputKey, known_prefixes: &HashSet<String>) -> ChordResult {
// Drop stale held keys past the timeout (caller checks before feeding).
if let Some(t) = self.last_press {
if t.elapsed() > self.timeout {
self.held.clear();
}
}
let key_s = key.canonical();
let candidate = if self.held.is_empty() {
key_s.clone()
} else {
format!("{} {}", self.held.join(" "), key_s)
};
// If candidate is itself a known *prefix* (not yet a complete binding),
// hold. We treat any string in `known_prefixes` that has a longer
// member starting with it as a prefix.
let is_prefix = known_prefixes
.iter()
.any(|kb| kb != &candidate && kb.starts_with(&format!("{candidate} ")));
self.last_press = Some(Instant::now());
if is_prefix {
self.held.push(key_s);
ChordResult::Pending
} else {
self.held.clear();
ChordResult::Resolved(candidate)
}
}
}
Add pub mod input; to lib.rs.
- Step 4: Run tests — pass
Run: cargo test -p maestro-tui input::
Expected: 5 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): InputKey canonicalisation + chord state machine"
Task 4: Keymap table + resolver
Files:
-
Create:
crates/maestro-tui/src/keymap.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing test
// crates/maestro-tui/src/keymap.rs
#[cfg(test)]
mod tests {
use super::*;
use crate::action::{Action, Direction};
fn km(entries: &[(Mode, &str, &str)]) -> Keymap {
let mut k = Keymap::default();
for (mode, input, action) in entries {
k.set(*mode, input, action).unwrap();
}
k
}
#[test]
fn resolves_single_key() {
let k = km(&[(Mode::Normal, "i", "EnterInsert")]);
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
assert_eq!(k.resolve(Mode::Insert, "i"), None);
}
#[test]
fn resolves_chord() {
let k = km(&[(Mode::Normal, "g t", "NextTab")]);
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
}
#[test]
fn known_prefixes_set() {
let k = km(&[
(Mode::Normal, "g t", "NextTab"),
(Mode::Normal, "ctrl-w h", "FocusPane(Left)"),
]);
let pfx = k.known_prefixes(Mode::Normal);
assert!(pfx.contains("g t"));
assert!(pfx.contains("ctrl-w h"));
}
#[test]
fn reports_lex_errors_at_load() {
let mut k = Keymap::default();
let err = k.set(Mode::Normal, "ctrl-glorb", "EnterInsert").unwrap_err();
assert!(matches!(err, KeymapError::BadInput(_)));
}
#[test]
fn reports_unknown_action() {
let mut k = Keymap::default();
let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
assert!(matches!(err, KeymapError::BadAction(_)));
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui keymap::tests
Expected: errors that Keymap, Mode, KeymapError don't exist.
- Step 3: Implement
// crates/maestro-tui/src/keymap.rs
use std::collections::{HashMap, HashSet};
use crate::action::{Action, ActionParseError};
use crate::input::{InputKey, InputParseError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Mode { Normal, Insert }
#[derive(Debug, thiserror::Error)]
pub enum KeymapError {
#[error("bad input key: {0}")]
BadInput(#[from] InputParseError),
#[error("bad action: {0}")]
BadAction(#[from] ActionParseError),
}
#[derive(Debug, Default)]
pub struct Keymap {
normal: HashMap<String, Action>,
insert: HashMap<String, Action>,
}
impl Keymap {
/// Bind a canonical input string to an action under a mode. Validates both.
pub fn set(&mut self, mode: Mode, input: &str, action: &str) -> Result<(), KeymapError> {
// Validate input string parses to a sequence of canonical keys.
for tok in input.split_whitespace() {
InputKey::parse(tok)?;
}
let action = Action::parse(action)?;
let table = match mode { Mode::Normal => &mut self.normal, Mode::Insert => &mut self.insert };
table.insert(input.to_string(), action);
Ok(())
}
pub fn resolve(&self, mode: Mode, canonical: &str) -> Option<Action> {
let table = match mode { Mode::Normal => &self.normal, Mode::Insert => &self.insert };
table.get(canonical).cloned()
}
/// All bound chord strings for the given mode (used by ChordState to
/// decide whether to hold a key).
pub fn known_prefixes(&self, mode: Mode) -> HashSet<String> {
let table = match mode { Mode::Normal => &self.normal, Mode::Insert => &self.insert };
table.keys().cloned().collect()
}
}
Add pub mod keymap; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui keymap::
Expected: 5 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): Keymap table with input/action validation"
Task 5: TUI config section + keymap defaults
Files:
-
Modify:
crates/maestro-config/src/lib.rs(addTuiSection) -
Create:
crates/maestro-tui/src/config.rs(loader + shipped defaults) -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Add
TuiSectionto the unified config
In crates/maestro-config/src/lib.rs, alongside the other section structs (after WebSection):
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TuiSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chord_timeout_ms: Option<u64>,
/// Normal-mode bindings, applied over the shipped defaults.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub keymap_normal: std::collections::BTreeMap<String, String>,
/// Insert-mode bindings, applied over the shipped defaults.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub keymap_insert: std::collections::BTreeMap<String, String>,
}
impl TuiSection {
pub fn is_empty(&self) -> bool {
self.leader.is_none()
&& self.chord_timeout_ms.is_none()
&& self.keymap_normal.is_empty()
&& self.keymap_insert.is_empty()
}
}
Then add pub tui: TuiSection to MaestroConfigFile with the same serde(default, skip_serializing_if) pattern as the other sections.
- Step 2: Write the failing test for the loader
// crates/maestro-tui/src/config.rs
#[cfg(test)]
mod tests {
use super::*;
use maestro_config::TuiSection;
#[test]
fn loads_shipped_defaults() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
// Sanity: a few bindings every TUI ships with.
assert_eq!(
cfg.keymap.resolve(crate::keymap::Mode::Normal, "i"),
Some(crate::action::Action::EnterInsert)
);
assert_eq!(
cfg.keymap.resolve(crate::keymap::Mode::Insert, &cfg.leader.canonical()),
Some(crate::action::Action::LeaveInsert)
);
}
#[test]
fn user_overrides_merge_over_defaults() {
let mut section = TuiSection::default();
section.keymap_normal.insert("i".into(), "ShowHelp".into());
let cfg = TuiConfig::load(§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
// crates/maestro-tui/src/config.rs
use std::time::Duration;
use maestro_config::TuiSection;
use crate::action::Action;
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
#[derive(Debug)]
pub struct TuiConfig {
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
}
#[derive(Debug, thiserror::Error)]
pub enum TuiConfigError {
#[error("invalid leader key: {0}")]
BadLeader(String),
#[error("invalid keymap entry [{mode:?}] {input:?} = {action:?}: {source}")]
BadKeymap { mode: Mode, input: String, action: String, source: KeymapError },
}
/// Bindings that ship by default. These are *defaults*, not contract — the
/// shipped defaults will be tuned during dogfooding. Every action in v1 is
/// reachable from this table.
const DEFAULT_NORMAL: &[(&str, &str)] = &[
("i", "EnterInsert"),
("s", "OpenSpawnModal"),
("x", "KillHighlighted"),
("?", "ShowHelp"),
("ctrl-t", "NewTab"),
("ctrl-w c", "CloseTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
("ctrl-w h", "FocusSidebar"),
("enter", "AttachHighlighted"),
(" ", "ToggleAttachHighlighted"),
("ctrl-w b", "ToggleSidebar"),
("ctrl-w q", "Quit"),
("mouse:click(pane)", "FocusSidebar"),
("mouse:dblclick(sidebar-row)","AttachHighlighted"),
("mouse:click(tab)", "NextTab"),
];
const DEFAULT_INSERT: &[(&str, &str)] = &[
// Insert mode only intercepts the leader; the rest passes through.
// The leader binding is inserted dynamically in load() below.
];
impl TuiConfig {
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.into()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
let mut keymap = Keymap::default();
// Apply shipped defaults first.
for (input, action) in DEFAULT_NORMAL {
keymap
.set(Mode::Normal, input, action)
.map_err(|source| TuiConfigError::BadKeymap {
mode: Mode::Normal,
input: (*input).into(),
action: (*action).into(),
source,
})?;
}
for (input, action) in DEFAULT_INSERT {
keymap.set(Mode::Insert, input, action).map_err(|source| TuiConfigError::BadKeymap {
mode: Mode::Insert,
input: (*input).into(),
action: (*action).into(),
source,
})?;
}
// Insert-mode default: leader → LeaveInsert.
keymap
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
.expect("leader binding well-formed by construction");
// Apply user overrides.
for (input, action) in §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
git add crates/maestro-tui/src/ crates/maestro-config/src/lib.rs
git commit -m "feat(maestro-tui): TuiConfig with shipped defaults and merge-over-user-overrides"
Phase 2 — Catalog
Task 6: CatalogStore + REST bootstrap
Files:
-
Create:
crates/maestro-tui/src/catalog.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing test using a mocked daemon
// crates/maestro-tui/src/catalog.rs
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Router, Json};
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use std::net::SocketAddr;
use uuid::Uuid;
async fn spawn_fake_daemon() -> SocketAddr {
let daemons = vec![DaemonInfo {
daemon_id: "fake".into(),
hostname: "fake-host".into(),
version: "test".into(),
daemon_url: "ws://127.0.0.1:0".into(),
}];
let sessions: Vec<Session> = vec![];
let app = Router::new()
.route("/api/daemons", get(move || async move { Json(daemons.clone()) }))
.route("/api/sessions", get(move || async move { Json::<Vec<Session>>(sessions.clone()) }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
addr
}
#[tokio::test]
async fn bootstrap_loads_daemons_and_sessions() {
let addr = spawn_fake_daemon().await;
let base = format!("http://{addr}");
let store = CatalogStore::default();
store.bootstrap(&base).await.unwrap();
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.daemons[0].daemon_id, "fake");
assert!(snap.sessions.is_empty());
assert_eq!(snap.version, 1);
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui catalog::tests::bootstrap_loads_daemons_and_sessions
Expected: errors that CatalogStore doesn't exist.
- Step 3: Implement CatalogStore
// crates/maestro-tui/src/catalog.rs
use std::collections::HashMap;
use std::sync::Arc;
use maestro_protocol::{DaemonInfo, Session};
use parking_lot::RwLock; // add `parking_lot = "0.12"` to Cargo.toml deps in this same step
use tokio::sync::Notify;
use uuid::Uuid;
#[derive(Debug, Clone, Default)]
pub struct CatalogSnapshot {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub version: u64,
}
#[derive(Debug, Default, Clone)]
pub struct CatalogStore {
inner: Arc<Inner>,
}
#[derive(Debug, Default)]
struct Inner {
state: RwLock<State>,
notify: Notify,
}
#[derive(Debug, Default)]
struct State {
daemons: HashMap<String, DaemonInfo>,
sessions: HashMap<Uuid, Session>,
version: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("bad response: {0}")]
BadResponse(String),
}
impl CatalogStore {
pub async fn bootstrap(&self, base_url: &str) -> Result<(), CatalogError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
let daemons: Vec<DaemonInfo> = client
.get(format!("{base_url}/api/daemons")).send().await?.error_for_status()?.json().await?;
let sessions: Vec<Session> = client
.get(format!("{base_url}/api/sessions")).send().await?.error_for_status()?.json().await?;
let mut s = self.inner.state.write();
s.daemons = daemons.into_iter().map(|d| (d.daemon_id.clone(), d)).collect();
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
s.version += 1;
drop(s);
self.inner.notify.notify_waiters();
Ok(())
}
pub fn snapshot(&self) -> CatalogSnapshot {
let s = self.inner.state.read();
let mut daemons: Vec<_> = s.daemons.values().cloned().collect();
daemons.sort_by(|a, b| a.daemon_id.cmp(&b.daemon_id));
let mut sessions: Vec<_> = s.sessions.values().cloned().collect();
sessions.sort_by_key(|s| s.id);
CatalogSnapshot { daemons, sessions, version: s.version }
}
pub fn changed_notify(&self) -> Arc<Notify> {
Arc::new(Notify::new()) // placeholder; replaced by real handle in next task
}
}
Add parking_lot = "0.12" to crates/maestro-tui/Cargo.toml [dependencies]. Add pub mod catalog; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui catalog::tests
Expected: 1 passing.
- Step 5: Commit
git add crates/maestro-tui/
git commit -m "feat(maestro-tui): CatalogStore with REST bootstrap"
Task 7: Events WS subscription + reconcile
Files:
-
Modify:
crates/maestro-tui/src/catalog.rs -
Step 1: Write the failing test for event application
// inside crates/maestro-tui/src/catalog.rs `#[cfg(test)] mod tests`
use maestro_protocol::{RegistryEvent, Session, SessionStatus};
#[test]
fn applies_session_created_then_ended() {
let store = CatalogStore::default();
let sess = Session {
id: Uuid::from_u128(1),
daemon_id: "fake".into(),
label: Some("a".into()),
command: "bash".into(),
cwd: "/tmp".into(),
env: Default::default(),
status: SessionStatus::Running,
created_at: chrono::Utc::now(),
};
store.apply_event(RegistryEvent::SessionCreated(sess.clone()));
assert_eq!(store.snapshot().sessions.len(), 1);
store.apply_event(RegistryEvent::SessionEnded { id: sess.id });
assert!(store.snapshot().sessions.is_empty());
}
(Inspect crates/maestro-protocol/src/lib.rs to confirm the exact RegistryEvent variants; adjust the test's variant names to match. If the protocol uses different field names or SessionStatus shape, follow the current source — DO NOT invent fields.)
- Step 2: Run — fail
Run: cargo test -p maestro-tui catalog::tests::applies_session_created_then_ended
Expected: error that apply_event doesn't exist.
- Step 3: Implement event application + WS receive loop
Append to crates/maestro-tui/src/catalog.rs:
use futures_util::StreamExt;
use maestro_protocol::RegistryEvent;
use tokio_tungstenite::tungstenite::Message;
impl CatalogStore {
pub fn apply_event(&self, ev: RegistryEvent) {
let mut s = self.inner.state.write();
match ev {
RegistryEvent::SessionCreated(sess) => { s.sessions.insert(sess.id, sess); }
RegistryEvent::SessionUpdated(sess) => { s.sessions.insert(sess.id, sess); }
RegistryEvent::SessionEnded { id } => { s.sessions.remove(&id); }
RegistryEvent::DaemonJoined(d) => { s.daemons.insert(d.daemon_id.clone(), d); }
RegistryEvent::DaemonLeft { id } => { s.daemons.remove(&id); }
}
s.version += 1;
drop(s);
self.inner.notify.notify_waiters();
}
/// Long-running task: connect to `WS ws_url`, parse RegistryEvent frames,
/// apply them to the store. Reconnects with exponential backoff on
/// disconnect; rebootstraps via REST on reconnect.
pub async fn run_events_loop(self, ws_url: String, rest_base: String) {
let mut backoff = std::time::Duration::from_secs(1);
loop {
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws, _)) => {
backoff = std::time::Duration::from_secs(1);
let (_sink, mut stream) = ws.split();
while let Some(Ok(msg)) = stream.next().await {
if let Message::Text(t) = msg {
if let Ok(ev) = serde_json::from_str::<RegistryEvent>(&t) {
self.apply_event(ev);
}
}
}
// Disconnect: rebootstrap snapshot before reconnect.
let _ = self.bootstrap(&rest_base).await;
}
Err(_) => {
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, std::time::Duration::from_secs(30));
continue;
}
}
}
}
}
- Step 4: Run — pass
Run: cargo test -p maestro-tui catalog::tests
Expected: 2 passing.
- Step 5: Commit
git add crates/maestro-tui/src/catalog.rs
git commit -m "feat(maestro-tui): events WS subscription with reconnect-and-rebootstrap"
Phase 3 — Session liveness
Task 8: SessionTask (WS + vt100 parser)
Files:
-
Create:
crates/maestro-tui/src/session_task.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing test using an in-process WS server
// crates/maestro-tui/src/session_task.rs
#[cfg(test)]
mod tests {
use super::*;
use futures_util::SinkExt;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test]
async fn parser_consumes_bytes_into_screen() {
// Stand up a fake WS server that, on connect, sends "hello\r\n".
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Binary(b"hello\r\n".to_vec())).await.unwrap();
// Keep the connection open until the client closes.
while ws.next().await.is_some() {}
});
let url = format!("ws://{addr}/api/attach/dummy");
let handle = SessionTask::spawn(url, 24, 80).await.unwrap();
// Poll the screen for up to 500 ms looking for "hello".
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let scr = handle.screen.lock();
let rendered: String = (0..1).map(|r| scr.row_contents(r).collect::<String>()).collect();
if rendered.starts_with("hello") {
return;
}
}
panic!("screen never contained 'hello'");
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui session_task::
Expected: errors that SessionTask doesn't exist.
- Step 3: Implement SessionTask
// crates/maestro-tui/src/session_task.rs
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::ClientControl;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug)]
pub enum SessionInput {
Bytes(Vec<u8>),
Resize { cols: u16, rows: u16 },
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState { Connecting = 0, Live = 1, Reconnecting = 2, Ended = 3 }
#[derive(Debug, Clone)]
pub struct SessionHandle {
pub tx: mpsc::Sender<SessionInput>,
pub screen: Arc<Mutex<vt100::Parser>>,
pub state: Arc<AtomicU8>,
}
impl SessionHandle {
pub fn state(&self) -> SessionState {
match self.state.load(Ordering::Acquire) {
0 => SessionState::Connecting,
1 => SessionState::Live,
2 => SessionState::Reconnecting,
_ => SessionState::Ended,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionTaskError {
#[error("ws: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
}
/// Just the constructor — the actual long-running task is spawned on a Tokio
/// runtime via `spawn`.
#[derive(Debug)]
pub struct SessionTask;
impl SessionTask {
pub async fn spawn(ws_url: String, rows: u16, cols: u16) -> Result<SessionHandle, SessionTaskError> {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let parser_for_task = parser.clone();
let state_for_task = state.clone();
tokio::spawn(async move {
let url_owned = ws_url;
let mut backoff = std::time::Duration::from_secs(1);
loop {
state_for_task.store(SessionState::Connecting as u8, Ordering::Release);
let (ws, _) = match tokio_tungstenite::connect_async(&url_owned).await {
Ok(x) => x,
Err(_) => {
state_for_task.store(SessionState::Reconnecting as u8, Ordering::Release);
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, std::time::Duration::from_secs(30));
continue;
}
};
backoff = std::time::Duration::from_secs(1);
state_for_task.store(SessionState::Live as u8, Ordering::Release);
let (mut sink, mut stream) = ws.split();
let parser_in = parser_for_task.clone();
let recv = async {
while let Some(m) = stream.next().await {
match m {
Ok(Message::Binary(bytes)) => {
parser_in.lock().process(&bytes);
}
Ok(Message::Text(t)) => {
if serde_json::from_str::<ClientControl>(&t)
.map(|c| matches!(c, ClientControl::Ping))
.unwrap_or(false)
{
// Pong handled in the send half via a side-channel
// if we add it. v1 leaves Ping unanswered — harmless.
}
}
Ok(Message::Close(_)) | Err(_) => return false,
_ => {}
}
}
false
};
let send = async {
while let Some(inp) = rx.recv().await {
match inp {
SessionInput::Bytes(b) => {
if sink.send(Message::Binary(b)).await.is_err() { return; }
}
SessionInput::Resize { cols, rows } => {
let frame = serde_json::to_string(&ClientControl::Resize { cols, rows })
.expect("serialize resize");
if sink.send(Message::Text(frame)).await.is_err() { return; }
}
}
}
};
tokio::select! {
_ = recv => {},
_ = send => {},
}
// On disconnect, mark reconnecting and loop.
state_for_task.store(SessionState::Reconnecting as u8, Ordering::Release);
}
});
Ok(SessionHandle { tx, screen: parser, state })
}
}
Add pub mod session_task; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui session_task::
Expected: 1 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): per-session WS+vt100 task with reconnect"
Task 9: SessionEmulators registry + min-size resize
Files:
-
Create:
crates/maestro-tui/src/emulators.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing test
// crates/maestro-tui/src/emulators.rs
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn per_session_min_picks_smallest_per_dim() {
let mut tracker = ResizeTracker::default();
let sid = Uuid::from_u128(1);
tracker.set_pane_size(sid, "pane-a", (100, 30));
tracker.set_pane_size(sid, "pane-b", (80, 40));
assert_eq!(tracker.session_size(sid), Some((80, 30)));
// Independent dimensions: cols from b, rows from a.
}
#[test]
fn unset_session_has_no_size() {
let tracker = ResizeTracker::default();
assert_eq!(tracker.session_size(Uuid::from_u128(9)), None);
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui emulators::tests
Expected: errors that ResizeTracker doesn't exist.
- Step 3: Implement registry
// crates/maestro-tui/src/emulators.rs
use std::collections::HashMap;
use uuid::Uuid;
use crate::session_task::SessionHandle;
#[derive(Debug, Default)]
pub struct SessionEmulators {
handles: HashMap<Uuid, SessionHandle>,
resize: ResizeTracker,
}
impl SessionEmulators {
pub fn get(&self, id: Uuid) -> Option<&SessionHandle> { self.handles.get(&id) }
pub fn insert(&mut self, id: Uuid, h: SessionHandle) { self.handles.insert(id, h); }
pub fn remove(&mut self, id: Uuid) { self.handles.remove(&id); self.resize.drop_session(id); }
pub fn resize_tracker_mut(&mut self) -> &mut ResizeTracker { &mut self.resize }
}
#[derive(Debug, Default)]
pub struct ResizeTracker {
// (SessionId, PaneId) -> (cols, rows)
per_pane: HashMap<(Uuid, String), (u16, u16)>,
}
impl ResizeTracker {
pub fn set_pane_size(&mut self, sid: Uuid, pane_id: &str, size: (u16, u16)) {
self.per_pane.insert((sid, pane_id.to_string()), size);
}
pub fn session_size(&self, sid: Uuid) -> Option<(u16, u16)> {
let mut cols: Option<u16> = None;
let mut rows: Option<u16> = None;
for ((s, _), (c, r)) in &self.per_pane {
if *s == sid {
cols = Some(cols.map_or(*c, |x| x.min(*c)));
rows = Some(rows.map_or(*r, |x| x.min(*r)));
}
}
match (cols, rows) {
(Some(c), Some(r)) => Some((c, r)),
_ => None,
}
}
pub fn drop_session(&mut self, sid: Uuid) {
self.per_pane.retain(|(s, _), _| *s != sid);
}
}
Add pub mod emulators; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui emulators::tests
Expected: 2 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): SessionEmulators registry with per-dimension min resize"
Phase 4 — Top-level state
Task 10: AppState (mode, tabs, focus)
Files:
-
Create:
crates/maestro-tui/src/state.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing tests
// crates/maestro-tui/src/state.rs
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn new_app_starts_with_one_empty_tab_focused_on_sidebar() {
let state = AppState::new();
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.tabs[0].pane, None);
assert_eq!(state.focus, Focus::Sidebar);
assert_eq!(state.mode, crate::keymap::Mode::Normal);
}
#[test]
fn new_tab_extends_and_focuses() {
let mut state = AppState::new();
state.new_tab();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
}
#[test]
fn close_tab_keeps_at_least_one() {
let mut state = AppState::new();
state.close_tab(); // attempting to close the only tab is a no-op
assert_eq!(state.tabs.len(), 1);
state.new_tab();
state.close_tab();
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn attach_binds_session_to_focused_pane() {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
// Focus moves into the pane after attach.
assert_eq!(state.focus, Focus::Pane);
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui state::tests
Expected: errors that AppState, Focus, etc. don't exist.
- Step 3: Implement AppState
// crates/maestro-tui/src/state.rs
use uuid::Uuid;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus { Sidebar, Pane }
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
/// v1: each tab has exactly one pane bound to an optional session.
/// v1.5 replaces this with a layout tree.
pub pane: Option<Uuid>,
}
#[derive(Debug)]
pub struct AppState {
pub tabs: Vec<Tab>,
pub active_tab_idx: usize,
pub focus: Focus,
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<Uuid>,
pub overlay: Option<OverlayKind>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
}
impl AppState {
pub fn new() -> Self {
Self {
tabs: vec![Tab { name: "1".into(), pane: None }],
active_tab_idx: 0,
focus: Focus::Sidebar,
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
overlay: None,
}
}
pub fn active_tab(&self) -> &Tab { &self.tabs[self.active_tab_idx] }
pub fn active_tab_mut(&mut self) -> &mut Tab { &mut self.tabs[self.active_tab_idx] }
pub fn new_tab(&mut self) {
let name = format!("{}", self.tabs.len() + 1);
self.tabs.push(Tab { name, pane: None });
self.active_tab_idx = self.tabs.len() - 1;
self.focus = Focus::Sidebar;
}
pub fn close_tab(&mut self) {
if self.tabs.len() <= 1 { return; }
self.tabs.remove(self.active_tab_idx);
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
}
pub fn next_tab(&mut self) {
if self.tabs.is_empty() { return; }
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
}
pub fn prev_tab(&mut self) {
if self.tabs.is_empty() { return; }
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
}
pub fn goto_tab(&mut self, n: u8) {
let idx = (n as usize).saturating_sub(1).min(self.tabs.len().saturating_sub(1));
self.active_tab_idx = idx;
}
pub fn attach_to_focused(&mut self, session_id: Uuid) {
match self.focus {
Focus::Sidebar | Focus::Pane => {
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
}
}
}
}
Add pub mod state; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui state::tests
Expected: 4 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): AppState with tab/focus/mode/overlay"
Phase 5 — Rendering
Task 11: Terminal RAII guard + panic hook
Files:
-
Create:
crates/maestro-tui/src/terminal.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the smoke test
// crates/maestro-tui/src/terminal.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_panic_hook_is_idempotent() {
install_panic_hook();
install_panic_hook(); // re-installing must not panic.
}
}
- Step 2: Run — fail
Run: cargo test -p maestro-tui terminal::tests
Expected: error that install_panic_hook doesn't exist.
- Step 3: Implement RAII guard + panic hook
// crates/maestro-tui/src/terminal.rs
use std::io;
use crossterm::execute;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
#[derive(Debug)]
pub struct TerminalGuard;
#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
#[error("io: {0}")]
Io(#[from] io::Error),
}
impl TerminalGuard {
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
Ok(TerminalGuard)
}
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) { Self::restore(); }
}
static HOOK_INSTALLED: std::sync::Once = std::sync::Once::new();
pub fn install_panic_hook() {
HOOK_INSTALLED.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
TerminalGuard::restore();
prev(info);
}));
});
}
Add pub mod terminal; to lib.rs.
- Step 4: Run — pass
Run: cargo test -p maestro-tui terminal::tests
Expected: 1 passing.
- Step 5: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): TerminalGuard + idempotent panic hook"
Task 12: Sidebar widget + snapshot test
Files:
-
Create:
crates/maestro-tui/src/sidebar.rs -
Create:
crates/maestro-tui/tests/snapshots/sidebar_basic.txt -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Write the failing snapshot test
// crates/maestro-tui/src/sidebar.rs
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use ratatui::{backend::TestBackend, Terminal};
use uuid::Uuid;
fn make_snapshot(sb: &Sidebar) -> String {
let backend = TestBackend::new(28, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| sb.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn renders_one_daemon_two_sessions() {
let daemons = vec![DaemonInfo {
daemon_id: "konishi".into(), hostname: "konishi".into(),
version: "0".into(), daemon_url: "ws://x".into(),
}];
let sessions = vec![
Session {
id: Uuid::from_u128(1), daemon_id: "konishi".into(),
label: Some("claude".into()), command: "claude".into(), cwd: "/x".into(),
env: Default::default(), status: SessionStatus::Running,
created_at: chrono::Utc::now(),
},
Session {
id: Uuid::from_u128(2), daemon_id: "konishi".into(),
label: Some("bash".into()), command: "bash".into(), cwd: "/x".into(),
env: Default::default(), status: SessionStatus::Running,
created_at: chrono::Utc::now(),
},
];
let sb = Sidebar { daemons, sessions, highlight: None, attached: Default::default() };
let rendered = make_snapshot(&sb);
let expected = std::fs::read_to_string("tests/snapshots/sidebar_basic.txt").unwrap();
assert_eq!(rendered, expected);
}
}
- Step 2: Implement Sidebar (so we can generate the first snapshot)
// crates/maestro-tui/src/sidebar.rs
use std::collections::HashSet;
use maestro_protocol::{DaemonInfo, Session};
use ratatui::{
Frame,
layout::Rect,
style::{Style, Modifier},
widgets::{Block, Borders, Paragraph},
text::{Line, Span},
};
use uuid::Uuid;
#[derive(Debug)]
pub struct Sidebar {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub attached: HashSet<Uuid>,
}
impl Sidebar {
pub fn render(&self, f: &mut Frame, area: Rect) {
let block = Block::default().title(" daemons ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
for d in &self.daemons {
lines.push(Line::from(format!("● {}", d.hostname)));
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
let glyph = if self.attached.contains(&s.id) { "●" } else { "○" };
let label = s.label.clone().unwrap_or_else(|| s.command.clone());
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
}
f.render_widget(Paragraph::new(lines), inner);
}
}
Add pub mod sidebar; to lib.rs.
- Step 3: Generate the golden snapshot
Run the test once with the file missing — it'll fail; copy the actual rendered output into crates/maestro-tui/tests/snapshots/sidebar_basic.txt. Then re-run.
Run: cargo test -p maestro-tui sidebar::tests::renders_one_daemon_two_sessions
Expected (first run): fails because the snapshot file doesn't exist.
Copy the printed output (visible by adding a dbg!(&rendered) in the test temporarily) into the snapshot file. Remove dbg!. Re-run.
Expected (second run): pass.
- Step 4: Commit
git add crates/maestro-tui/src/sidebar.rs crates/maestro-tui/tests/snapshots/sidebar_basic.txt
git commit -m "feat(maestro-tui): sidebar widget + golden snapshot"
Task 13: Tab bar widget + snapshot
Files:
-
Create:
crates/maestro-tui/src/tabs.rs -
Create:
crates/maestro-tui/tests/snapshots/tabbar_basic.txt -
Step 1: Write the failing snapshot test (analogous pattern to Task 12)
// crates/maestro-tui/src/tabs.rs
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn renders_three_tabs_with_active_highlighted() {
let bar = TabBar { tab_names: vec!["1".into(), "2".into(), "3".into()], active_idx: 1 };
let backend = TestBackend::new(40, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| bar.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
out.push('\n');
}
let expected = std::fs::read_to_string("tests/snapshots/tabbar_basic.txt").unwrap();
assert_eq!(out, expected);
}
}
- Step 2: Implement TabBar
// crates/maestro-tui/src/tabs.rs
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
widgets::Tabs,
text::Line,
};
#[derive(Debug)]
pub struct TabBar {
pub tab_names: Vec<String>,
pub active_idx: usize,
}
impl TabBar {
pub fn render(&self, f: &mut Frame, area: Rect) {
let titles: Vec<Line> = self.tab_names.iter().map(|n| Line::from(n.as_str())).collect();
let tabs = Tabs::new(titles)
.select(self.active_idx)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_widget(tabs, area);
}
}
Add pub mod tabs; to lib.rs.
-
Step 3: Generate golden, run test until pass. Same pattern as Task 12.
-
Step 4: Commit
git add crates/maestro-tui/src/tabs.rs crates/maestro-tui/tests/snapshots/tabbar_basic.txt
git commit -m "feat(maestro-tui): tab bar widget + snapshot"
Task 14: Attach pane widget (vt100 → buffer) + snapshot
Files:
-
Create:
crates/maestro-tui/src/pane.rs -
Create:
crates/maestro-tui/tests/snapshots/pane_hello.txt -
Step 1: Write the failing snapshot test
// crates/maestro-tui/src/pane.rs
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
use ratatui::{backend::TestBackend, Terminal};
use std::sync::Arc;
#[test]
fn renders_vt100_screen_with_hello() {
let parser = Arc::new(Mutex::new(vt100::Parser::new(5, 20, 0)));
parser.lock().process(b"hello world");
let pane = Pane { screen: parser, status: PaneStatus::Live, focused: false };
let backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| pane.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
out.push('\n');
}
let expected = std::fs::read_to_string("tests/snapshots/pane_hello.txt").unwrap();
assert_eq!(out, expected);
}
}
- Step 2: Implement Pane
// crates/maestro-tui/src/pane.rs
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
buffer::Buffer,
widgets::Widget,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneStatus { Connecting, Live, Reconnecting, Ended }
#[derive(Debug)]
pub struct Pane {
pub screen: Arc<Mutex<vt100::Parser>>,
pub status: PaneStatus,
pub focused: bool,
}
impl Pane {
pub fn render(&self, f: &mut Frame, area: Rect) {
let parser = self.screen.lock();
let scr = parser.screen();
let buf = f.buffer_mut();
let (rows, cols) = (area.height.min(scr.size().0), area.width.min(scr.size().1));
for r in 0..rows {
for c in 0..cols {
if let Some(cell) = scr.cell(r, c) {
let s = cell.contents();
let s = if s.is_empty() { " " } else { s };
buf[(area.x + c, area.y + r)]
.set_symbol(s)
.set_style(translate_cell_style(cell));
}
}
}
}
}
fn translate_cell_style(cell: &vt100::Cell) -> Style {
use vt100::Color::*;
fn cvt(c: vt100::Color) -> Option<Color> {
Some(match c {
Default => return None,
Idx(i) => Color::Indexed(i),
Rgb(r, g, b) => Color::Rgb(r, g, b),
})
}
let mut s = Style::default();
if let Some(c) = cvt(cell.fgcolor()) { s = s.fg(c); }
if let Some(c) = cvt(cell.bgcolor()) { s = s.bg(c); }
s
}
Add pub mod pane; to lib.rs.
-
Step 3: Generate golden, run test until pass.
-
Step 4: Commit
git add crates/maestro-tui/src/pane.rs crates/maestro-tui/tests/snapshots/pane_hello.txt
git commit -m "feat(maestro-tui): pane widget mapping vt100 cells into ratatui buffer"
Task 15: Overlay widgets (Spawn modal, Kill confirm, Help, Toast)
Files:
-
Create:
crates/maestro-tui/src/overlays.rs -
Create:
crates/maestro-tui/tests/snapshots/spawn_modal.txt -
Create:
crates/maestro-tui/tests/snapshots/help_overlay.txt -
Step 1: Sketch each overlay's API
// crates/maestro-tui/src/overlays.rs
use maestro_protocol::DaemonInfo;
use ratatui::{
Frame, layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
text::{Line, Span},
};
#[derive(Debug, Default)]
pub struct SpawnModal {
pub daemons: Vec<DaemonInfo>,
pub daemon_idx: usize,
pub cwd: String,
pub command: String,
pub label: String,
pub focused_field: SpawnField,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default] Daemon, Cwd, Command, Label, Submit,
}
impl SpawnModal {
pub fn next_field(&mut self) { /* … */ }
pub fn prev_field(&mut self) { /* … */ }
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 14);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2), Constraint::Length(2), Constraint::Length(2),
Constraint::Length(2), Constraint::Length(1),
]).split(inner);
let style = |f: SpawnField| if self.focused_field == f {
Style::default().add_modifier(Modifier::REVERSED)
} else { Style::default() };
let daemon_label = self.daemons.get(self.daemon_idx).map(|d| d.hostname.as_str()).unwrap_or("(none)");
f.render_widget(Paragraph::new(vec![Line::from(vec![
Span::raw("daemon: "), Span::styled(daemon_label, style(SpawnField::Daemon)),
])]), rows[0]);
f.render_widget(Paragraph::new(vec![Line::from(vec![
Span::raw("cwd: "), Span::styled(self.cwd.as_str(), style(SpawnField::Cwd)),
])]), rows[1]);
f.render_widget(Paragraph::new(vec![Line::from(vec![
Span::raw("command: "), Span::styled(self.command.as_str(), style(SpawnField::Command)),
])]), rows[2]);
f.render_widget(Paragraph::new(vec![Line::from(vec![
Span::raw("label: "), Span::styled(self.label.as_str(), style(SpawnField::Label)),
])]), rows[3]);
f.render_widget(Paragraph::new(vec![Line::from(vec![
Span::styled("[ Spawn ]", style(SpawnField::Submit)),
])]), rows[4]);
}
}
#[derive(Debug)]
pub struct KillConfirm { pub label: String }
impl KillConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 50, 5);
f.render_widget(Clear, area);
let block = Block::default().title(" kill ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::from(format!("Kill session {}?", self.label)),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
#[derive(Debug)]
pub struct HelpOverlay { pub lines: Vec<String> }
impl HelpOverlay {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 70, parent.height.saturating_sub(4));
f.render_widget(Clear, area);
let block = Block::default().title(" help ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let items: Vec<ListItem> = self.lines.iter().map(|s| ListItem::new(s.as_str())).collect();
f.render_widget(List::new(items), inner);
}
}
#[derive(Debug)]
pub struct Toast { pub text: String }
impl Toast {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = 3u16; let w = parent.width.min(self.text.len() as u16 + 4);
let area = Rect { x: parent.x + (parent.width - w) / 2, y: parent.y + parent.height - h - 1, width: w, height: h };
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(self.text.as_str()), inner);
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
x: parent.x + (parent.width.saturating_sub(w)) / 2,
y: parent.y + (parent.height.saturating_sub(h)) / 2,
width: w, height: h,
}
}
Add pub mod overlays; to lib.rs. Implement next_field/prev_field rotating Daemon → Cwd → Command → Label → Submit → Daemon:
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Cwd,
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Label,
SpawnField::Label => SpawnField::Submit,
SpawnField::Submit => SpawnField::Daemon,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Submit,
SpawnField::Cwd => SpawnField::Daemon,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Label => SpawnField::Command,
SpawnField::Submit => SpawnField::Label,
};
}
/// Keyboard handling while the spawn modal owns input. Returns true if the
/// modal should close (Submit pressed, caller fires the POST).
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
use crate::input::InputKey;
match key {
InputKey::Tab => { self.next_field(); SpawnInputResult::Consumed }
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
_ => { self.next_field(); SpawnInputResult::Consumed }
},
InputKey::Char { c, .. } => {
match self.focused_field {
SpawnField::Cwd => { self.cwd.push(*c); SpawnInputResult::Consumed }
SpawnField::Command => { self.command.push(*c); SpawnInputResult::Consumed }
SpawnField::Label => { self.label.push(*c); SpawnInputResult::Consumed }
SpawnField::Daemon => {
// 'j'/'k' cycle through daemons.
if *c == 'j' && !self.daemons.is_empty() {
self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
} else if *c == 'k' && !self.daemons.is_empty() {
self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1) % self.daemons.len();
}
SpawnInputResult::Consumed
}
SpawnField::Submit => SpawnInputResult::Consumed,
}
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => { self.cwd.pop(); }
SpawnField::Command => { self.command.pop(); }
SpawnField::Label => { self.label.pop(); }
_ => {}
}
SpawnInputResult::Consumed
}
_ => SpawnInputResult::Consumed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult { Consumed, Submit, Cancel }
- Step 2: Snapshot tests
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::DaemonInfo;
use ratatui::{backend::TestBackend, Terminal};
fn capture<F: FnOnce(&mut ratatui::Frame)>(w: u16, h: u16, f: F) -> String {
let backend = TestBackend::new(w, h);
let mut t = Terminal::new(backend).unwrap();
t.draw(|frame| f(frame)).unwrap();
let buf = t.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); }
out.push('\n');
}
out
}
#[test]
fn spawn_modal_renders_with_one_daemon() {
let modal = SpawnModal {
daemons: vec![DaemonInfo {
daemon_id: "k".into(), hostname: "konishi".into(),
version: "0".into(), daemon_url: "ws://x".into(),
}],
daemon_idx: 0, cwd: "/x".into(), command: "bash".into(), label: "".into(),
focused_field: SpawnField::Cwd,
};
let out = capture(80, 20, |f| modal.render(f, f.area()));
let expected = std::fs::read_to_string("tests/snapshots/spawn_modal.txt").unwrap();
assert_eq!(out, expected);
}
#[test]
fn help_overlay_renders_lines() {
let help = HelpOverlay { lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
"x KillHighlighted".into(),
]};
let out = capture(80, 12, |f| help.render(f, f.area()));
let expected = std::fs::read_to_string("tests/snapshots/help_overlay.txt").unwrap();
assert_eq!(out, expected);
}
}
Generate goldens by running each test once with the file missing, capturing the output via a temporary dbg!(&out), copying to the snapshot file, removing the dbg!, re-running until green.
- Step 3: Commit
git add crates/maestro-tui/src/overlays.rs crates/maestro-tui/tests/snapshots/spawn_modal.txt crates/maestro-tui/tests/snapshots/help_overlay.txt
git commit -m "feat(maestro-tui): overlay widgets — spawn modal, kill confirm, help, toast"
Task 16: Render orchestration + region hit-test
Files:
-
Create:
crates/maestro-tui/src/render.rs -
Modify:
crates/maestro-tui/src/lib.rs -
Step 1: Define the render entry point and region map
// crates/maestro-tui/src/render.rs
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
widgets::{Block, Borders},
};
use crate::input::Region;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::pane::{Pane, PaneStatus};
use crate::sidebar::Sidebar;
use crate::state::{AppState, OverlayKind};
use crate::tabs::TabBar;
/// Map of screen regions for the input router's hit-test step.
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub tab_bar: Option<Rect>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
impl RegionMap {
pub fn region_at(&self, col: u16, row: u16) -> Region {
if let Some(r) = self.modal { if inside(r, col, row) { return Region::Modal; } }
if let Some(r) = self.tab_bar { if inside(r, col, row) { return Region::Tab; } }
if let Some(r) = self.sidebar { if inside(r, col, row) { return Region::SidebarRow; } }
if let Some(r) = self.pane { if inside(r, col, row) { return Region::Pane; } }
Region::Unknown
}
}
fn inside(r: Rect, col: u16, row: u16) -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
}
pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let mut map = RegionMap::default();
let area = f.area();
let cols = if inputs.state.sidebar_visible {
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(0)]).split(area)
} else {
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Length(0), Constraint::Min(0)]).split(area)
};
let sidebar_area = cols[0];
let right = cols[1];
if inputs.state.sidebar_visible {
inputs.sidebar.render(f, sidebar_area);
map.sidebar = Some(sidebar_area);
}
let rows = Layout::default().direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)]).split(right);
let tab_area = rows[0];
let pane_area = rows[1];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let pane_block = Block::default().borders(Borders::ALL);
let pane_inner = pane_block.inner(pane_area);
f.render_widget(pane_block, pane_area);
if let Some(p) = inputs.pane { p.render(f, pane_inner); }
map.pane = Some(pane_inner);
match &inputs.state.overlay {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::KillConfirm(id)) => {
let kc = KillConfirm { label: id.to_string() };
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
}
None => {}
}
map
}
Add pub mod render; to lib.rs.
- Step 2: Smoke test the region map
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn region_map_resolves_known_areas() {
let map = RegionMap {
sidebar: Some(Rect { x: 0, y: 0, width: 10, height: 20 }),
tab_bar: Some(Rect { x: 10, y: 0, width: 30, height: 1 }),
pane: Some(Rect { x: 10, y: 1, width: 30, height: 19 }),
modal: None,
};
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
assert_eq!(map.region_at(15, 0), Region::Tab);
assert_eq!(map.region_at(15, 5), Region::Pane);
assert_eq!(map.region_at(50, 50), Region::Unknown);
}
}
Run: cargo test -p maestro-tui render::tests
Expected: 1 passing.
- Step 3: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): render orchestration + region map for hit-testing"
Phase 6 — Event loop & lifecycle wiring
Task 17: App event loop (input → action → mutate → render)
Files:
-
Create:
crates/maestro-tui/src/app.rs -
Modify:
crates/maestro-tui/src/lib.rs(re-export, wirerun()) -
Step 1: Implement the loop skeleton
// crates/maestro-tui/src/app.rs
use std::sync::Arc;
use std::time::Duration;
use crossterm::event::EventStream;
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::Stdout;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, InputKey, Region};
use crate::keymap::Mode;
use crate::render::{draw, RegionMap, RenderInputs};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::pane::{Pane, PaneStatus};
use crate::session_task::{SessionInput, SessionTask};
pub struct App {
pub cfg: TuiConfig,
pub catalog: CatalogStore,
pub emulators: SessionEmulators,
pub state: AppState,
pub chord: ChordState,
pub daemon_base_url: String,
pub region: RegionMap,
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App").finish()
}
}
impl App {
pub async fn run(
mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), crate::TuiError> {
let mut events = EventStream::new();
let notify = self.catalog.changed_notify();
loop {
self.redraw(terminal);
tokio::select! {
Some(Ok(ev)) = events.next() => {
let map = self.region.clone();
if let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) {
let known = self.cfg.keymap.known_prefixes(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => {}
ChordResult::Resolved(canon) => {
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
if let Action::Quit = act { return Ok(()); }
self.dispatch(act);
} else if self.state.mode == Mode::Insert
&& matches!(self.state.focus, Focus::Pane)
{
self.forward_to_session(&key).await;
}
}
}
}
}
_ = notify.notified() => {
// Catalog change: next loop redraws.
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
// 30 Hz max; redraw on tick too.
}
}
}
}
fn redraw(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) {
let snap = self.catalog.snapshot();
let attached: std::collections::HashSet<_> = self.state.tabs.iter()
.filter_map(|t| t.pane).collect();
let sidebar = Sidebar {
daemons: snap.daemons.clone(),
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
attached,
};
let tab_bar = TabBar {
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
active_idx: self.state.active_tab_idx,
};
let active_session = self.state.active_tab().pane;
let pane = active_session.and_then(|id| self.emulators.get(id).map(|h| Pane {
screen: h.screen.clone(),
status: pane_status_from(h.state()),
focused: matches!(self.state.focus, Focus::Pane),
}));
let spawn = SpawnModal { daemons: snap.daemons.clone(), ..Default::default() };
let help = HelpOverlay { lines: render_help_lines(&self.cfg) };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: pane.as_ref(),
state: &self.state,
spawn: &spawn,
help: &help,
};
let mut new_region = RegionMap::default();
let _ = terminal.draw(|f| {
new_region = draw(f, inputs);
});
self.region = new_region;
}
fn dispatch(&mut self, act: Action) {
match act {
Action::EnterInsert => self.state.mode = Mode::Insert,
Action::LeaveInsert => self.state.mode = Mode::Normal,
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
Action::NewTab => self.state.new_tab(),
Action::CloseTab => self.state.close_tab(),
Action::NextTab => self.state.next_tab(),
Action::PrevTab => self.state.prev_tab(),
Action::GotoTab(n) => self.state.goto_tab(n),
Action::ToggleSidebar => self.state.sidebar_visible = !self.state.sidebar_visible,
Action::ShowHelp => self.state.overlay = Some(OverlayKind::Help),
Action::OpenSpawnModal => self.state.overlay = Some(OverlayKind::Spawn),
Action::AttachHighlighted => {
// Real attach implementation lands in Task 18 (which makes `dispatch`
// async). For Task 17 we record intent only — the action mutates
// local UI state, the WS task is wired in next.
if let Some(sid) = self.state.sidebar_highlight {
self.state.attach_to_focused(sid);
}
}
Action::KillHighlighted => {
if let Some(sid) = self.state.sidebar_highlight {
self.state.overlay = Some(OverlayKind::KillConfirm(sid));
}
}
Action::FocusPane(_)
| Action::ToggleAttachHighlighted
| Action::Noop
| Action::Quit => {}
}
}
async fn forward_to_session(&mut self, key: &InputKey) {
if let Some(sid) = self.state.active_tab().pane {
if let Some(h) = self.emulators.get(sid) {
if let InputKey::Char { c, ctrl, alt, .. } = key {
let mut bytes = Vec::new();
if *alt { bytes.push(0x1b); }
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
if *ctrl {
// Map ctrl-<letter> to 0x01..0x1A (best-effort, ASCII).
if c.is_ascii_alphabetic() {
bytes.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
bytes.extend_from_slice(s.as_bytes());
}
} else {
bytes.extend_from_slice(s.as_bytes());
}
let _ = h.tx.send(SessionInput::Bytes(bytes)).await;
}
}
}
}
}
fn pane_status_from(s: crate::session_task::SessionState) -> PaneStatus {
use crate::session_task::SessionState::*;
match s {
Connecting => PaneStatus::Connecting,
Live => PaneStatus::Live,
Reconnecting => PaneStatus::Reconnecting,
Ended => PaneStatus::Ended,
}
}
fn ws_base(http: &str) -> String {
if let Some(rest) = http.strip_prefix("http://") { format!("ws://{rest}") }
else if let Some(rest) = http.strip_prefix("https://") { format!("wss://{rest}") }
else { http.to_string() }
}
fn render_help_lines(_cfg: &TuiConfig) -> Vec<String> {
// v1: enumerate the keymap. Cheap.
vec!["? to close q/Ctrl-w q to quit".into()]
}
Then wire lib::run:
// crates/maestro-tui/src/lib.rs (replace the stub `run`)
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
terminal::install_panic_hook();
let _guard = terminal::TerminalGuard::enter().map_err(|e| TuiError::Terminal(e.to_string()))?;
let base_url = format!("http://{}", cfg.daemon_addr);
// Health check.
let probe = reqwest::Client::new()
.get(format!("{base_url}/api/web/info"))
.timeout(std::time::Duration::from_millis(500))
.send().await;
if probe.is_err() {
eprintln!("maestro: cannot reach local daemon at {base_url}");
eprintln!("hint: `maestro daemon configure` and `systemctl --user start maestro-daemon`");
return Err(TuiError::DaemonUnreachable);
}
// v1 uses shipped defaults; the `--config` flag is already global on the
// umbrella binary, so once a Task-25 follow-up plumbs the parsed file path
// into RunConfig we just swap MaestroConfigFile::default() for the loaded one.
let file = maestro_config::MaestroConfigFile::default();
let tui_cfg = config::TuiConfig::load(&file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
let catalog = catalog::CatalogStore::default();
catalog.bootstrap(&base_url).await.map_err(|e| TuiError::Catalog(e.to_string()))?;
tokio::spawn(catalog.clone().run_events_loop(
format!("{}/api/events", ws_base(&base_url)),
base_url.clone(),
));
let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout());
let mut terminal = ratatui::Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
let app = app::App {
cfg: tui_cfg,
catalog,
emulators: emulators::SessionEmulators::default(),
state: state::AppState::new(),
chord: input::ChordState::new(std::time::Duration::from_millis(1000)),
daemon_base_url: base_url.clone(),
region: render::RegionMap::default(),
};
app.run(&mut terminal).await
}
fn ws_base(http: &str) -> String {
if let Some(rest) = http.strip_prefix("http://") { format!("ws://{rest}") }
else if let Some(rest) = http.strip_prefix("https://") { format!("wss://{rest}") }
else { http.to_string() }
}
Extend TuiError:
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("not implemented")]
NotImplemented,
#[error("daemon unreachable")]
DaemonUnreachable,
#[error("config: {0}")]
Config(String),
#[error("catalog: {0}")]
Catalog(String),
#[error("terminal: {0}")]
Terminal(String),
}
- Step 2: Smoke build
Run: cargo build -p maestro-tui
Expected: clean build.
- Step 3: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): app event loop with input→action→mutate→render"
Task 18: AttachHighlighted spawns a real SessionTask
The placeholder in Task 17's dispatch doesn't actually open the WS. Real implementation.
Files:
-
Modify:
crates/maestro-tui/src/app.rs -
Step 1: Replace the placeholder branch
Action::AttachHighlighted => {
if let Some(sid) = self.state.sidebar_highlight {
if self.emulators.get(sid).is_none() {
let url = format!("{}/api/attach/{}", ws_base(&self.daemon_base_url), sid);
// Initial size: 80×24, will be corrected on next render's resize pass.
match SessionTask::spawn(url, 24, 80).await {
Ok(handle) => self.emulators.insert(sid, handle),
Err(e) => {
self.state.overlay = Some(OverlayKind::Toast(format!("attach failed: {e}")));
}
}
}
self.state.attach_to_focused(sid);
}
}
(dispatch becomes async fn dispatch; the call site in run switches to self.dispatch(act).await.)
- Step 2: Smoke build + integration test
Run: cargo build -p maestro-tui
Expected: clean.
(Integration test that drives this end-to-end comes in Task 22.)
- Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): AttachHighlighted opens real SessionTask"
Task 19: Spawn flow — modal submit → POST /api/sessions
Files:
-
Modify:
crates/maestro-tui/src/app.rs -
Modify:
crates/maestro-tui/src/overlays.rs -
Step 1: Keyboard handling for the spawn modal
In App::run's event branch, intercept input while the spawn overlay is active. Add a handle_overlay_input helper:
// inside App
async fn handle_overlay_input(&mut self, key: &InputKey) -> bool {
match (&self.state.overlay, key) {
(Some(OverlayKind::Spawn), InputKey::Esc) => {
self.state.overlay = None; true
}
(Some(OverlayKind::Spawn), InputKey::Enter) => {
self.submit_spawn().await;
true
}
(Some(OverlayKind::Spawn), key) => {
match self.spawn.on_input(key) {
crate::overlays::SpawnInputResult::Consumed => {}
crate::overlays::SpawnInputResult::Cancel => { self.state.overlay = None; }
crate::overlays::SpawnInputResult::Submit => { self.submit_spawn().await; }
}
true
}
_ => false,
}
}
Add a spawn: SpawnModal field on App so its state persists while the overlay is open. The render path reads &self.spawn instead of constructing a fresh one each frame.
- Step 2: Implement the POST
async fn submit_spawn(&mut self) {
let body = serde_json::json!({
"daemon_id": self.spawn.daemons.get(self.spawn.daemon_idx).map(|d| &d.daemon_id),
"cwd": self.spawn.cwd,
"command": self.spawn.command,
"label": if self.spawn.label.is_empty() { serde_json::Value::Null }
else { serde_json::Value::String(self.spawn.label.clone()) },
});
let res = reqwest::Client::new()
.post(format!("{}/api/sessions", self.daemon_base_url))
.json(&body)
.send().await;
match res.and_then(|r| r.error_for_status()) {
Ok(_) => self.state.overlay = None,
Err(e) => self.state.overlay = Some(OverlayKind::Toast(format!("spawn failed: {e}"))),
}
}
(Inspect crates/maestro-protocol::CreateSessionRequest and serialize that struct directly instead of using json! — that's the canonical form; use json! only if the struct is private to the daemon.)
- Step 3: Smoke build
Run: cargo build -p maestro-tui
Expected: clean.
- Step 4: Commit
git add crates/maestro-tui/src/
git commit -m "feat(maestro-tui): spawn modal submit hits POST /api/sessions"
Task 20: Kill flow — confirm overlay → DELETE /api/sessions/:id
Files:
-
Modify:
crates/maestro-tui/src/app.rs -
Step 1: Confirm overlay key handling + DELETE
Extend handle_overlay_input:
(Some(OverlayKind::KillConfirm(id)), InputKey::Char { c: 'y', .. }) => {
let sid = *id;
self.state.overlay = None;
let url = format!("{}/api/sessions/{sid}", self.daemon_base_url);
let res = reqwest::Client::new().delete(&url).send().await
.and_then(|r| r.error_for_status());
if let Err(e) = res {
self.state.overlay = Some(OverlayKind::Toast(format!("kill failed: {e}")));
}
true
}
(Some(OverlayKind::KillConfirm(_)), InputKey::Char { c: 'n', .. } | InputKey::Esc) => {
self.state.overlay = None;
true
}
- Step 2: Smoke build
Run: cargo build -p maestro-tui
Expected: clean.
- Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): kill confirm overlay → DELETE /api/sessions/:id"
Phase 7 — Integration
Task 21: Resize propagation in the render loop
Files:
-
Modify:
crates/maestro-tui/src/app.rs -
Step 1: Track each pane's allocated rect after render, recompute min per session, send Resize when changed
At the end of redraw:
// In App, add:
// last_sent: HashMap<Uuid, (u16, u16)>
let active_id = self.state.active_tab().pane;
if let Some(sid) = active_id {
if let Some(rect) = self.region.pane {
self.emulators.resize_tracker_mut().set_pane_size(
sid,
"active",
(rect.width, rect.height),
);
}
}
let snapshot_sids: Vec<_> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
for sid in snapshot_sids {
if let Some((cols, rows)) = self.emulators.resize_tracker_mut().session_size(sid) {
let prev = self.last_sent.get(&sid).copied();
if prev != Some((cols, rows)) {
if let Some(h) = self.emulators.get(sid) {
let _ = h.tx.try_send(SessionInput::Resize { cols, rows });
}
self.last_sent.insert(sid, (cols, rows));
}
}
}
- Step 2: Smoke build
Run: cargo build -p maestro-tui
Expected: clean.
- Step 3: Commit
git add crates/maestro-tui/src/app.rs
git commit -m "feat(maestro-tui): per-session resize propagation after render"
Task 22: Integration test against in-process daemon
Files:
-
Create:
crates/maestro-tui/tests/integration.rs -
Step 1: Read the existing daemon-spinning helper
Before writing, read crates/maestro-e2e/src/ (or wherever the harness lives — grep -rn "LocalApiState::" crates/maestro-e2e) to find the canonical helper that builds LocalApiState. Use it directly; do not re-derive the fields. The daemon's local_api::router(state) is pub, which is the seam.
- Step 2: Write the test
// crates/maestro-tui/tests/integration.rs
use std::time::Duration;
use maestro_protocol::CreateSessionRequest;
use maestro_tui::catalog::CatalogStore;
// Pull in the canonical helper from maestro-e2e. If its module path differs,
// match what you found in Step 1 — DO NOT invent constructor fields.
use maestro_e2e::fixtures::spawn_in_process_daemon;
#[tokio::test(flavor = "multi_thread")]
async fn tui_catalog_reflects_session_created_via_events_ws() {
let daemon = spawn_in_process_daemon().await; // returns { http_base, ws_base, _drop_guard }
let store = CatalogStore::default();
store.bootstrap(&daemon.http_base).await.unwrap();
assert_eq!(store.snapshot().daemons.len(), 1);
assert!(store.snapshot().sessions.is_empty());
// Spawn the events loop in the background.
let bg_store = store.clone();
let ws_url = format!("{}/api/events", daemon.ws_base);
let rest_base = daemon.http_base.clone();
tokio::spawn(async move { bg_store.run_events_loop(ws_url, rest_base).await });
// POST a session via the daemon's own HTTP surface.
let req = CreateSessionRequest {
daemon_id: store.snapshot().daemons[0].daemon_id.clone(),
cwd: "/tmp".into(),
command: "bash".into(),
label: Some("integration".into()),
env: Default::default(),
worktree: None,
};
let res = reqwest::Client::new()
.post(format!("{}/api/sessions", daemon.http_base))
.json(&req).send().await.unwrap().error_for_status().unwrap();
let _: serde_json::Value = res.json().await.unwrap();
// Wait for the events WS to deliver SessionCreated. Poll for up to 2 s.
for _ in 0..200 {
if store.snapshot().sessions.len() == 1 { return; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("session never propagated through /api/events");
}
If CreateSessionRequest's field names differ from this listing, match the current source — open crates/maestro-protocol/src/lib.rs first.
- Step 3: Run
Run: cargo test -p maestro-tui --test integration -- --nocapture
Expected: passing after iterating on field-name mismatches surfaced by the compiler.
- Step 2: Run — fail (until helper imports compile and assertions hold)
Run: cargo test -p maestro-tui --features integration --test integration
Expected: red, then green as the test is fleshed out.
- Step 3: Commit
git add crates/maestro-tui/tests/integration.rs crates/maestro-tui/Cargo.toml
git commit -m "test(maestro-tui): integration test against in-process daemon"
Task 23: E2E test in maestro-e2e
Files:
-
Create:
crates/maestro-e2e/tests/tui_e2e.rs -
Step 1: Drive the TUI from a substitutable event source
Add an internal crate::testing module to maestro-tui that exposes an alternate constructor:
// crates/maestro-tui/src/testing.rs (gated behind cfg(any(test, feature = "test-util")))
use tokio::sync::mpsc;
use crossterm::event::Event;
pub struct TestEventSource(pub mpsc::Receiver<Event>);
// Re-export from lib.rs behind the feature; provide an alternate App::run_with_events
// that accepts a custom event stream instead of crossterm::event::EventStream.
E2E test:
// crates/maestro-e2e/tests/tui_e2e.rs
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
use maestro_e2e::fixtures::two_daemon_topology; // existing fixture; match the real name
use maestro_tui::testing::TestEventSource;
fn key(c: char) -> Event {
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
fn special(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[tokio::test(flavor = "multi_thread")]
async fn tui_spawn_attach_type_kill_across_two_daemons() {
let topo = two_daemon_topology().await; // .registry, .daemon_a, .daemon_b, _drop_guards
let (tx, rx) = mpsc::channel::<Event>(64);
let events = TestEventSource(rx);
let cfg = maestro_tui::RunConfig { daemon_addr: topo.daemon_a.local_addr };
let tui_handle = tokio::spawn(async move {
maestro_tui::run_with_events(cfg, events).await
});
// Allow the catalog bootstrap to populate.
tokio::time::sleep(Duration::from_millis(200)).await;
// s → spawn modal
tx.send(key('s')).await.unwrap();
// Tab to daemon field → 'j' to pick daemon B (defaults to A which is index 0)
// Default focused field is Daemon, so 'j' switches the dropdown.
tx.send(key('j')).await.unwrap();
// Tab → cwd, type "/tmp"
tx.send(special(KeyCode::Tab)).await.unwrap();
for c in "/tmp".chars() { tx.send(key(c)).await.unwrap(); }
// Tab → command, type "bash"
tx.send(special(KeyCode::Tab)).await.unwrap();
for c in "bash".chars() { tx.send(key(c)).await.unwrap(); }
// Tab → label, type "e2e"
tx.send(special(KeyCode::Tab)).await.unwrap();
for c in "e2e".chars() { tx.send(key(c)).await.unwrap(); }
// Tab → Submit, Enter
tx.send(special(KeyCode::Tab)).await.unwrap();
tx.send(special(KeyCode::Enter)).await.unwrap();
// Wait for the session to land in daemon B's catalog (visible via daemon A's mirror).
topo.wait_until_session("e2e", Duration::from_secs(2)).await.unwrap();
// Enter → attach the highlighted session (sidebar highlight should be the new one).
tx.send(special(KeyCode::Enter)).await.unwrap();
// i → Insert; send "echo hi\n"; Ctrl-A (leader) → Normal.
tx.send(key('i')).await.unwrap();
for c in "echo hi\n".chars() { tx.send(key(c)).await.unwrap(); }
tx.send(Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL))).await.unwrap();
// Assert the session's vt100 screen contains "hi" within 2 s.
topo.wait_until_session_screen_contains("e2e", "hi", Duration::from_secs(2)).await.unwrap();
// x → kill-confirm; y → confirm.
tx.send(key('x')).await.unwrap();
tx.send(key('y')).await.unwrap();
topo.wait_until_no_session("e2e", Duration::from_secs(2)).await.unwrap();
// Ctrl-w q → quit.
tx.send(Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL))).await.unwrap();
tx.send(key('q')).await.unwrap();
tui_handle.await.unwrap().unwrap();
}
The two_daemon_topology / wait_until_session* helpers may already exist in maestro-e2e under different names — grep for wait_until_session first. Add the missing ones inline in crates/maestro-e2e/src/fixtures.rs if they don't exist yet, before the test compiles.
- Step 2: Run — green after iteration
Run: cargo test -p maestro-e2e tui_e2e --features e2e
Expected: eventually green.
- Step 3: Commit
git add crates/maestro-e2e/tests/tui_e2e.rs crates/maestro-tui/src/testing.rs crates/maestro-tui/src/lib.rs
git commit -m "test(maestro-tui): e2e through two daemons via maestro-e2e harness"
Phase 8 — Wrap-up
Task 24: Workspace-level CI verification
- Step 1: Push the branch
git push -u origin <branch>
- Step 2: Watch gitea CI
(Per user convention: heavy workspace builds run on gitea CI, not locally. Poll until green.)
- Step 3: Address any cross-crate fallout — most likely
cargo clippy --all-targets -- -D warningslints 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 tuiis the new subcommand, that it ships with the default feature set, and that opt-out works via--no-default-features --features cli. -
Step 2: Add a paragraph on
[tui]config — leader, chord timeout, normal/insert keymap tables — pointing at the spec for the action vocabulary. -
Step 3: Commit
git add docs/INSTALL.md README.md
git commit -m "docs(maestro): document \`maestro tui\` subcommand and [tui] config"
Self-review
A note for the engineer executing this plan: the spec deliberately defers a few things to "during implementation". These are not gaps — they are scope edges where the right answer depends on what vt100 and ratatui look like once we touch them. Re-read §7 of the spec when in doubt.
Spec coverage notes:
- Sidebar / tab bar / pane / overlays — Tasks 12–15.
- Layout tree — v1 uses
Option<SessionId>; the binary split tree the spec describes lands in v1.5 (out of this plan). - Configurable keymap + mouse — Tasks 3–5 + 16.
- Catalog bootstrap + events WS reconnect — Tasks 6–7.
- Per-session liveness + reconnect — Task 8.
- Per-dimension min resize — Tasks 9 + 21.
- Daemon-unreachable startup check — wired in Task 17
lib::run. - Terminal restore on panic — Task 11.
- Integration + e2e — Tasks 22–23.
What the plan deliberately omits vs the spec:
- Split panes (
SplitHorizontal/SplitVertical/etc.) — v1.5. - Vim-
:command palette — v1.5+. POST /api/sessions/:id/refreshdaemon 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.