feat(maestro): daemon status indicator and restart overlay in the TUI
ci / flake (push) Successful in 9m3s
ci / web (push) Successful in 1m22s
ci / rust (push) Successful in 4m25s

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: <hash> (restart tui)` instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-02 15:52:44 +01:00
co-authored by Claude Opus 5
parent 4d7e368d31
commit 29545d15fe
9 changed files with 640 additions and 19 deletions
+5
View File
@@ -71,6 +71,11 @@ before changing anything that creates or removes one.
sidebars — line layout, activity colours, duration format, state precedence. sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering. 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 `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 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 daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+3 -1
View File
@@ -32,7 +32,9 @@ fn main() {
let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]); let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]);
let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") { let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") {
Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), 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}"); println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}");
+39 -2
View File
@@ -26,6 +26,7 @@ use uuid::Uuid;
use crate::action::Action; use crate::action::Action;
use crate::catalog::CatalogStore; use crate::catalog::CatalogStore;
use crate::config::TuiConfig; use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink;
use crate::draft::{DraftInputResult, DraftPane}; use crate::draft::{DraftInputResult, DraftPane};
use crate::emulators::SessionEmulators; use crate::emulators::SessionEmulators;
use crate::input::{ use crate::input::{
@@ -58,6 +59,9 @@ pub struct App {
help: HelpOverlay, help: HelpOverlay,
chord: ChordState, chord: ChordState,
daemon: DaemonClient, 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 /// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
/// `AppState` so the overlay enum stays comparable — the same reason the /// `AppState` so the overlay enum stays comparable — the same reason the
/// rename buffer lives here. /// rename buffer lives here.
@@ -127,7 +131,12 @@ pub enum Effect {
} }
impl App { 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 (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
let chord_timeout = cfg.chord_timeout; let chord_timeout = cfg.chord_timeout;
let help = HelpOverlay { let help = HelpOverlay {
@@ -148,6 +157,7 @@ impl App {
help, help,
chord: ChordState::new(chord_timeout), chord: ChordState::new(chord_timeout),
daemon, daemon,
link,
http, http,
rename_input: TextInput::default(), rename_input: TextInput::default(),
draft_tx, draft_tx,
@@ -182,6 +192,10 @@ impl App {
_ = self.catalog.changed() => { _ = self.catalog.changed() => {
// Render on next loop iteration. // 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)) => { _ = tokio::time::sleep(Duration::from_millis(33)) => {
let effects = self.poll_due_probes_at(std::time::Instant::now()); let effects = self.poll_due_probes_at(std::time::Instant::now());
self.perform(effects).await; self.perform(effects).await;
@@ -198,6 +212,21 @@ impl App {
}; };
let key = self.clicks.promote(key, std::time::Instant::now()); 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. // Overlay intercept first.
let (decision, effects) = self.handle_overlay_input(&key); let (decision, effects) = self.handle_overlay_input(&key);
self.perform(effects).await; self.perform(effects).await;
@@ -919,6 +948,7 @@ impl App {
None => "session".into(), None => "session".into(),
}; };
let link_status = self.link.status();
let inputs = RenderInputs { let inputs = RenderInputs {
sidebar: &sidebar, sidebar: &sidebar,
home_dir: &self.home_dir, home_dir: &self.home_dir,
@@ -928,6 +958,7 @@ impl App {
draft: self.draft.as_ref(), draft: self.draft.as_ref(),
help: &self.help, help: &self.help,
rename_input: &self.rename_input, rename_input: &self.rename_input,
link: &link_status,
}; };
let mut new_region = RegionMap::default(); let mut new_region = RegionMap::default();
@@ -1176,7 +1207,12 @@ mod tests {
fn make_app_at(base: &str) -> App { fn make_app_at(base: &str) -> App {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); 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] #[test]
@@ -1772,6 +1808,7 @@ mod tests {
Duration::from_millis(200), Duration::from_millis(200),
Duration::from_millis(500), Duration::from_millis(500),
), ),
DaemonLink::default(),
); );
app.dispatch(Action::OpenSpawnModal); app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest { let req = CreateSessionRequest {
+14
View File
@@ -127,11 +127,24 @@ impl CatalogStore {
/// apply them. Reconnects with exponential backoff on disconnect. Never /// apply them. Reconnects with exponential backoff on disconnect. Never
/// returns — spawn it on a Tokio task. /// returns — spawn it on a Tokio task.
pub async fn run_events_loop(self, ws_url: String) { 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); let mut backoff = Duration::from_secs(1);
loop { loop {
match tokio_tungstenite::connect_async(&ws_url).await { match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws, _resp)) => { Ok((ws, _resp)) => {
backoff = Duration::from_secs(1); backoff = Duration::from_secs(1);
link.note_connected();
let (_sink, mut stream) = ws.split(); let (_sink, mut stream) = ws.split();
while let Some(frame) = stream.next().await { while let Some(frame) = stream.next().await {
match frame { match frame {
@@ -152,6 +165,7 @@ impl CatalogStore {
tracing::debug!(error = ?e, "catalog: connect failed"); tracing::debug!(error = ?e, "catalog: connect failed");
} }
} }
link.note_disconnected(std::time::Instant::now());
tracing::debug!(?backoff, "catalog: sleeping before reconnect"); tracing::debug!(?backoff, "catalog: sleeping before reconnect");
tokio::time::sleep(jitter(backoff)).await; tokio::time::sleep(jitter(backoff)).await;
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30)); backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
+343
View File
@@ -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<BuildInfo>,
/// 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<BuildInfo>,
}
#[derive(Debug, Default, Clone)]
pub struct DaemonLink {
inner: Arc<Inner>,
}
#[derive(Debug, Default)]
struct Inner {
state: RwLock<State>,
notify: Notify,
}
#[derive(Debug)]
struct State {
state: LinkState,
identity: Option<DaemonInfo>,
attempts: u32,
/// When the events socket dropped, for the grace window.
down_since: Option<Instant>,
upgraded_to: Option<BuildInfo>,
}
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());
}
}
+11 -2
View File
@@ -7,6 +7,7 @@ pub mod app;
pub mod catalog; pub mod catalog;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod daemon_link;
pub mod draft; pub mod draft;
pub mod emulators; pub mod emulators;
pub mod fast_forward; pub mod fast_forward;
@@ -33,6 +34,7 @@ use ratatui::Terminal;
use crate::app::App; use crate::app::App;
use crate::catalog::CatalogStore; use crate::catalog::CatalogStore;
use crate::config::TuiConfig; use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink;
/// Inputs for [`run`]. Built by the umbrella binary from the parsed /// Inputs for [`run`]. Built by the umbrella binary from the parsed
/// `TuiArgs` plus any global flags. /// `TuiArgs` plus any global flags.
@@ -94,8 +96,15 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
let ws_url = daemon let ws_url = daemon
.events_url() .events_url()
.map_err(|e| TuiError::Config(e.to_string()))?; .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 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 // Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
// still enter the UI — the sidebar will populate when events flow. // 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 backend = CrosstermBackend::new(std::io::stdout());
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?; 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 app.run(&mut terminal).await
} }
+37 -1
View File
@@ -1,5 +1,5 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help //! 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 //! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing. //! `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)] #[derive(Debug, Clone)]
pub struct KillConfirm { pub struct KillConfirm {
pub label: String, pub label: String,
+174 -5
View File
@@ -3,14 +3,17 @@
//! hit-test mouse clicks. //! hit-test mouse clicks.
use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier; use ratatui::style::{Modifier, Style};
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::Frame; use ratatui::Frame;
use maestro_protocol::ActivityState;
use crate::daemon_link::{format_release, LinkState, LinkStatus};
use crate::input::Region; use crate::input::Region;
use crate::keymap::Mode; 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::palette;
use crate::pane::Pane; use crate::pane::Pane;
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
@@ -83,6 +86,9 @@ pub struct RenderInputs<'a> {
pub help: &'a HelpOverlay, pub help: &'a HelpOverlay,
/// Edit buffer behind the rename prompt (#125). /// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput, 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 /// 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 // Status bar — always visible, never eclipsed by overlays so the user can
// always see the current mode + key hints. // 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() { match inputs.state.overlay() {
Some(OverlayKind::Spawn) => { Some(OverlayKind::Spawn) => {
@@ -203,6 +209,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
None => {} 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 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 /// 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 /// mode + focused panel + the most useful key hints so the user is never
/// guessing what state they're in. /// 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 { let mode_label = match state.mode {
Mode::Normal => "[NORMAL]", Mode::Normal => "[NORMAL]",
Mode::Insert => "[INSERT]", 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::styled(format!(" {mode_label} "), mode_style),
Span::raw(format!(" {focus_label} • {hint}")), 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)] #[cfg(test)]
@@ -246,6 +312,16 @@ mod tests {
use super::*; use super::*;
use crate::input::Region; 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] #[test]
fn region_map_resolves_known_areas() { fn region_map_resolves_known_areas() {
let map = RegionMap { 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::<Vec<_>>()
.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 /// The spawn form is a modal: the frame shows it over whatever the pane
/// was displaying, framed by its own border. /// was displaying, framed by its own border.
#[test] #[test]
@@ -356,6 +522,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
link: &online_link(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -408,6 +575,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
link: &online_link(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap(); let mut terminal = ratatui::Terminal::new(backend).unwrap();
@@ -447,6 +615,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
link: &online_link(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap(); let mut terminal = ratatui::Terminal::new(backend).unwrap();
+14 -8
View File
@@ -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 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). — commit hash and commit date — on hover (web) or in the status bar (TUI).
| state | colour | meaning | | state | colour | meaning |
| -------------- | ------ | -------------------------------------------------- | | -------------- | ------ | ---------------------------------------------------- |
| `connecting` | amber | first probe hasn't landed | | `connecting` | amber | first probe hasn't landed |
| `online` | green | daemon answered; same process, or a resolved new one | | `online` | green | daemon answered; same process, or a resolved new one |
| `reconnecting` | amber | socket dropped, still inside the grace window | | `reconnecting` | amber | socket dropped, still inside the grace window |
| `down` | red | unreachable past the grace window — the UI blocks | | `down` | red | unreachable past the grace window — the UI blocks |
| `updating` | red | answered on a new commit; the page is reloading | | `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: <hash> (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 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 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 would act on a daemon that is not there. In the TUI the quit binding still
succeeds — there is no "retry" button, because the client is already retrying. 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 ## Timing