From 29545d15fe94ce41c2dbc2b5fc7ff4eda9af2889 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:52:44 +0100 Subject: [PATCH] feat(maestro): daemon status indicator and restart overlay in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar gains the TUI's half of the same contract the web client got: a coloured dot plus the release the daemon is running, pinned right so a narrow terminal truncates the key hints rather than the release. `DaemonLink` mirrors web/src/daemonLink.ts — the events loop reports its connects and disconnects, a probe task polls `GET /api/info` while the daemon is unreachable, and past the grace window a modal covers the frame and locks the keyboard (quit excepted) until it answers. A daemon that comes back on a new commit cannot be reloaded into a running TUI binary, so the status bar says `updated: (restart tui)` instead. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 + crates/maestro-daemon/build.rs | 4 +- crates/maestro-tui/src/app.rs | 41 ++- crates/maestro-tui/src/catalog.rs | 14 ++ crates/maestro-tui/src/daemon_link.rs | 343 ++++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 13 +- crates/maestro-tui/src/overlays.rs | 38 ++- crates/maestro-tui/src/render.rs | 179 +++++++++++++- docs/RESTART.md | 22 +- 9 files changed, 640 insertions(+), 19 deletions(-) create mode 100644 crates/maestro-tui/src/daemon_link.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8579633..65a3e10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,11 @@ before changing anything that creates or removes one. sidebars — line layout, activity colours, duration format, state precedence. Read it before changing sidebar rendering. +`docs/RESTART.md` is the contract for what clients do when the daemon restarts +or is upgraded under them — the build stamp on `/api/info`, the status +indicator's states, the blocking overlay, grace and poll timings. Read it +before changing reconnect behaviour on either client. + `nix run .#dev` (or `just dev`) brings up the whole local stack under process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only diff --git a/crates/maestro-daemon/build.rs b/crates/maestro-daemon/build.rs index bdb1b66..6e7ba4f 100644 --- a/crates/maestro-daemon/build.rs +++ b/crates/maestro-daemon/build.rs @@ -32,7 +32,9 @@ fn main() { let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]); let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") { Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), - Err(_) => !git(&["status", "--porcelain"]).unwrap_or_default().is_empty(), + Err(_) => !git(&["status", "--porcelain"]) + .unwrap_or_default() + .is_empty(), }; println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}"); diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22d88d4..a47867e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -26,6 +26,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; use crate::config::TuiConfig; +use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; use crate::emulators::SessionEmulators; use crate::input::{ @@ -58,6 +59,9 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, + /// Liveness + release of the daemon (`docs/RESTART.md`): drives the status + /// bar's indicator, and locks the keyboard behind the restart overlay. + link: DaemonLink, /// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in /// `AppState` so the overlay enum stays comparable — the same reason the /// rename buffer lives here. @@ -127,7 +131,12 @@ pub enum Effect { } impl App { - pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self { + pub fn new( + cfg: TuiConfig, + catalog: CatalogStore, + daemon: DaemonClient, + link: DaemonLink, + ) -> Self { let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel(); let chord_timeout = cfg.chord_timeout; let help = HelpOverlay { @@ -148,6 +157,7 @@ impl App { help, chord: ChordState::new(chord_timeout), daemon, + link, http, rename_input: TextInput::default(), draft_tx, @@ -182,6 +192,10 @@ impl App { _ = self.catalog.changed() => { // Render on next loop iteration. } + _ = self.link.changed() => { + // The status indicator and the restart overlay both read + // the link; redraw on the next loop iteration. + } _ = tokio::time::sleep(Duration::from_millis(33)) => { let effects = self.poll_due_probes_at(std::time::Instant::now()); self.perform(effects).await; @@ -198,6 +212,21 @@ impl App { }; let key = self.clicks.promote(key, std::time::Instant::now()); + // The daemon is gone: everything a key could do goes through it, so + // only the quit binding still resolves until it answers again. + if self.link.is_blocking() { + let known = self.cfg.keymap.known_bindings(self.state.mode); + if let ChordResult::Resolved(canon) = self.chord.feed(&key, &known) { + if matches!( + self.cfg.keymap.resolve(self.state.mode, &canon), + Some(Action::Quit) + ) { + return Ok(true); + } + } + return Ok(false); + } + // Overlay intercept first. let (decision, effects) = self.handle_overlay_input(&key); self.perform(effects).await; @@ -919,6 +948,7 @@ impl App { None => "session".into(), }; + let link_status = self.link.status(); let inputs = RenderInputs { sidebar: &sidebar, home_dir: &self.home_dir, @@ -928,6 +958,7 @@ impl App { draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, + link: &link_status, }; let mut new_region = RegionMap::default(); @@ -1176,7 +1207,12 @@ mod tests { fn make_app_at(base: &str) -> App { let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); - App::new(cfg, CatalogStore::default(), DaemonClient::new(base)) + App::new( + cfg, + CatalogStore::default(), + DaemonClient::new(base), + DaemonLink::default(), + ) } #[test] @@ -1772,6 +1808,7 @@ mod tests { Duration::from_millis(200), Duration::from_millis(500), ), + DaemonLink::default(), ); app.dispatch(Action::OpenSpawnModal); let req = CreateSessionRequest { diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index e3466f0..2de8298 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -127,11 +127,24 @@ impl CatalogStore { /// apply them. Reconnects with exponential backoff on disconnect. Never /// returns — spawn it on a Tokio task. pub async fn run_events_loop(self, ws_url: String) { + self.run_events_loop_reporting(ws_url, crate::daemon_link::DaemonLink::default()) + .await + } + + /// [`Self::run_events_loop`], reporting every connect and disconnect to + /// `link` — the socket dying is the first sign the daemon went down or was + /// upgraded under the client (`docs/RESTART.md`). + pub async fn run_events_loop_reporting( + self, + ws_url: String, + link: crate::daemon_link::DaemonLink, + ) { let mut backoff = Duration::from_secs(1); loop { match tokio_tungstenite::connect_async(&ws_url).await { Ok((ws, _resp)) => { backoff = Duration::from_secs(1); + link.note_connected(); let (_sink, mut stream) = ws.split(); while let Some(frame) = stream.next().await { match frame { @@ -152,6 +165,7 @@ impl CatalogStore { tracing::debug!(error = ?e, "catalog: connect failed"); } } + link.note_disconnected(std::time::Instant::now()); tracing::debug!(?backoff, "catalog: sleeping before reconnect"); tokio::time::sleep(jitter(backoff)).await; backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30)); diff --git a/crates/maestro-tui/src/daemon_link.rs b/crates/maestro-tui/src/daemon_link.rs new file mode 100644 index 0000000..ab18418 --- /dev/null +++ b/crates/maestro-tui/src/daemon_link.rs @@ -0,0 +1,343 @@ +//! Liveness and identity of the daemon behind this TUI. +//! +//! The status bar renders [`LinkStatus`], and [`LinkState::Down`] puts the +//! restart overlay up and locks the keyboard. `docs/RESTART.md` is the +//! contract; this is the TUI half of what `web/src/daemonLink.ts` does. + +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use maestro_protocol::{BuildInfo, DaemonClient, DaemonInfo}; +use tokio::sync::Notify; + +/// How long a lost connection stays quiet before the overlay blocks the UI. +pub const GRACE: Duration = Duration::from_millis(1200); +/// Cadence of `GET /api/info` while the daemon is unreachable. +pub const POLL: Duration = Duration::from_millis(700); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkState { + /// No probe has landed yet. + Connecting, + /// The daemon answered. + Online, + /// The events socket dropped, still inside the grace window. + Reconnecting, + /// Unreachable past the grace window: the overlay is up. + Down, +} + +/// What happened between two `/api/info` replies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartKind { + Same, + Restarted, + Upgraded, +} + +/// Compare two daemon replies. The commit decides first — a different one is +/// new code. `instance` catches a restart of the same build, and is the only +/// signal for a daemon built outside a git checkout (empty commit), which must +/// never be read as an upgrade. +pub fn compare_identity(before: &DaemonInfo, after: &DaemonInfo) -> RestartKind { + let a = before.build.commit.as_str(); + let b = after.build.commit.as_str(); + if !a.is_empty() && !b.is_empty() && a != b { + return RestartKind::Upgraded; + } + if !before.instance.is_empty() + && !after.instance.is_empty() + && before.instance != after.instance + { + return RestartKind::Restarted; + } + RestartKind::Same +} + +/// One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the +/// build carried uncommitted changes. +pub fn format_release(build: &BuildInfo) -> String { + if build.commit.is_empty() { + return "unknown release".to_string(); + } + let mut out = build.commit.clone(); + if build.commit_date.len() >= 10 { + out.push_str(" · "); + out.push_str(&build.commit_date[..10]); + } + if build.dirty { + out.push_str(" (dirty)"); + } + out +} + +/// Snapshot of the link, taken once per frame by the renderer. +#[derive(Debug, Clone)] +pub struct LinkStatus { + pub state: LinkState, + /// The release the daemon last reported. `None` until the first probe. + pub build: Option, + /// Failed probes in the current outage — the overlay counts them so a long + /// restart doesn't read as a hang. + pub attempts: u32, + /// Set once the daemon comes back on a different commit. The TUI is its own + /// binary, so it keeps running against the new daemon and shows this until + /// the user restarts it onto the new client. + pub upgraded_to: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct DaemonLink { + inner: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + state: RwLock, + notify: Notify, +} + +#[derive(Debug)] +struct State { + state: LinkState, + identity: Option, + attempts: u32, + /// When the events socket dropped, for the grace window. + down_since: Option, + upgraded_to: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + state: LinkState::Connecting, + identity: None, + attempts: 0, + down_since: None, + upgraded_to: None, + } + } +} + +impl DaemonLink { + pub fn status(&self) -> LinkStatus { + let s = self.read(); + LinkStatus { + state: s.state, + build: s.identity.as_ref().map(|i| i.build.clone()), + attempts: s.attempts, + upgraded_to: s.upgraded_to.clone(), + } + } + + /// Whether the UI is locked behind the restart overlay. + pub fn is_blocking(&self) -> bool { + self.read().state == LinkState::Down + } + + /// Wait until anything about the link changes, so the render loop redraws. + pub async fn changed(&self) { + self.inner.notify.notified().await; + } + + /// The events socket dropped. Starts the grace window. + pub fn note_disconnected(&self, now: Instant) { + { + let mut s = self.write(); + if s.state == LinkState::Down { + return; + } + s.state = LinkState::Reconnecting; + s.attempts = 0; + s.down_since = Some(now); + } + self.wake(); + } + + /// The events socket came back. Does not clear the state on its own: the + /// socket carries no identity, and only a probe can say which daemon + /// answered. + pub fn note_connected(&self) { + self.wake(); + } + + /// Fold in a successful `/api/info`. A reply on a new commit is recorded as + /// an upgrade; either way the link is back online. + pub fn apply_info(&self, info: DaemonInfo) { + { + let mut s = self.write(); + if let Some(before) = &s.identity { + if compare_identity(before, &info) == RestartKind::Upgraded { + s.upgraded_to = Some(info.build.clone()); + } + } + s.identity = Some(info); + s.state = LinkState::Online; + s.attempts = 0; + s.down_since = None; + } + self.wake(); + } + + /// Fold in a failed probe. The overlay goes up once the grace window since + /// the disconnect has passed. + pub fn probe_failed(&self, now: Instant) { + { + let mut s = self.write(); + s.attempts = s.attempts.saturating_add(1); + let since = *s.down_since.get_or_insert(now); + if now.duration_since(since) >= GRACE { + s.state = LinkState::Down; + } else if s.state == LinkState::Online { + s.state = LinkState::Reconnecting; + } + } + self.wake(); + } + + /// Long-running task: keep [`LinkStatus`] true. Probes on a flat cadence + /// while the daemon is unreachable and idles while it is up. Never returns. + pub async fn run_probe_loop(self, daemon: DaemonClient) { + loop { + if self.read().state == LinkState::Online { + // A tick bounds the wait so a disconnect racing the notify + // costs a second, not the whole outage. + let _ = tokio::time::timeout(Duration::from_secs(1), self.changed()).await; + continue; + } + match daemon.daemon_info().await { + Ok(info) => self.apply_info(info), + Err(_) => { + self.probe_failed(Instant::now()); + tokio::time::sleep(POLL).await; + } + } + } + } + + fn wake(&self) { + self.inner.notify.notify_waiters(); + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, State> { + self.inner + .state + .read() + .expect("daemon link rwlock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> { + self.inner + .state + .write() + .expect("daemon link rwlock poisoned") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(commit: &str, instance: &str) -> DaemonInfo { + DaemonInfo { + hostname: "konishi".into(), + version: "0.1.0".into(), + home_dir: "/home/naps".into(), + presets: Vec::new(), + build: BuildInfo { + commit: commit.into(), + commit_date: "2026-09-02T14:03:11Z".into(), + dirty: false, + }, + instance: instance.into(), + } + } + + #[test] + fn identity_tells_upgrade_from_restart() { + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("bbb", "i2")), + RestartKind::Upgraded + ); + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("aaa", "i2")), + RestartKind::Restarted + ); + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("aaa", "i1")), + RestartKind::Same + ); + // No build stamp: the instance still reports the restart, and nothing + // ever claims a phantom upgrade. + assert_eq!( + compare_identity(&info("", "i1"), &info("", "i2")), + RestartKind::Restarted + ); + } + + #[test] + fn release_line_pairs_hash_with_date() { + assert_eq!( + format_release(&info("f89be4a716c7", "i1").build), + "f89be4a716c7 · 2026-09-02" + ); + assert_eq!( + format_release(&BuildInfo { + commit: "abc".into(), + commit_date: String::new(), + dirty: true, + }), + "abc (dirty)" + ); + assert_eq!(format_release(&BuildInfo::default()), "unknown release"); + } + + #[test] + fn grace_window_holds_the_overlay_back() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + assert_eq!(link.status().state, LinkState::Online); + + let t0 = Instant::now(); + link.note_disconnected(t0); + assert_eq!(link.status().state, LinkState::Reconnecting); + assert!(!link.is_blocking()); + + link.probe_failed(t0 + Duration::from_millis(400)); + assert_eq!(link.status().state, LinkState::Reconnecting); + + link.probe_failed(t0 + GRACE); + assert_eq!(link.status().state, LinkState::Down); + assert!(link.is_blocking()); + assert_eq!(link.status().attempts, 2); + } + + #[test] + fn a_plain_restart_unlocks_without_an_upgrade_note() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + let t0 = Instant::now(); + link.note_disconnected(t0); + link.probe_failed(t0 + GRACE); + assert!(link.is_blocking()); + + link.apply_info(info("aaa", "i2")); + assert_eq!(link.status().state, LinkState::Online); + assert!(link.status().upgraded_to.is_none()); + } + + #[test] + fn an_upgrade_unlocks_and_names_the_new_release() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + let t0 = Instant::now(); + link.note_disconnected(t0); + link.probe_failed(t0 + GRACE); + + link.apply_info(info("bbb", "i2")); + let status = link.status(); + assert_eq!(status.state, LinkState::Online); + assert_eq!(status.upgraded_to.unwrap().commit, "bbb"); + assert!(!link.is_blocking()); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 9b1ccca..66236b0 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,6 +7,7 @@ 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; @@ -33,6 +34,7 @@ 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. @@ -94,8 +96,15 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { 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(); - tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await }); + 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. @@ -107,7 +116,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { 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, daemon); + let app = App::new(tui_cfg, catalog, daemon, link); app.run(&mut terminal).await } diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index a3140f1..776ddc3 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,5 +1,5 @@ //! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help -//! overlay, transient toast. +//! overlay, restart overlay, transient toast. //! //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. @@ -33,6 +33,42 @@ impl SpawnModal { } } +/// Shown while the daemon is unreachable past the grace window, over +/// everything else, with the keyboard locked (`docs/RESTART.md`). Nothing to +/// dismiss: it clears itself when a probe succeeds. +#[derive(Debug, Clone)] +pub struct RestartOverlay { + /// Failed probes so far, so a long restart doesn't read as a hang. + pub attempts: u32, + /// The release the daemon was running before it went away. + pub release: String, +} + +impl RestartOverlay { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = centered(parent, 54, 7); + f.render_widget(Clear, area); + let block = Block::default() + .title(" daemon restarting ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let attempts = if self.attempts <= 1 { + String::new() + } else { + format!(" ({} attempts)", self.attempts) + }; + let lines = vec![ + Line::from("Waiting for the daemon to come back…"), + Line::from(format!("Polling /api/info{attempts}")), + Line::from(""), + Line::from(format!("was: {}", self.release)), + Line::from("Sessions are held by tmux and survive the restart."), + ]; + f.render_widget(Paragraph::new(lines), inner); + } +} + #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 171b19d..db547f9 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -3,14 +3,17 @@ //! hit-test mouse clicks. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::Modifier; +use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::Frame; +use maestro_protocol::ActivityState; + +use crate::daemon_link::{format_release, LinkState, LinkStatus}; use crate::input::Region; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, RestartOverlay, SpawnModal, Toast}; use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; @@ -83,6 +86,9 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Edit buffer behind the rename prompt (#125). pub rename_input: &'a crate::text_input::TextInput, + /// Liveness + release of the daemon, for the status bar's indicator and the + /// restart overlay (`docs/RESTART.md`). + pub link: &'a crate::daemon_link::LinkStatus, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -145,7 +151,7 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { // Status bar — always visible, never eclipsed by overlays so the user can // always see the current mode + key hints. - render_status_bar(f, status_area, inputs.state); + render_status_bar(f, status_area, inputs.state, inputs.link); match inputs.state.overlay() { Some(OverlayKind::Spawn) => { @@ -203,6 +209,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { None => {} } + // Last, so it covers whatever else is up: while the daemon is gone none of + // it can act (`docs/RESTART.md`). + if inputs.link.state == LinkState::Down { + RestartOverlay { + attempts: inputs.link.attempts, + release: inputs + .link + .build + .as_ref() + .map(format_release) + .unwrap_or_else(|| "unknown release".to_string()), + } + .render(f, area); + map.modal = Some(area); + } + map } @@ -220,7 +242,16 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) { /// Render the always-visible status bar at the bottom of the frame. Surfaces /// mode + focused panel + the most useful key hints so the user is never /// guessing what state they're in. -fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { +fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) { + // The daemon indicator is pinned right and sized to its own text, so a + // narrow terminal truncates the key hints rather than the release. + let label = link_label(link); + let indicator_w = (label.chars().count() as u16).saturating_add(3); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(indicator_w)]) + .split(area); + let mode_label = match state.mode { Mode::Normal => "[NORMAL]", Mode::Insert => "[INSERT]", @@ -238,7 +269,42 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { Span::styled(format!(" {mode_label} "), mode_style), Span::raw(format!(" {focus_label} • {hint}")), ]); - f.render_widget(Paragraph::new(line), area); + f.render_widget(Paragraph::new(line), cols[0]); + + let indicator = ratatui::text::Line::from(vec![ + Span::styled(" ●", Style::default().fg(link_colour(link.state))), + Span::raw(format!(" {label} ")), + ]); + f.render_widget(Paragraph::new(indicator), cols[1]); +} + +/// The status dot's colour: the same three the sidebar cards use for session +/// activity, so "green is fine, red is broken" reads the same everywhere. +fn link_colour(state: LinkState) -> ratatui::style::Color { + let card = palette::active().card; + match state { + LinkState::Online => card.activity(ActivityState::Active), + LinkState::Connecting | LinkState::Reconnecting => { + card.activity(ActivityState::AwaitingInput) + } + LinkState::Down => card.activity(ActivityState::Error), + } +} + +/// What sits next to the dot. The browser shows the release on hover; a +/// terminal has no hover, so it is always on the line — replaced by the state +/// while the daemon is not answering, which is the more urgent fact. +fn link_label(link: &LinkStatus) -> String { + match link.state { + LinkState::Connecting => "connecting…".to_string(), + LinkState::Reconnecting => "reconnecting…".to_string(), + LinkState::Down => "daemon unreachable".to_string(), + LinkState::Online => match (&link.upgraded_to, &link.build) { + (Some(build), _) => format!("updated: {} (restart tui)", format_release(build)), + (None, Some(build)) => format_release(build), + (None, None) => "unknown release".to_string(), + }, + } } #[cfg(test)] @@ -246,6 +312,16 @@ mod tests { use super::*; use crate::input::Region; + /// A link that answered — the normal case every other render test assumes. + fn online_link() -> LinkStatus { + LinkStatus { + state: LinkState::Online, + build: None, + attempts: 0, + upgraded_to: None, + } + } + #[test] fn region_map_resolves_known_areas() { let map = RegionMap { @@ -331,6 +407,96 @@ mod tests { ); } + fn frame_text(link: &LinkStatus) -> String { + use crate::overlays::HelpOverlay; + use std::collections::HashSet; + + let sidebar = Sidebar { + sessions: Vec::new(), + highlight: None, + attached: HashSet::new(), + }; + let help = HelpOverlay { lines: Vec::new() }; + let state = AppState::new(); + let inputs = RenderInputs { + sidebar: &sidebar, + home_dir: "", + pane_title: "session", + pane: None, + draft: None, + state: &state, + help: &help, + rename_input: &crate::text_input::TextInput::default(), + link, + }; + let backend = ratatui::backend::TestBackend::new(120, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + buf.content() + .iter() + .map(|c| c.symbol()) + .collect::>() + .join("") + } + + #[test] + fn the_status_bar_names_the_release_the_daemon_is_running() { + let text = frame_text(&LinkStatus { + state: LinkState::Online, + build: Some(maestro_protocol::BuildInfo { + commit: "f89be4a716c7".into(), + commit_date: "2026-09-02T14:03:11Z".into(), + dirty: false, + }), + attempts: 0, + upgraded_to: None, + }); + assert!(text.contains("f89be4a716c7"), "{text}"); + assert!(text.contains("2026-09-02"), "{text}"); + } + + #[test] + fn an_unreachable_daemon_covers_the_frame_with_the_restart_overlay() { + let up = frame_text(&LinkStatus { + state: LinkState::Online, + build: None, + attempts: 0, + upgraded_to: None, + }); + assert!(!up.contains("daemon restarting")); + + let down = frame_text(&LinkStatus { + state: LinkState::Down, + build: None, + attempts: 4, + upgraded_to: None, + }); + assert!(down.contains("daemon restarting"), "{down}"); + assert!(down.contains("(4 attempts)"), "{down}"); + assert!(down.contains("daemon unreachable"), "{down}"); + } + + #[test] + fn an_upgraded_daemon_tells_the_user_to_restart_the_tui() { + let text = frame_text(&LinkStatus { + state: LinkState::Online, + build: Some(maestro_protocol::BuildInfo::default()), + attempts: 0, + upgraded_to: Some(maestro_protocol::BuildInfo { + commit: "bbb111222333".into(), + commit_date: "2026-09-03T09:00:00Z".into(), + dirty: false, + }), + }); + assert!(text.contains("updated: bbb111222333"), "{text}"); + assert!(text.contains("restart tui"), "{text}"); + } + /// The spawn form is a modal: the frame shows it over whatever the pane /// was displaying, framed by its own border. #[test] @@ -356,6 +522,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -408,6 +575,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -447,6 +615,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/docs/RESTART.md b/docs/RESTART.md index 4441951..e6f7286 100644 --- a/docs/RESTART.md +++ b/docs/RESTART.md @@ -49,18 +49,24 @@ The **status indicator** lives in the sidebar footer, left of the spawn button, in both clients. Its colour is the link state, and it names the running release — commit hash and commit date — on hover (web) or in the status bar (TUI). -| state | colour | meaning | -| -------------- | ------ | -------------------------------------------------- | -| `connecting` | amber | first probe hasn't landed | +| state | colour | meaning | +| -------------- | ------ | ---------------------------------------------------- | +| `connecting` | amber | first probe hasn't landed | | `online` | green | daemon answered; same process, or a resolved new one | -| `reconnecting` | amber | socket dropped, still inside the grace window | -| `down` | red | unreachable past the grace window — the UI blocks | -| `updating` | red | answered on a new commit; the page is reloading | +| `reconnecting` | amber | socket dropped, still inside the grace window | +| `down` | red | unreachable past the grace window — the UI blocks | +| `updating` | red | answered on a new commit; the page is reloading | + +`updating` is web-only, because reloading is web-only. The TUI goes straight +back to `online` after an upgrade and puts `updated: (restart tui)` on +its status bar instead, until the user restarts it. The **restart overlay** is up for exactly `down` and `updating`. It is not dismissable and it swallows the app's keyboard shortcuts: everything behind it -would act on a daemon that is not there. It clears itself the moment a probe -succeeds — there is no "retry" button, because the client is already retrying. +would act on a daemon that is not there. In the TUI the quit binding still +resolves, so a blocked client is never a trapped one. It clears itself the +moment a probe succeeds — there is no "retry" button, because the client is +already retrying. ## Timing