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:
Generated
+1
@@ -1263,6 +1263,7 @@ dependencies = [
|
|||||||
"maestro-config",
|
"maestro-config",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ futures-util.workspace = true
|
|||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol.workspace = true
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
|
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
|
||||||
//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.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)]
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
|
pub mod app;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -24,6 +22,15 @@ pub mod state;
|
|||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod terminal;
|
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
|
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
||||||
/// `TuiArgs` plus any global flags.
|
/// `TuiArgs` plus any global flags.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -35,12 +42,76 @@ pub struct RunConfig {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum TuiError {
|
pub enum TuiError {
|
||||||
#[error("not implemented")]
|
#[error("daemon unreachable at {0}; try `maestro daemon configure` or check `systemctl --user status maestro-daemon`")]
|
||||||
NotImplemented,
|
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
|
/// Entry point. Spawns the catalog WS loop, installs the panic hook, enters
|
||||||
/// dispatch path is exercisable end-to-end before the renderer lands.
|
/// the TUI alt screen, drives [`App::run`] until the user quits.
|
||||||
pub async fn run(_cfg: RunConfig) -> Result<(), TuiError> {
|
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
||||||
Err(TuiError::NotImplemented)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||||
let area = centered(parent, 60, 14);
|
let area = centered(parent, 60, 14);
|
||||||
f.render_widget(Clear, area);
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user