Opens from Normal and Insert mode as an ordinary keymap entry, so it is rebindable and the help overlay picks it up from the live keymap. An empty query lists sessions most recently focused first, matching the web bar's `orderSessionsForBar`; typing ranks through `matcher.rs` with the matched ranges highlighted, grouped under kind headers. Enter attaches through the same path the sidebar's attach uses, Escape closes and leaves focus and mode where they were.
169 lines
6.2 KiB
Rust
169 lines
6.2 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 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()))
|
|
}
|