Compare commits

..
3 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 29545d15fe 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>
2026-09-02 15:52:44 +01:00
Miguel PalhasandClaude Opus 5 4d7e368d31 feat(maestro): daemon status indicator and restart overlay on web
The sidebar footer gains a status dot beside the spawn button: green while the
daemon answers, amber while reconnecting, red while it is gone. Hovering it
shows the release the daemon is running — commit hash and commit date.

When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past
a 1200 ms grace window an overlay blocks the UI (and swallows the app's
shortcuts) until the daemon answers again. If it comes back on a different
commit the page reloads, so a tab never drives new server code from the bundle
that was just replaced; a plain restart just unlocks.

docs/RESTART.md is the contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:45:50 +01:00
Miguel PalhasandClaude Opus 5 eaf60d9dac feat(maestro): stamp the daemon with its build commit
`GET /api/info` now carries `build` (commit hash, commit date, dirty flag)
and `instance`, a fresh uuid per daemon process. Together they let a client
name the release it is talking to and, after losing its socket, tell an
upgrade from a plain restart from a network blip.

The stamp comes from `maestro-daemon/build.rs`, which reads the git checkout
it compiles in. The Nix build has no `.git` in the crane-filtered source, so
`flake.nix` passes the flake's own revision and last-modified date through
`MAESTRO_BUILD_*` instead. Neither available leaves the fields empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:40:41 +01:00
35 changed files with 1911 additions and 295 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.
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
@@ -29,6 +29,7 @@ async fn info_carries_version_and_presets() {
label: "Claude".into(),
command: "claude".into(),
}],
..Default::default()
});
let info = mock.client().daemon_info().await.expect("info");
+77
View File
@@ -0,0 +1,77 @@
//! Stamps the daemon binary with the commit it was built from, so
//! `GET /api/info` can tell clients which release they are talking to.
//!
//! Two sources, in order:
//!
//! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in
//! the environment. The Nix build has no `.git` in its source tree (crane
//! filters it out), so `flake.nix` passes the flake's own revision this way.
//! 2. The git checkout the crate is being compiled in.
//!
//! Neither available leaves the values empty; clients treat that as "unknown
//! release" rather than an error.
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT");
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE");
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY");
// Rebuild when HEAD moves, so a plain `cargo build` after a commit or a
// branch switch restamps instead of serving the previous hash.
for f in ["HEAD", "ORIG_HEAD"] {
if let Some(p) = git_dir().map(|d| d.join(f)) {
if p.exists() {
println!("cargo:rerun-if-changed={}", p.display());
}
}
}
let commit = env_or_git("MAESTRO_BUILD_COMMIT", &["rev-parse", "--short=12", "HEAD"]);
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(),
};
println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}");
println!("cargo:rustc-env=MAESTRO_BUILD_DATE={date}");
println!("cargo:rustc-env=MAESTRO_BUILD_DIRTY={dirty}");
}
/// The environment variable if set and non-empty, else what git reports, else
/// an empty string.
fn env_or_git(var: &str, args: &[&str]) -> String {
match std::env::var(var) {
Ok(v) if !v.trim().is_empty() => v.trim().to_string(),
_ => git(args).unwrap_or_default(),
}
}
/// Run `git` inside the crate's source tree. `None` when git is missing, the
/// tree is not a checkout, or the command fails — every one of which is a
/// legitimate way to build Maestro.
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git")
.args(args)
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Absolute path of the `.git` directory (a worktree's `.git` is a file
/// pointing elsewhere, and `--git-common-dir` resolves both).
fn git_dir() -> Option<PathBuf> {
let dir = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
if dir.is_empty() {
return None;
}
Some(PathBuf::from(dir))
}
+1
View File
@@ -20,6 +20,7 @@ pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod release;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
+2
View File
@@ -155,6 +155,8 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
version: PROTOCOL_VERSION.into(),
home_dir: (*s.home_dir).clone(),
presets: (*s.presets).clone(),
build: crate::release::build_info(),
instance: crate::release::instance_id().to_string(),
})
}
+31
View File
@@ -0,0 +1,31 @@
//! What release this daemon is, and which run of it.
//!
//! Two facts ride `GET /api/info` so clients can render a status indicator and
//! survive an upgrade:
//!
//! * [`build_info`] — the commit the binary was compiled from, stamped by
//! `build.rs`. Shown in the indicator's tooltip; a change across a reconnect
//! means the operator upgraded, so a browser reloads its assets.
//! * [`instance_id`] — a fresh id per daemon process. A change with the same
//! commit means a plain restart, which clients ride out silently.
use std::sync::OnceLock;
use maestro_protocol::BuildInfo;
/// The commit this binary was built from. Every field is empty when the build
/// happened outside a git checkout and no `MAESTRO_BUILD_*` override was given.
pub fn build_info() -> BuildInfo {
BuildInfo {
commit: env!("MAESTRO_BUILD_COMMIT").to_string(),
commit_date: env!("MAESTRO_BUILD_DATE").to_string(),
dirty: env!("MAESTRO_BUILD_DIRTY") == "true",
}
}
/// Identity of this daemon process, stable for its lifetime and different on
/// every start. Clients diff it across a reconnect to detect a restart.
pub fn instance_id() -> &'static str {
static ID: OnceLock<String> = OnceLock::new();
ID.get_or_init(|| uuid::Uuid::new_v4().to_string())
}
+29
View File
@@ -91,6 +91,35 @@ async fn info_returns_daemon_identity() {
assert!(resp["version"].is_string());
}
#[tokio::test]
async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare
// against after a reconnect. `commit`/`commit_date` are empty when the
// binary was built outside a checkout, so only their presence is asserted;
// `instance` is a fresh uuid per daemon process.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp["build"]["commit"].is_string());
assert!(resp["build"]["commit_date"].is_string());
assert!(resp["build"]["dirty"].is_boolean());
let instance = resp["instance"].as_str().unwrap();
assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid");
// Same process, same id — a client only treats a *changed* id as a restart.
let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(again["instance"].as_str().unwrap(), instance);
}
#[tokio::test]
async fn list_sessions_starts_empty_and_reflects_creates() {
let h = spawn("naps-laptop").await;
+43
View File
@@ -485,6 +485,40 @@ pub struct SpawnPreset {
pub command: String,
}
/// Build provenance of the running daemon: the commit it was compiled
/// from and when that commit was authored.
///
/// Clients show it in the status indicator's tooltip, and compare `commit`
/// across a reconnect to tell an upgrade apart from a plain restart — an
/// upgrade means the served assets changed, so a browser reloads rather than
/// resuming against new code with an old bundle.
///
/// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git
/// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE`
/// environment variables when there is no checkout (the Nix build). Every field
/// is empty when neither is available; clients must render that gracefully.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildInfo {
/// Abbreviated commit hash the binary was built from. Empty if unknown.
#[serde(default)]
pub commit: String,
/// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty
/// if unknown.
#[serde(default)]
pub commit_date: String,
/// Whether the working tree carried uncommitted changes at build time.
#[serde(default)]
pub dirty: bool,
}
impl BuildInfo {
/// Whether anything is known about this build. A client with `false` here
/// shows the indicator without a release line rather than an empty one.
pub fn is_known(&self) -> bool {
!self.commit.is_empty()
}
}
/// Payload of `GET /api/info` — daemon identity, protocol version, and the
/// spawn presets clients render as one-click tiles.
///
@@ -503,6 +537,15 @@ pub struct DaemonInfo {
pub home_dir: String,
#[serde(default)]
pub presets: Vec<SpawnPreset>,
/// Which commit this daemon was built from.
#[serde(default)]
pub build: BuildInfo,
/// Identity of this daemon *process*, regenerated on every start. Two
/// replies carrying different ids mean the daemon restarted in between —
/// which is how a client that lost its socket tells a restart from a
/// network blip, even when the build did not change.
#[serde(default)]
pub instance: String,
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
+1
View File
@@ -278,6 +278,7 @@ async fn daemon_info(State(s): State<AppState>) -> Response {
version: PROTOCOL_VERSION.into(),
home_dir: "/home/mock".into(),
presets: Vec::new(),
..Default::default()
});
Json(info).into_response()
}
+43 -62
View File
@@ -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;
@@ -572,27 +601,9 @@ impl App {
Some(None)
}
/// Put the spawn form up with a fresh draft behind it, preselecting the
/// project of the session the operator was last on — so `n` then Enter
/// spawns another session there, the way "spawn similar" would. A
/// worktree session seeds its *base repository*, not its checkout: the
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
/// rule as web/src/spawnSimilar.ts.
/// Put the spawn form up with a fresh draft behind it.
fn open_spawn_modal(&mut self) {
let seed_cwd = self.state.active_session().and_then(|sid| {
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.map(|s| match &s.worktree {
Some(wt) => wt.base_repo.clone(),
None => s.cwd.clone(),
})
});
let mut draft = DraftPane::default();
draft.seed_cwd = seed_cwd;
self.draft = Some(draft);
self.draft = Some(DraftPane::default());
self.state.open_spawn_modal();
}
@@ -849,9 +860,6 @@ impl App {
return;
};
draft.home_dir = home_dir;
// The fast path resolved its own project; its pick wins over the
// focused session's, so the seed never lands on top of it.
draft.seed_cwd = None;
if !presets.is_empty() {
draft.set_presets(presets);
}
@@ -940,6 +948,7 @@ impl App {
None => "session".into(),
};
let link_status = self.link.status();
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: &self.home_dir,
@@ -949,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();
@@ -1197,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]
@@ -1661,41 +1676,6 @@ mod tests {
assert_eq!(draft.command(), "claude");
}
/// Opening the form preselects the project of the session in the pane, so
/// `n` then Enter spawns another session there.
#[test]
fn opening_the_spawn_modal_seeds_the_focused_sessions_project() {
use maestro_protocol::{CatalogEvent, SessionWorktree};
let sid = Uuid::from_u128(21);
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![test_session(sid, "/repos/maestro")],
});
app.state.attach_to_focused(sid);
app.dispatch(Action::OpenSpawnModal);
assert_eq!(
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
Some(std::path::Path::new("/repos/maestro"))
);
// A worktree session seeds its base repository: the daemon never
// adopts an existing worktree (docs/WORKTREES.md).
let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn");
wt_session.worktree = Some(SessionWorktree {
base_repo: "/repos/maestro".into(),
path: "/repos/maestro/worktrees/flugelhorn".into(),
});
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![wt_session],
});
app.dispatch(Action::OpenSpawnModal);
assert_eq!(
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
Some(std::path::Path::new("/repos/maestro"))
);
}
/// A valid form flows through: pick a project (set directly — the picker
/// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row.
@@ -1709,11 +1689,11 @@ mod tests {
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
app.handle_draft_input(&InputKey::Tab); // → custom command
app.handle_draft_input(&InputKey::Tab); // preset → custom command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // → dir picker
app.handle_draft_input(&InputKey::Tab); // → name
for c in "crimson-otter".chars() {
app.handle_draft_input(&InputKey::char(c));
@@ -1828,6 +1808,7 @@ mod tests {
Duration::from_millis(200),
Duration::from_millis(500),
),
DaemonLink::default(),
);
app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest {
+14
View File
@@ -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));
+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());
}
}
+49 -131
View File
@@ -221,11 +221,6 @@ pub struct DraftPane {
pub cwd_raw_visible: bool,
pub cwd_raw: crate::text_input::TextInput,
pub picked_cwd: Option<PathBuf>,
/// Directory to preselect once the project scan lands — the project of
/// the session the operator was last looking at, so `n` then Enter
/// respawns there without touching the picker. Consumed by the first
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
pub seed_cwd: Option<PathBuf>,
/// The repository picked in step one ([`group_key`] of its main box),
/// `None` until a box is picked. Looked up fresh in `groups` rather than
/// cached, so a project-list refresh can't leave it stale.
@@ -298,7 +293,6 @@ impl Default for DraftPane {
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
seed_cwd: None,
selected_group_key: None,
worktree_choice: WorktreeChoice::New,
name: crate::text_input::TextInput::default(),
@@ -321,9 +315,7 @@ impl Default for DraftPane {
branches_for: None,
needs_branches: false,
error: None,
// The picker owns the keyboard on open: typing filters projects
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
focused_field: DraftField::Preset,
home_dir: String::new(),
}
}
@@ -366,26 +358,6 @@ impl DraftPane {
})
.collect();
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
self.apply_seed_cwd();
}
/// Preselect the seeded directory now that the scan has landed: highlight
/// its repository box and pick it, so Enter from the untouched picker
/// spawns there. A seed no box matches is dropped — the operator picks by
/// hand, exactly as before.
fn apply_seed_cwd(&mut self) {
let Some(seed) = self.seed_cwd.take() else {
return;
};
let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| {
let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed);
matches.then(|| (i, (*g).clone()))
});
let Some((index, group)) = hit else {
return;
};
self.highlighted = index;
self.select_group(&group);
}
/// Fill the local name batch from `GET /api/names`, seeding the field
@@ -733,14 +705,14 @@ impl DraftPane {
}
fn field_order(&self) -> Vec<DraftField> {
let mut order = vec![DraftField::DirPicker];
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
}
order.push(DraftField::Preset);
let mut order = vec![DraftField::Preset];
if self.is_custom() {
order.push(DraftField::CustomCommand);
}
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
@@ -1144,15 +1116,13 @@ impl DraftPane {
+ u16::from(self.error.is_some());
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
// The picker leads the form: it is where focus lands on open, and Tab
// walks down from it into the command tiles.
let mut constraints = vec![Constraint::Length(1)]; // dir filter
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
let mut constraints = vec![Constraint::Length(1)];
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // command tiles
if show_custom {
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // name
@@ -1193,6 +1163,36 @@ impl DraftPane {
// Tracks where the terminal cursor belongs for the focused text field.
let mut cursor: Option<(u16, u16)> = None;
// -- command presets ---------------------------------------------------
let tiles_row = next_row();
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
for (i, preset) in self.presets.iter().enumerate() {
if i > 0 {
tiles.push(Span::raw(" "));
}
let mut style = Style::default();
if i == self.selected_preset {
style = style.add_modifier(Modifier::REVERSED);
}
tiles.push(Span::styled(format!("[{}]", preset.label), style));
}
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
// -- custom command (only while a custom tile is selected) -------------
if show_custom {
let row = next_row();
draw_gutter(f, row, "");
let col = draw_text_field(
f,
&self.custom_command,
row,
field_style(DraftField::CustomCommand),
);
if self.focused_field == DraftField::CustomCommand {
cursor = Some((col, row.y));
}
}
// -- directory picker ----------------------------------------------------
let filter_row = next_row();
draw_gutter(f, filter_row, "dir:");
@@ -1280,36 +1280,6 @@ impl DraftPane {
}
}
// -- command presets ---------------------------------------------------
let tiles_row = next_row();
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
for (i, preset) in self.presets.iter().enumerate() {
if i > 0 {
tiles.push(Span::raw(" "));
}
let mut style = Style::default();
if i == self.selected_preset {
style = style.add_modifier(Modifier::REVERSED);
}
tiles.push(Span::styled(format!("[{}]", preset.label), style));
}
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
// -- custom command (only while a custom tile is selected) -------------
if show_custom {
let row = next_row();
draw_gutter(f, row, "");
let col = draw_text_field(
f,
&self.custom_command,
row,
field_style(DraftField::CustomCommand),
);
if self.focused_field == DraftField::CustomCommand {
cursor = Some((col, row.y));
}
}
// -- name + reroll ---------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
@@ -1711,10 +1681,7 @@ mod tests {
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = DraftPane {
focused_field: DraftField::Preset,
..DraftPane::default()
};
let mut draft = DraftPane::default();
draft.on_input(&InputKey::Right);
assert_eq!(draft.command(), "opencode");
draft.on_input(&InputKey::Left);
@@ -1737,22 +1704,17 @@ mod tests {
draft.selected_preset = draft.presets.len() - 1;
assert!(draft.is_custom());
assert_eq!(
draft.field_order()[..3],
[
DraftField::DirPicker,
DraftField::Preset,
DraftField::CustomCommand
],
"the picker leads; custom command sits right after the tiles"
draft.field_order()[..2],
[DraftField::Preset, DraftField::CustomCommand],
"custom command sits between presets and the dir picker"
);
draft.next_field(); // DirPicker → Preset
draft.next_field();
assert_eq!(draft.focused_field, DraftField::CustomCommand);
typed(&mut draft, "bash -l");
assert_eq!(draft.command(), "bash -l");
// Tab walks past the hidden fields in order.
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Name);
assert_eq!(draft.focused_field, DraftField::DirPicker);
}
#[test]
@@ -1815,11 +1777,8 @@ mod tests {
],
Utc::now(),
);
assert_eq!(
draft.focused_field,
DraftField::DirPicker,
"the form opens with the project filter focused"
);
draft.next_field(); // Preset → DirPicker
assert_eq!(draft.focused_field, DraftField::DirPicker);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_groups().len(),
@@ -1931,47 +1890,6 @@ mod tests {
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/repos/other")),
..DraftPane::default()
};
draft.set_projects(
vec![
project("maestro", "/repos/maestro", 1, Some("claude")),
project("other", "/repos/other", 2, Some("zsh")),
],
Utc::now(),
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the seeded project is picked before a key is pressed"
);
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"one Enter spawns in the seeded project"
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
}
#[test]
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/gone")),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
assert_eq!(draft.picked_cwd, None);
assert_eq!(draft.selection(), 0);
}
#[test]
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
let mut draft = DraftPane::default();
@@ -2222,7 +2140,7 @@ mod tests {
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
}
#[test]
+11 -2
View File
@@ -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
}
+37 -1
View File
@@ -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,
+174 -5
View File
@@ -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::<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
/// 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();
+84
View File
@@ -0,0 +1,84 @@
# Restart and upgrade contract
Maestro is upgraded in place: the operator reinstalls the binary and systemd
restarts the daemon under every browser tab and TUI that is attached to it.
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
lose their sockets. This document is the contract for what a client does about
it — what the daemon publishes, how a client tells a restart from a blip, and
what the user sees while it happens.
## What the daemon publishes
`GET /api/info` carries two fields beyond identity and presets:
| field | meaning |
| ---------- | -------------------------------------------------------------- |
| `build` | `{ commit, commit_date, dirty }` — the commit the binary was compiled from |
| `instance` | uuid regenerated on every daemon start |
`build` is stamped at compile time by `crates/maestro-daemon/build.rs`, which
runs `git` in the checkout it is compiled in. The Nix build has no `.git` in the
crane-filtered source tree, so `flake.nix` passes the flake's own revision as
`MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY`. A build
with neither source reports empty strings — clients render that as
"unknown release" and fall back to `instance` alone.
`instance` is the one that never goes missing. It says *this process*, so two
replies that differ on it are two runs of the daemon, even when the commit did
not change (a plain `systemctl restart`, or a crash).
## How a client reads them
Three verdicts, from comparing the reply before an outage with the one after
(`compareIdentity` in `web/src/daemonLink.ts`, mirrored by
`crates/maestro-tui/src/daemon_link.rs`):
- **upgraded** — both commits are known and differ. New server code.
- **restarted** — commits match (or are unknown) and `instance` differs.
- **same** — neither changed; the socket blipped, the daemon never moved.
Only *upgraded* is special. A browser tab is holding a bundle that the upgraded
daemon no longer serves, so it reloads the page rather than driving new server
code from an old bundle. The TUI is its own binary — a reload would not replace
it — so it stays up, reconnects, and says which release it is now talking to;
the user restarts `maestro tui` when they want the new client.
## What the user sees
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 |
| `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 |
`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
dismissable and it swallows the app's keyboard shortcuts: everything behind it
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
- **Grace window** — 1200 ms between the socket dropping and the overlay
appearing. A wifi hiccup or a laptop waking up should not throw a modal.
- **Poll cadence** — `GET /api/info` every 700 ms while unreachable, flat, not
backed off: a restart takes a second or two and the point is to unlock as soon
as it is over. The events socket keeps its own exponential backoff for the
session stream; the two are independent, and the probe is what decides state.
The events socket dropping is the trigger, not a timer: the daemon cannot warn
anyone it is about to die, so its socket closing is the earliest signal there
is. A reconnect of that socket does **not** by itself clear the state — only a
probe does, because the socket carries no identity and reconnecting to an
upgraded daemon must reload rather than resume.
+20
View File
@@ -33,6 +33,25 @@
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
# Build provenance for `GET /api/info`. The Rust source tree crane
# hands to the compiler has no `.git`, so `maestro-daemon/build.rs`
# cannot ask git itself — the flake's own revision is passed instead.
# A dirty tree has no `rev`, and `dirtyRev` carries a `-dirty` suffix.
rev = inputs.self.rev or inputs.self.dirtyRev or "";
# `lastModifiedDate` is `YYYYMMDDhhmmss` in UTC; clients want RFC 3339.
ld = inputs.self.lastModifiedDate or "";
iso =
if ld == "" then
""
else
"${builtins.substring 0 4 ld}-${builtins.substring 4 2 ld}-${builtins.substring 6 2 ld}"
+ "T${builtins.substring 8 2 ld}:${builtins.substring 10 2 ld}:${builtins.substring 12 2 ld}Z";
buildStamp = {
MAESTRO_BUILD_COMMIT = builtins.substring 0 12 rev;
MAESTRO_BUILD_DATE = iso;
MAESTRO_BUILD_DIRTY = if inputs.self ? rev then "0" else "1";
};
# Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The
# SPA is built separately (below) and handed to the Rust build as a
# path, so nothing under web/ needs to be in this source tree.
@@ -85,6 +104,7 @@
maestro = craneLib.buildPackage (
commonArgs
// buildStamp
// {
inherit cargoArtifacts;
pname = "maestro";
+10
View File
@@ -25,6 +25,8 @@ import {
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
import type { DaemonLink } from "./daemonLink.js";
import { createStatusIndicator } from "./statusIndicator.js";
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
@@ -336,6 +338,9 @@ export interface SidebarOptions {
/** Pointerdown on a session row — the multi-view controller decides drag vs
* tap (issue #32). Tap falls through to the row's normal click (open). */
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -390,6 +395,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
// Daemon status sits beside the spawn button: colour for the link state,
// hover for the release the daemon is running.
const status = opts.link ? createStatusIndicator(opts.link) : null;
if (status) footer.appendChild(status.el);
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
@@ -617,6 +626,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
dispose() {
clearInterval(clock);
status?.dispose();
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
import type { Scheduler } from "./events.js";
import type { DaemonInfo } from "./types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: date, dirty: false },
instance,
};
}
/** Scheduler whose timers only fire when the test says so. */
function manual(): Scheduler & { run(): void; pending(): number } {
let next = 1;
const timers = new Map<number, () => void>();
return {
setTimer(fn) {
const h = next++;
timers.set(h, fn);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.entries()];
timers.clear();
for (const [, fn] of due) fn();
},
pending: () => timers.size,
};
}
describe("compareIdentity", () => {
it("reads a changed commit as an upgrade", () => {
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
});
it("reads a changed instance on the same commit as a plain restart", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
});
it("reads an unchanged process as no event at all", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
});
it("falls back to the instance when the build is unknown", () => {
// A daemon built outside a checkout reports empty commits; the instance is
// still enough to see the restart, and never claims a phantom upgrade.
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
});
});
describe("formatRelease", () => {
it("pairs the short hash with a readable date", () => {
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
"f89be4a716c7 · 2026-09-02",
);
});
it("marks a dirty build", () => {
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
});
it("says so when the release is unknown", () => {
expect(formatRelease(undefined)).toBe("unknown release");
});
});
describe("DaemonLink", () => {
it("starts online once the first probe lands", async () => {
const link = new DaemonLink({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
await link.start();
expect(link.status().state).toBe("online");
expect(link.status().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
graceMs: 1000,
});
link.noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(link.status().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload,
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(link.status().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await link.probe();
expect(reload).not.toHaveBeenCalled();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await link.start();
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(link.status().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const link = new DaemonLink({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
link.noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
const first = attempts;
expect(first).toBeGreaterThan(0);
sched.run(); // next poll tick
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
link.stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const link = new DaemonLink({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
link.subscribe((s) => seen.push(s.state));
await link.start();
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
expect(seen[0]).toBe("connecting");
});
});
+218
View File
@@ -0,0 +1,218 @@
// The link to the daemon behind this page: is it up, and which release is it?
// The sidebar's status indicator and the restart overlay both read this one
// controller. See docs/RESTART.md for the restart contract it implements.
import { getDaemonInfo } from "./api.js";
import type { Scheduler } from "./events.js";
import type { BuildInfo, DaemonInfo } from "./types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
export interface LinkStatus {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
export type LinkListener = (status: LinkStatus) => void;
export class DaemonLink {
private state: LinkState = "connecting";
private identity: DaemonInfo | null = null;
private attempts = 0;
private graceTimer: number | null = null;
private pollTimer: number | null = null;
private readonly listeners = new Set<LinkListener>();
private readonly fetchInfo: () => Promise<DaemonInfo>;
private readonly scheduler: Scheduler;
private readonly reload: () => void;
private readonly graceMs: number;
private readonly pollMs: number;
constructor(opts: DaemonLinkOptions = {}) {
this.fetchInfo = opts.fetchInfo ?? getDaemonInfo;
this.scheduler = opts.scheduler ?? browserScheduler;
this.reload = opts.reload ?? (() => window.location.reload());
this.graceMs = opts.graceMs ?? GRACE_MS;
this.pollMs = opts.pollMs ?? POLL_MS;
}
/** Record the release we started against. Everything later is a diff on it. */
async start(): Promise<void> {
await this.probe();
}
status(): LinkStatus {
return {
state: this.state,
build: this.identity?.build,
hostname: this.identity?.hostname,
attempts: this.attempts,
};
}
subscribe(listener: LinkListener): () => void {
this.listeners.add(listener);
listener(this.status());
return () => this.listeners.delete(listener);
}
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void {
if (this.state === "down" || this.state === "updating") return;
this.attempts = 0;
this.setState("reconnecting");
if (this.graceTimer === null) {
this.graceTimer = this.scheduler.setTimer(() => {
this.graceTimer = null;
if (this.state === "reconnecting") this.setState("down");
void this.poll();
}, this.graceMs);
}
}
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void {
if (this.state === "online" || this.state === "updating") return;
void this.probe();
}
/**
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
*
* A first answer becomes the baseline. A later one that differs on commit
* reloads the page; anything else (plain restart, or the same process) puts
* the UI back online.
*/
async probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await this.fetchInfo();
} catch {
this.attempts += 1;
this.emit();
return false;
}
const before = this.identity;
this.identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
this.setState("updating");
this.reload();
return true;
}
this.attempts = 0;
this.clearTimers();
this.setState("online");
return true;
}
/** Stop polling. The page's own teardown; not used in normal operation. */
stop(): void {
this.clearTimers();
}
private async poll(): Promise<void> {
if (this.state === "online" || this.state === "updating") return;
const ok = await this.probe();
if (ok || this.pollTimer !== null) return;
this.pollTimer = this.scheduler.setTimer(() => {
this.pollTimer = null;
void this.poll();
}, this.pollMs);
}
private clearTimers(): void {
if (this.graceTimer !== null) {
this.scheduler.clearTimer(this.graceTimer);
this.graceTimer = null;
}
if (this.pollTimer !== null) {
this.scheduler.clearTimer(this.pollTimer);
this.pollTimer = null;
}
}
private setState(state: LinkState): void {
if (this.state === state) return;
this.state = state;
this.emit();
}
private emit(): void {
const s = this.status();
for (const fn of this.listeners) fn(s);
}
}
+18
View File
@@ -160,3 +160,21 @@ describe("CatalogStore reconnect", () => {
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
});
describe("connection listeners", () => {
it("report the socket opening and dropping", () => {
const sock = new FakeSocket();
const store = new CatalogStore({
socket: () => sock as unknown as WebSocket,
scheduler: fakeScheduler(),
});
const seen: boolean[] = [];
store.subscribeConnection((up) => seen.push(up));
store.start();
// Nothing on subscribe — the daemon link's own probe sets the first state.
expect(seen).toEqual([]);
sock.open();
sock.drop();
expect(seen).toEqual([true, false]);
});
});
+19
View File
@@ -33,9 +33,15 @@ export interface CatalogStoreOptions {
refetch?: () => Promise<Session[]>;
}
/** Called with `true` when the events socket opens, `false` when it drops.
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
* the daemon went down or was upgraded under the page. */
export type ConnectionListener = (up: boolean) => void;
export class CatalogStore {
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private connectionListeners = new Set<ConnectionListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
private backoffMs = INITIAL_BACKOFF_MS;
@@ -70,6 +76,13 @@ export class CatalogStore {
}
}
/** Watch the socket's up/down transitions. Not called on subscribe: the
* caller's own probe decides the starting state. */
subscribeConnection(listener: ConnectionListener): () => void {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
subscribe(listener: CatalogListener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
@@ -99,10 +112,12 @@ export class CatalogStore {
ws.onopen = () => {
this.backoffMs = INITIAL_BACKOFF_MS;
this.emitConnection(true);
};
ws.onclose = () => {
this.ws = null;
this.emitConnection(false);
this.scheduleReconnect();
};
@@ -161,6 +176,10 @@ export class CatalogStore {
}
}
private emitConnection(up: boolean): void {
for (const fn of this.connectionListeners) fn(up);
}
private emit(): void {
const snap = this.snapshot();
for (const fn of this.listeners) fn(snap);
+14 -9
View File
@@ -7,7 +7,9 @@ import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
@@ -22,7 +24,7 @@ import {
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { openSpawnModal } from "./spawnModal.js";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
@@ -69,6 +71,15 @@ if (import.meta.env.DEV && location.search.includes("kit")) {
async function bootApp(): Promise<void> {
const store = new CatalogStore();
// The daemon's identity and liveness: names the release in the sidebar's status
// dot, and blocks the UI behind the restart overlay while the daemon is away
// (docs/RESTART.md). The events socket is the trigger — it dies first.
const link = new DaemonLink();
installRestartOverlay(link);
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
void link.start();
store.start();
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
@@ -89,6 +100,7 @@ shellEl.dataset.route = "catalog";
let mv: MultiView;
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
});
const content = document.createElement("section");
content.className = "content";
@@ -243,14 +255,7 @@ function requestOpenSpawn(): void {
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
// the old behaviour: both act on the session in front of you, so a split
// beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null;
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }),
});
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
+93
View File
@@ -0,0 +1,93 @@
// Blocking overlay for a daemon that went away — see docs/RESTART.md.
//
// Mounted while the link is `down` or `updating`, torn down the moment it is
// back. It owns no polling of its own: DaemonLink polls, and this only renders
// what the link reports.
import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js";
export interface RestartOverlayHandle {
dispose(): void;
}
/** Mount the overlay controller. It stays subscribed for the page's lifetime. */
export function installRestartOverlay(
link: DaemonLink,
host: HTMLElement = document.body,
): RestartOverlayHandle {
let el: HTMLElement | null = null;
let title: HTMLElement | null = null;
let detail: HTMLElement | null = null;
let release: HTMLElement | null = null;
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const blocking = status.state === "down" || status.state === "updating";
if (!blocking) {
unmount();
return;
}
if (!el) mount();
const updating = status.state === "updating";
title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting";
detail!.textContent = updating
? "Reloading this page onto the new release…"
: attemptText(status.attempts);
release!.textContent = formatRelease(status.build);
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function mount(): void {
el = document.createElement("div");
el.className = "modal-backdrop restart-backdrop";
const dialog = document.createElement("div");
dialog.className = "modal restart-modal";
dialog.setAttribute("role", "alertdialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "restart-title");
title = document.createElement("h2");
title.id = "restart-title";
detail = document.createElement("p");
detail.className = "restart-detail";
release = document.createElement("p");
release.className = "restart-release";
const spinner = document.createElement("div");
spinner.className = "restart-spinner";
spinner.setAttribute("aria-hidden", "true");
dialog.append(spinner, title, detail, release);
el.appendChild(dialog);
host.appendChild(el);
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
window.addEventListener("keydown", swallow, true);
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
function unmount(): void {
if (!el) return;
window.removeEventListener("keydown", swallow, true);
el.remove();
el = null;
}
return {
dispose(): void {
unsubscribe();
unmount();
},
};
}
+8 -14
View File
@@ -122,6 +122,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -133,12 +139,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -896,14 +896,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
e.preventDefault();
if (target === filterInput) {
// Resolve the filter to a project only when the current pick isn't one
// of the matches: a filter typed past it means the operator moved on,
// while a pick that still matches (including the seeded one the form
// opens with) keeps its worktree choice.
const visible = visibleGroups(filterInput.value);
const stillMatches =
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
if (!stillMatches && visible[0]) selectProject(visible[0]);
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
}
form.requestSubmit();
});
+2 -23
View File
@@ -68,12 +68,9 @@ describe("spawnForm Enter-to-submit (#97)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(
projects: Project[],
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
): Promise<HTMLFormElement> {
async function mount(projects: Project[]): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ seed, onSuccess() {} });
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
@@ -235,24 +232,6 @@ describe("spawnForm Enter-to-submit (#97)", () => {
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("submits in the seeded project rather than the freshest one", async () => {
// What `n` then Enter must do: the modal opens parked on the project of
// the session you were last on (OTHER here), even though MAESTRO's
// worktree is the freshest row in the list.
const form = await mount([MAESTRO, WT_A, OTHER], {
cwd: OTHER.path,
cwdRawVisible: false,
});
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
-10
View File
@@ -52,16 +52,6 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, ahead of the command tiles", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
await flush();
-3
View File
@@ -59,9 +59,6 @@ export function openSpawnModal(opts: SpawnModalOptions): void {
},
});
dialog.appendChild(form.el);
// The project filter owns the keyboard on open: typing narrows the list and
// Tab from here reaches the command tiles, matching the TUI's draft pane.
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
function close(): void {
if (closeCurrent === close) closeCurrent = null;
+1 -21
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
function session(overrides: Partial<Session> = {}): Session {
@@ -43,23 +43,3 @@ describe("spawnSimilarSeed (#28)", () => {
expect(seed.name).toBeUndefined();
});
});
describe("spawnSimilarProjectSeed", () => {
it("seeds the project alone, leaving command and worktree at their defaults", () => {
const seed = spawnSimilarProjectSeed(session());
expect(seed.cwd).toBe("/repos/app");
expect(seed.cwdRawVisible).toBe(false);
expect(seed.command).toBeUndefined();
expect(seed.worktreeEnabled).toBeUndefined();
});
it("seeds a worktree session's base repository", () => {
const seed = spawnSimilarProjectSeed(
session({
cwd: "/repos/app-worktrees/feature",
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
}),
);
expect(seed.cwd).toBe("/repos/app");
});
});
-14
View File
@@ -28,17 +28,3 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed {
cwdRawVisible: false,
};
}
/** The project half of a clone: the session's repository, nothing else.
*
* The plain spawn modal opens on this so the picker is already parked on the
* project you were last looking at — one Enter spawns there — while the
* command, worktree decision and name stay at the form's own defaults. Same
* base-repository rule as `spawnSimilarSeed`. */
export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null;
return {
cwd: wt ? wt.base_repo : s.cwd,
cwdRawVisible: false,
};
}
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { DaemonLink } from "./daemonLink.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { createStatusIndicator } from "./statusIndicator.js";
import type { DaemonInfo } from "./types.js";
import type { Scheduler } from "./events.js";
function info(commit: string, instance: string): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false },
instance,
};
}
function manual(): Scheduler & { run(): void } {
let next = 1;
const timers = new Map<number, () => void>();
return {
setTimer(fn) {
const h = next++;
timers.set(h, fn);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.values()];
timers.clear();
for (const fn of due) fn();
},
};
}
describe("status indicator", () => {
it("shows the release on the tooltip once the daemon answers", async () => {
const link = new DaemonLink({
fetchInfo: async () => info("f89be4a716c7", "i1"),
scheduler: manual(),
reload: () => {},
});
const ind = createStatusIndicator(link);
await link.start();
expect(ind.el.dataset.state).toBe("online");
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
expect(tip).toContain("f89be4a716c7");
expect(tip).toContain("2026-09-02");
expect(tip).toContain("konishi");
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
ind.dispose();
});
it("tracks the link through a drop", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const ind = createStatusIndicator(link);
expect(ind.el.dataset.state).toBe("connecting");
link.noteDisconnected();
expect(ind.el.dataset.state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(ind.el.dataset.state).toBe("down");
ind.dispose();
link.stop();
});
});
describe("restart overlay", () => {
it("blocks once the daemon is unreachable and unlocks when it answers", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
expect(document.querySelector(".restart-backdrop")).toBeNull();
reply = null;
link.noteDisconnected();
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
sched.run();
await Promise.resolve();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro is restarting");
// Restarted on the same build: no reload, and the overlay goes away.
reply = info("aaa", "i2");
await link.probe();
expect(document.querySelector(".restart-backdrop")).toBeNull();
overlay.dispose();
});
it("swallows the app's shortcuts while it is up", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
let sawKey = false;
window.addEventListener("keydown", () => {
sawKey = true;
});
link.noteDisconnected();
sched.run();
await Promise.resolve();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
expect(sawKey).toBe(false);
overlay.dispose();
link.stop();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
expect(sawKey).toBe(true);
});
it("says the page is reloading after an upgrade", async () => {
const sched = manual();
let reply = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => reply,
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro was updated");
expect(dialog?.textContent).toContain("bbb");
overlay.dispose();
});
});
+67
View File
@@ -0,0 +1,67 @@
// The sidebar footer's daemon-status dot, sat next to the spawn button.
//
// Colour is the link state; hovering (or focusing — it is a button, so the
// keyboard reaches it) reveals the release the daemon is running: commit hash
// and commit date.
import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js";
/** One human-readable phrase per link state, used for both the tooltip's first
* line and the button's accessible name. */
const LABELS: Record<LinkState, string> = {
connecting: "Connecting…",
online: "Connected",
reconnecting: "Reconnecting…",
down: "Daemon unreachable",
updating: "Updated — reloading",
};
export interface StatusIndicatorHandle {
el: HTMLElement;
dispose(): void;
}
export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle {
const el = document.createElement("button");
el.type = "button";
el.className = "btn btn-ghost btn-icon status-chip";
el.setAttribute("aria-describedby", "status-tip");
const dot = document.createElement("span");
dot.className = "status-dot";
dot.setAttribute("aria-hidden", "true");
el.appendChild(dot);
const tip = document.createElement("span");
tip.className = "status-tip";
tip.id = "status-tip";
tip.setAttribute("role", "tooltip");
el.appendChild(tip);
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const label = LABELS[status.state];
const release = formatRelease(status.build);
el.dataset.state = status.state;
el.setAttribute("aria-label", `${label} — ${release}`);
tip.textContent = "";
tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label));
tip.append(line("status-tip-release", release));
}
function line(cls: string, text: string): HTMLElement {
const span = document.createElement("span");
span.className = cls;
span.textContent = text;
return span;
}
return {
el,
dispose(): void {
unsubscribe();
el.remove();
},
};
}
+128
View File
@@ -435,6 +435,134 @@ textarea {
flex: 1 1 auto;
}
/* ---------- daemon status dot ---------- */
/* Colour is the link state; the tooltip names the release the daemon is
running. The chip is a button so the keyboard reaches the tooltip too. */
.status-chip {
position: relative;
flex: 0 0 auto;
overflow: visible;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 3px transparent;
transition:
background var(--dur) var(--ease-out),
box-shadow var(--dur) var(--ease-out);
}
.status-chip[data-state="online"] .status-dot {
background: var(--live);
}
.status-chip[data-state="connecting"] .status-dot,
.status-chip[data-state="reconnecting"] .status-dot {
background: var(--warn);
animation: pulse 1.1s var(--ease-out) infinite;
}
.status-chip[data-state="down"] .status-dot,
.status-chip[data-state="updating"] .status-dot {
background: var(--danger);
animation: pulse 1.1s var(--ease-out) infinite;
}
.status-tip {
position: absolute;
bottom: calc(100% + var(--sp-2));
left: 0;
z-index: var(--z-sticky);
display: flex;
flex-direction: column;
gap: 2px;
min-width: 180px;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--surface-2);
box-shadow: var(--shadow-2);
font-size: var(--fs-11);
text-align: left;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity var(--dur-fast) var(--ease-out),
visibility var(--dur-fast) var(--ease-out);
}
.status-chip:hover .status-tip,
.status-chip:focus-visible .status-tip {
opacity: 1;
visibility: visible;
}
.status-tip-state {
color: var(--fg);
}
.status-tip-release {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---------- restart overlay ---------- */
/* Up while the daemon is unreachable or being replaced. Nothing to dismiss:
it clears itself when the link is back (docs/RESTART.md). */
.restart-modal {
max-width: 380px;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-5) var(--sp-4);
text-align: center;
}
.restart-modal h2 {
font-size: var(--fs-15);
margin: 0;
}
.restart-detail {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
}
.restart-release {
margin: 0;
color: var(--faint);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
.restart-spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.restart-spinner,
.status-dot {
animation: none;
}
}
.sidebar-search .search {
width: 100%;
max-width: none;
+18
View File
@@ -86,6 +86,24 @@ export interface DaemonInfo {
home_dir?: string;
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
presets: SpawnPreset[];
/** Which commit the daemon was built from. Absent on older daemons. */
build?: BuildInfo;
/** Identity of the daemon *process*, regenerated on every start — a changed
* value across a reconnect means the daemon restarted. Absent on older
* daemons, which the restart flow treats as "cannot tell". */
instance?: string;
}
/** Build provenance of the running daemon: what the status indicator names in
* its tooltip, and what tells an upgrade apart from a plain restart. Every
* field is empty when the binary was built outside a git checkout. */
export interface BuildInfo {
/** Abbreviated commit hash. */
commit: string;
/** Commit date, RFC 3339. */
commit_date: string;
/** Whether the tree had uncommitted changes at build time. */
dirty: boolean;
}
/** One deduplicated recent working dir for the spawn form's recents picker