Ports the docs/COMMAND-BAR.md scoring algorithm (dynamic program over query/text positions, word-boundary bonuses, whole-field adjustment, four-level tie-break) to crates/maestro-tui, matching web/src/matcher.ts. Fixtures F1-F12 from the doc are ported as tests, asserting order, scores, field and ranges.
168 lines
6.2 KiB
Rust
168 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 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()))
|
|
}
|