This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/app.rs
T

661 lines
24 KiB
Rust

//! `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, DaemonClient};
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, ClickTracker, 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: DaemonClient,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("daemon", &self.daemon.base())
.field("state", &self.state)
.finish_non_exhaustive()
}
}
/// Something a keystroke decided to do that reaches outside `AppState`.
///
/// `dispatch` returns these instead of performing them, so every arm is
/// assertable without a daemon on the other end.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
Attach(Uuid),
Spawn(CreateSessionRequest),
Kill(Uuid),
Forward { id: Uuid, bytes: Vec<u8> },
}
impl App {
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
let chord_timeout = cfg.chord_timeout;
let help = HelpOverlay {
lines: render_help_lines(&cfg),
};
Self {
cfg,
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help,
chord: ChordState::new(chord_timeout),
daemon,
region: RegionMap::default(),
last_sent_size: HashMap::new(),
clicks: ClickTracker::default(),
}
}
/// 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);
};
let key = self.clicks.promote(key, std::time::Instant::now());
// Overlay intercept first.
let (decision, effects) = self.handle_overlay_input(&key);
self.perform(effects).await;
if decision == 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);
}
let effects = self.dispatch(act);
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
if let Some(effect) = self.forward_to_session(&key) {
self.perform(vec![effect]).await;
}
}
Ok(false)
}
}
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
for effect in effects {
match effect {
Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
}
}
}
}
}
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
use crate::overlays::SpawnInputResult;
match self.state.overlay().cloned() {
Some(OverlayKind::Spawn) => {
let mut effects = Vec::new();
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.dismiss_overlay();
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}));
}
}
(OverlayDecision::Consumed, effects)
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, vec![Effect::Kill(id)])
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Help) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
// A toast is informational: clear it, but let the keystroke go on
// and do its normal job rather than being swallowed.
Some(OverlayKind::Toast(_)) => {
self.state.dismiss_overlay();
(OverlayDecision::Pass, Vec::new())
}
None => (OverlayDecision::Pass, Vec::new()),
}
}
/// Fold a resolved action into `AppState` and report anything it wants done
/// outside it. Deliberately sync and side-effect-free.
fn dispatch(&mut self, act: Action) -> Vec<Effect> {
let mut effects = Vec::new();
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::SidebarNext => {
let order = visible_sidebar_order(&self.catalog.snapshot());
self.state.move_sidebar_highlight(&order, 1);
self.state.focus = Focus::Sidebar;
}
Action::SidebarPrev => {
let order = visible_sidebar_order(&self.catalog.snapshot());
self.state.move_sidebar_highlight(&order, -1);
self.state.focus = Focus::Sidebar;
}
Action::NewTab => self.state.new_tab(),
Action::CloseTab => {
if let Some(sid) = self.state.close_tab() {
self.release_session(sid);
}
}
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.help = HelpOverlay {
lines: render_help_lines(&self.cfg),
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
}
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
self.state.attach_to_focused(sid);
}
}
Action::KillHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
}
effects
}
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
if self.emulators.get(sid).is_some() {
return;
}
match self.daemon.attach_url(sid) {
Ok(ws_url) => {
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
Err(e) => self.state.toast(format!("attach failed: {e}")),
}
}
/// Tear down the emulator for `sid`, aborting its WebSocket task and
/// dropping its vt100 parser.
fn release_session(&mut self, sid: Uuid) {
self.emulators.remove(sid);
self.last_sent_size.remove(&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)
}
/// Encode a keystroke for the focused pane. `None` when there is nothing
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_tab().pane?;
self.emulators.get(sid)?;
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::BackTab => b"\x1b[Z".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::Home => b"\x1b[H".to_vec(),
InputKey::End => b"\x1b[F".to_vec(),
InputKey::Delete => b"\x1b[3~".to_vec(),
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
};
Some(Effect::Forward { id: sid, bytes })
}
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await {
Ok(_) => {
self.state.dismiss_overlay();
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
}
}
async fn submit_kill(&mut self, sid: Uuid) {
match self.daemon.kill_session(sid).await {
Ok(()) => self.release_session(sid),
Err(e) => self.state.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 {
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(())
}
}
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
/// visual sync with what the user sees.
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
let mut out = vec![format!("{label} mode"), String::new()];
for (input, action) in cfg.keymap.entries(mode) {
out.push(format!(" {input:<24} {action}"));
}
out.push(String::new());
out
}
let leader = cfg.leader.canonical();
let mut lines = vec![
format!("Leader: {leader} (Insert → Normal)"),
String::new(),
];
lines.extend(section(Mode::Normal, "Normal", cfg));
lines.extend(section(Mode::Insert, "Insert", cfg));
lines.push(String::from("(any key dismisses this overlay)"));
lines
}
#[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(),
DaemonClient::new("http://127.0.0.1:7070"),
)
}
#[test]
fn enter_insert_then_leave() {
let mut app = make_app();
assert_eq!(app.state.mode, Mode::Normal);
app.dispatch(Action::EnterInsert);
assert_eq!(app.state.mode, Mode::Insert);
app.dispatch(Action::LeaveInsert);
assert_eq!(app.state.mode, Mode::Normal);
}
#[test]
fn new_tab_action_extends_tabs() {
let mut app = make_app();
app.dispatch(Action::NewTab);
app.dispatch(Action::NewTab);
assert_eq!(app.state.tabs.len(), 3);
app.dispatch(Action::CloseTab);
assert_eq!(app.state.tabs.len(), 2);
}
#[test]
fn show_help_opens_overlay() {
let mut app = make_app();
app.dispatch(Action::ShowHelp);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
}
#[test]
fn open_spawn_modal_sets_overlay() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
}
/// Seed the catalog with one session and highlight it.
fn app_with_highlighted_session(sid: Uuid) -> App {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
}],
});
app.dispatch(Action::SidebarNext);
app
}
#[test]
fn attach_highlighted_asks_for_an_attach() {
let sid = Uuid::from_u128(11);
let mut app = app_with_highlighted_session(sid);
assert_eq!(
app.dispatch(Action::AttachHighlighted),
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().pane, Some(sid));
}
#[test]
fn confirming_a_kill_asks_for_the_kill() {
let sid = Uuid::from_u128(12);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::KillHighlighted);
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c: 'y',
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::Kill(sid)]);
assert!(app.state.overlay().is_none());
}
#[test]
fn declining_a_kill_asks_for_nothing() {
let sid = Uuid::from_u128(13);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::KillHighlighted);
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none());
}
#[test]
fn submitting_the_spawn_modal_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
match effects.as_slice() {
[Effect::Spawn(req)] => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
}
other => panic!("expected one Spawn effect, got {other:?}"),
}
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
/// failure message stayed on screen until another overlay replaced it.
#[test]
fn a_toast_is_dismissed_by_the_next_key_without_eating_it() {
let mut app = make_app();
app.state.toast("spawn failed: nope");
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c: 'j',
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(
decision,
OverlayDecision::Pass,
"the keystroke should still reach the keymap"
);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none(), "the toast must be gone");
}
#[test]
fn forwarding_is_a_no_op_without_an_attached_pane() {
let mut app = make_app();
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let mut app = make_app();
let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}],
});
app.dispatch(Action::KillHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
}