feat(maestro-tui): App event loop + lifecycle wiring (NAP-281)
ci / rust (push) Failing after 5m4s

Stitches the pieces from NAP-276..280 into a working app:

- `app.rs` — `App` owns config, catalog, emulators, state, modal, chord
  resolver, region map, last-sent resize cache, and the reqwest client.
  `run()` races crossterm events, catalog change notifies, and a 30 Hz
  heartbeat. `redraw()` builds `RenderInputs`, calls `render::draw`,
  refreshes `RegionMap`, then runs per-session min-size resize propagation
  to the daemon.
- `dispatch(Action)` — Action → state mutation. `AttachHighlighted` spawns
  a real `SessionTask` if none exists for the session. `OpenSpawnModal`
  rebuilds the modal against the current daemon list. `KillHighlighted`
  opens a confirm overlay.
- `handle_overlay_input` — intercepts keystrokes while spawn modal / kill
  confirm / help is open. Spawn `Submit` fires `POST /api/sessions`.
  Kill `y` fires `DELETE /api/sessions/:id` and drops the local handle.
- Insert-mode passthrough: characters (with ctrl/alt mapping), arrows
  (CSI sequences), Enter/Tab/Backspace/Esc become binary bytes on the
  active session's WS sink.
- `lib::run` — installs the panic hook, runs a 3×500ms `/api/web/info`
  probe, loads `[tui]` from `MaestroConfigFile`, spawns
  `CatalogStore::run_events_loop`, waits up to 2s for the bootstrap
  Snapshot, enters the terminal guard, and drives `App::run`.

