diff --git a/Cargo.lock b/Cargo.lock index 7858623..6235e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,7 @@ dependencies = [ "maestro-config", "maestro-protocol", "ratatui", + "reqwest", "serde_json", "thiserror 1.0.69", "tokio", diff --git a/crates/maestro-tui/Cargo.toml b/crates/maestro-tui/Cargo.toml index 7bd1dd6..c6f6fa8 100644 --- a/crates/maestro-tui/Cargo.toml +++ b/crates/maestro-tui/Cargo.toml @@ -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"] } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs new file mode 100644 index 0000000..22dde8d --- /dev/null +++ b/crates/maestro-tui/src/app.rs @@ -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, + 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>, + ) -> 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 { + 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 { + 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>, + ) -> Result<(), TuiError> { + let snap = self.catalog.snapshot(); + let attached: 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_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 = 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)); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index a9d19a2..4e8cf40 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -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() + } } diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 7c4e400..8bcb20d 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -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,