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/lib.rs
T

171 lines
6.3 KiB
Rust

//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
#![deny(missing_debug_implementations)]
pub mod action;
pub mod app;
pub mod catalog;
pub mod cli;
pub mod command_bar;
pub mod config;
pub mod daemon_link;
pub mod draft;
pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod matcher;
pub mod overlays;
pub mod palette;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod structured_pane;
pub mod structured_task;
pub mod terminal;
pub mod text_input;
use std::io::BufWriter;
use std::path::PathBuf;
use std::time::Duration;
use maestro_protocol::DaemonClient;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::app::App;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink;
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
/// `TuiArgs` plus any global flags.
#[derive(Debug, Clone)]
pub struct RunConfig {
/// Explicit `--daemon-url` / `$MAESTRO_DAEMON_URL` override. When `None`
/// the URL is resolved from the config file the same way the CLI resolves
/// it: `[cli].daemon_url`, then the local `[daemon].bind_addr`, then
/// `maestro_config::DEFAULT_DAEMON_URL`.
pub daemon_url: Option<String>,
/// Explicit `--config <path>` override from the umbrella binary's global
/// flag. When `None`, [`maestro_config::default_config_path`] picks the
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
pub config_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("daemon unreachable at {0}; 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. 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();
// Load runtime config first: it carries the daemon URL as well as the UI
// settings. Explicit `--config <path>` wins, then `$MAESTRO_CONFIG`, then
// the platform default. Missing file is fine — we fall back to shipped
// defaults.
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
});
let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
// Same resolution order the CLI uses, so a host that moved `bind_addr` off
// the default port stays reachable from the TUI without a second setting.
let base_url = cfg_file
.resolve_daemon_url(cfg.daemon_url.clone())
.trim_end_matches('/')
.to_string();
// Health check — fail fast if no daemon is reachable.
health_check(&DaemonClient::with_timeouts(
&base_url,
HEALTH_TIMEOUT,
HEALTH_TIMEOUT,
))
.await?;
let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT);
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
palette::set_active(tui_cfg.palette);
let catalog = CatalogStore::default();
let ws_url = daemon
.events_url()
.map_err(|e| TuiError::Config(e.to_string()))?;
// The link watches the same daemon over `/api/info`; the events socket
// reports its own connects and disconnects into it (`docs/RESTART.md`).
let link = DaemonLink::default();
let bg_catalog = catalog.clone();
let ws_link = link.clone();
tokio::spawn(async move { bg_catalog.run_events_loop_reporting(ws_url, ws_link).await });
let probe_link = link.clone();
let probe_daemon = daemon.clone();
tokio::spawn(async move { probe_link.run_probe_loop(probe_daemon).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()),
})?;
// Buffered: ratatui issues one `write!` per cell run — thousands per
// frame — and an unbuffered `Stdout` takes the global stdout lock on every
// one of them. A single BufWriter collapses a frame into one flush.
let backend = CrosstermBackend::new(BufWriter::with_capacity(
FRAME_BUFFER_BYTES,
std::io::stdout(),
));
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
let app = App::new(tui_cfg, catalog, daemon, link);
app.run(&mut terminal).await
}
/// One full 200x50 repaint is ~5 KB of escape sequences; 64 KB leaves room for
/// a large terminal without ever flushing mid-frame.
const FRAME_BUFFER_BYTES: usize = 64 * 1024;
/// Startup probe budget, applied to connect *and* the whole request: the user is
/// staring at a terminal, so an unreachable daemon must fail in under a second.
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
/// Budget for the TUI's own calls once it is up. Tighter than the client
/// default — a spawn or kill that stalls should surface as a toast, not hang
/// the interface.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn health_check(daemon: &DaemonClient) -> Result<(), TuiError> {
for _ in 0..3 {
if let Ok(info) = daemon.daemon_info().await {
if !maestro_protocol::is_compatible(&info.version) {
tracing::warn!(
daemon = %info.version,
client = %maestro_protocol::PROTOCOL_VERSION,
"daemon protocol version differs from this client's",
);
}
return Ok(());
}
tokio::time::sleep(HEALTH_TIMEOUT).await;
}
Err(TuiError::DaemonUnreachable(daemon.base().to_string()))
}