90 tests passing (5 new app dispatch unit tests + the existing widget +
catalog + session_task + emulators + pure-logic suites).
This commit is contained in:
2026-05-28 22:30:45 +00:00
parent 50688c39b7
commit 5a17de4489
5 changed files with 652 additions and 9 deletions
+1
View File
@@ -14,6 +14,7 @@ futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
ratatui.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
+503
View File
@@ -0,0 +1,503 @@
//! `App` — top-level event loop that stitches everything together.
//!
//! Per frame: `redraw()` reads from `CatalogStore` + `SessionEmulators` and
//! draws via [`crate::render::draw`], returning a `RegionMap` for the next
//! mouse hit-test. The event loop then races crossterm input, catalog
//! changes, and a 30Hz heartbeat.
//!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / `handle_overlay_input`.
use std::collections::{HashMap, HashSet};
use std::io::Stdout;
use std::time::Duration;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::CreateSessionRequest;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, InputKey};
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::TuiError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OverlayDecision {
/// Overlay handled the event; do not run keymap dispatch.
Consumed,
/// No overlay open; the dispatcher should process the event normally.
Pass,
}
pub struct App {
cfg: TuiConfig,
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
spawn: SpawnModal,
help: HelpOverlay,
chord: ChordState,
daemon_base_url: String,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
http: reqwest::Client,
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("daemon_base_url", &self.daemon_base_url)
.field("state", &self.state)
.finish_non_exhaustive()
}
}
impl App {
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon_base_url: String) -> Self {
let chord_timeout = cfg.chord_timeout;
Self {
cfg,
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help: HelpOverlay {
lines: vec!["? to close ctrl-w q to quit".into()],
},
chord: ChordState::new(chord_timeout),
daemon_base_url,
region: RegionMap::default(),
last_sent_size: HashMap::new(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client"),
}
}
/// Drive the loop until the user quits. Consumes self.
pub async fn run(
mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
let mut events = EventStream::new();
loop {
self.redraw(terminal)?;
tokio::select! {
Some(Ok(ev)) = events.next() => {
if self.handle_event(ev).await? {
return Ok(());
}
}
_ = self.catalog.changed() => {
// Render on next loop iteration.
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
// 30 Hz heartbeat.
}
}
}
}
/// Returns true when the loop should exit.
async fn handle_event(&mut self, ev: CtEvent) -> Result<bool, TuiError> {
let map = self.region.clone();
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
return Ok(false);
};
// Overlay intercept first.
if self.handle_overlay_input(&key).await == OverlayDecision::Consumed {
return Ok(false);
}
let known = self.cfg.keymap.known_bindings(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
ChordResult::Resolved(canon) => {
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
if matches!(act, Action::Quit) {
return Ok(true);
}
self.dispatch(act).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
self.forward_to_session(&key).await;
}
Ok(false)
}
}
}
async fn handle_overlay_input(&mut self, key: &InputKey) -> OverlayDecision {
use crate::overlays::SpawnInputResult;
match self.state.overlay.clone() {
Some(OverlayKind::Spawn) => {
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.overlay = None;
}
SpawnInputResult::Submit => {
self.submit_spawn().await;
}
}
OverlayDecision::Consumed
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.overlay = None;
self.submit_kill(id).await;
OverlayDecision::Consumed
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.overlay = None;
OverlayDecision::Consumed
}
_ => OverlayDecision::Consumed,
},
Some(OverlayKind::Help) => {
self.state.overlay = None;
OverlayDecision::Consumed
}
Some(OverlayKind::Toast(_)) | None => OverlayDecision::Pass,
}
}
async fn dispatch(&mut self, act: Action) {
match act {
Action::Noop | Action::Quit => {}
Action::EnterInsert => self.state.mode = Mode::Insert,
Action::LeaveInsert => self.state.mode = Mode::Normal,
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
Action::FocusPane(_) => {
// v1 has one pane per tab; geometric focus motion lands
// either in the sidebar or back in the pane.
self.state.focus = Focus::Pane;
}
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.toggle_sidebar(),
Action::ShowHelp => self.state.overlay = Some(OverlayKind::Help),
Action::OpenSpawnModal => {
// Rebuild spawn modal against the current catalog so the
// daemon dropdown is fresh.
let snap = self.catalog.snapshot();
self.spawn = SpawnModal {
daemons: snap.daemons,
daemon_idx: 0,
cwd: String::new(),
command: String::new(),
label: String::new(),
focused_field: Default::default(),
};
self.state.overlay = Some(OverlayKind::Spawn);
}
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
if self.emulators.get(sid).is_none() {
let ws_url = format!("{}/api/attach/{sid}", ws_base(&self.daemon_base_url));
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
self.state.attach_to_focused(sid);
}
}
Action::KillHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
self.state.overlay = Some(OverlayKind::KillConfirm(sid));
}
}
}
}
/// Return the session under the sidebar highlight, falling back to the
/// first listed session so the user can attach without arrowing first.
fn pick_sidebar_target(&self) -> Option<Uuid> {
if let Some(sid) = self.state.sidebar_highlight {
return Some(sid);
}
let snap = self.catalog.snapshot();
snap.sessions.first().map(|s| s.id)
}
async fn forward_to_session(&mut self, key: &InputKey) {
let Some(sid) = self.state.active_tab().pane else {
return;
};
let Some(handle) = self.emulators.get(sid) else {
return;
};
let bytes = match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
out
}
InputKey::Enter => b"\r".to_vec(),
InputKey::Tab => b"\t".to_vec(),
InputKey::Backspace => vec![0x7f],
InputKey::Esc => vec![0x1b],
InputKey::Up => b"\x1b[A".to_vec(),
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Mouse { .. } => return,
};
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
}
async fn submit_spawn(&mut self) {
let daemon_id = match self.spawn.daemons.get(self.spawn.daemon_idx) {
Some(d) => d.daemon_id.clone(),
None => {
self.state.overlay = Some(OverlayKind::Toast("spawn: pick a daemon first".into()));
return;
}
};
let cwd = std::path::PathBuf::from(self.spawn.cwd.clone());
let mut metadata = std::collections::HashMap::new();
if !self.spawn.label.is_empty() {
metadata.insert(
"label".to_string(),
serde_json::Value::String(self.spawn.label.clone()),
);
}
let req = CreateSessionRequest {
daemon_id,
cwd,
command: self.spawn.command.clone(),
env: Default::default(),
metadata,
worktree: None,
};
let url = format!("{}/api/sessions", self.daemon_base_url);
match self.http.post(&url).json(&req).send().await {
Ok(res) if res.status().is_success() => {
self.state.overlay = None;
}
Ok(res) => {
let status = res.status();
self.state.overlay =
Some(OverlayKind::Toast(format!("spawn failed: HTTP {status}")));
}
Err(e) => {
self.state.overlay = Some(OverlayKind::Toast(format!("spawn failed: {e}")));
}
}
}
async fn submit_kill(&mut self, sid: Uuid) {
let url = format!("{}/api/sessions/{sid}", self.daemon_base_url);
match self.http.delete(&url).send().await {
Ok(res) if res.status().is_success() => {
self.emulators.remove(sid);
self.last_sent_size.remove(&sid);
}
Ok(res) => {
let status = res.status();
self.state.overlay =
Some(OverlayKind::Toast(format!("kill failed: HTTP {status}")));
}
Err(e) => {
self.state.overlay = Some(OverlayKind::Toast(format!("kill failed: {e}")));
}
}
}
fn redraw(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = 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_sid = self.state.active_tab().pane;
let pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
status: PaneStatus::from(h.state()),
focused: matches!(self.state.focus, Focus::Pane),
})
});
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: pane.as_ref(),
state: &self.state,
spawn: &self.spawn,
help: &self.help,
};
let mut new_region = RegionMap::default();
terminal
.draw(|f| {
new_region = draw(f, inputs);
})
.map_err(|e| TuiError::Terminal(e.to_string()))?;
self.region = new_region;
// Resize propagation: record the active pane's rect, recompute the
// per-dimension min for every attached session, send Resize when
// changed.
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
self.emulators.resize_tracker_mut().set_pane_size(
sid,
"active",
(rect.width, rect.height),
);
}
let attached_sids: Vec<Uuid> = self.emulators.ids().collect();
for sid in attached_sids {
if let Some(size) = self.emulators.resize_tracker().session_size(sid) {
if self.last_sent_size.get(&sid).copied() != Some(size) {
if let Some(h) = self.emulators.get(sid) {
let _ = h.tx.try_send(SessionInput::Resize {
cols: size.0,
rows: size.1,
});
}
self.last_sent_size.insert(sid, size);
}
}
}
Ok(())
}
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::Action;
use crate::keymap::Mode;
use maestro_config::TuiSection;
fn make_app() -> App {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
App::new(cfg, CatalogStore::default(), "http://127.0.0.1:7070".into())
}
#[tokio::test]
async fn enter_insert_then_leave() {
let mut app = make_app();
assert_eq!(app.state.mode, Mode::Normal);
app.dispatch(Action::EnterInsert).await;
assert_eq!(app.state.mode, Mode::Insert);
app.dispatch(Action::LeaveInsert).await;
assert_eq!(app.state.mode, Mode::Normal);
}
#[tokio::test]
async fn new_tab_action_extends_tabs() {
let mut app = make_app();
app.dispatch(Action::NewTab).await;
app.dispatch(Action::NewTab).await;
assert_eq!(app.state.tabs.len(), 3);
app.dispatch(Action::CloseTab).await;
assert_eq!(app.state.tabs.len(), 2);
}
#[tokio::test]
async fn show_help_opens_overlay() {
let mut app = make_app();
app.dispatch(Action::ShowHelp).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::Help)));
}
#[tokio::test]
async fn open_spawn_modal_seeds_daemon_list() {
use maestro_protocol::{DaemonInfo, RegistryEvent};
let mut app = make_app();
let d = DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi.lan".into(),
version: "test".into(),
daemon_url: "ws://k:7070".into(),
};
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![d.clone()],
sessions: vec![],
});
app.dispatch(Action::OpenSpawnModal).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::Spawn)));
assert_eq!(app.spawn.daemons.len(), 1);
assert_eq!(app.spawn.daemons[0].daemon_id, "konishi");
}
#[tokio::test]
async fn kill_highlighted_opens_confirm() {
use chrono::Utc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
let mut app = make_app();
let sid = Uuid::from_u128(7);
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "k".into(),
version: "t".into(),
daemon_url: "ws://k".into(),
}],
sessions: vec![Session {
id: sid,
daemon_id: "k".into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
status: SessionStatus::Running,
created_at: Utc::now(),
}],
});
app.dispatch(Action::KillHighlighted).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::KillConfirm(s)) if s == sid));
}
}
+80 -9
View File
@@ -2,13 +2,11 @@
//!
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.md`
//!
//! Phase 1 (NAP-276) scaffolds the crate and a `Tui` subcommand stub. The real
//! event loop, rendering, and `/api/*` plumbing land in NAP-277 onward.
#![deny(missing_debug_implementations)]
pub mod action;
pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
@@ -24,6 +22,15 @@ pub mod state;
pub mod tabs;
pub mod terminal;
use std::time::Duration;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::app::App;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
/// `TuiArgs` plus any global flags.
#[derive(Debug, Clone)]
@@ -35,12 +42,76 @@ pub struct RunConfig {
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("not implemented")]
NotImplemented,
#[error("daemon unreachable at {0}; try `maestro daemon configure` or check `systemctl --user status maestro-daemon`")]
DaemonUnreachable(String),
#[error("config: {0}")]
Config(String),
#[error("catalog: {0}")]
Catalog(String),
#[error("terminal: {0}")]
Terminal(String),
}
/// Entry point. Phase-1 stub — returns `NotImplemented` so the subcommand
/// dispatch path is exercisable end-to-end before the renderer lands.
pub async fn run(_cfg: RunConfig) -> Result<(), TuiError> {
Err(TuiError::NotImplemented)
/// Entry point. Spawns the catalog WS loop, installs the panic hook, enters
/// the TUI alt screen, drives [`App::run`] until the user quits.
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
terminal::install_panic_hook();
let base_url = cfg.daemon_url.trim_end_matches('/').to_string();
// Health check — fail fast if no daemon is reachable.
health_check(&base_url).await?;
// Load runtime config from disk if present; fall back to defaults.
let cfg_file = match maestro_config::resolve_config_path() {
Ok(path) => maestro_config::MaestroConfigFile::load_or_default(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
let catalog = CatalogStore::default();
let ws_url = format!("{}/api/events", ws_base(&base_url));
let bg_catalog = catalog.clone();
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
// Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
// still enter the UI — the sidebar will populate when events flow.
let _ = tokio::time::timeout(Duration::from_secs(2), catalog.wait_for_initial_snapshot()).await;
let _guard = terminal::TerminalGuard::enter().map_err(|e| match e {
terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()),
})?;
let backend = CrosstermBackend::new(std::io::stdout());
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
let app = App::new(tui_cfg, catalog, base_url);
app.run(&mut terminal).await
}
async fn health_check(base_url: &str) -> Result<(), TuiError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.expect("reqwest client");
let url = format!("{base_url}/api/web/info");
for _ in 0..3 {
if let Ok(res) = client.get(&url).send().await {
if res.status().is_success() {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(TuiError::DaemonUnreachable(base_url.to_string()))
}
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()
}
}
+67
View File
@@ -52,6 +52,66 @@ impl SpawnModal {
};
}
/// Handle one keystroke while this modal owns input. Returns
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
/// the POST and dismisses the modal.
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),
SpawnField::Command => self.command.push(*c),
SpawnField::Label => self.label.push(*c),
SpawnField::Daemon => {
if !self.daemons.is_empty() {
match c {
'j' => {
self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
}
'k' => {
self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1)
% self.daemons.len();
}
_ => {}
}
}
}
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,
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 14);
f.render_widget(Clear, area);
@@ -110,6 +170,13 @@ impl SpawnModal {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult {
Consumed,
Cancel,
Submit,
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,