Compare commits

...
14 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 c8ab4a531e fix(maestro): tool icon left of diff stat on sidebar meta line
ci / flake (push) Successful in 9m17s
ci / web (push) Successful in 1m9s
ci / rust (push) Successful in 3m55s
Meta line fragment order was command last, after the blocked-work diff
stat, so the tool icon sat right of the diff count instead of left.
Reorder to duration/detail/command/diff-stat in both web and TUI
sidebars per docs/SIDEBAR.md, and update the contract doc + TUI tests
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:17:44 +01:00
naps62 21fc20b30a Merge pull request 'docs(maestro): replace the sidebar's activity dot with a rail' (#130) from indicators into main
ci / web (push) Successful in 3m40s
ci / rust (push) Successful in 5m49s
ci / flake (push) Successful in 6m12s
Reviewed-on: #130
2026-09-02 17:00:58 +01:00
Miguel Palhas abbd15a8b2 feat(maestro): TUI sidebar activity rail
ci / web (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 5m47s
ci / flake (pull_request) Successful in 9m14s
Leads the existing indent gutter on both the name row and the meta
row with a one-column block glyph in the state's palette role,
replacing the meta line's inline dot. Folder headers gain a matching
blank column so labels stay aligned under session names.
2026-09-02 16:47:51 +01:00
Miguel Palhas 1d18e21420 feat(maestro): web sidebar activity rail
Replaces the inline activity dot with a coloured bar positioned inside
each row's own indent gutter, per the updated docs/SIDEBAR.md contract.
Active breathes, awaiting-input blinks, error flashes; idle stays
solid. prefers-reduced-motion leaves every state static.
2026-09-02 16:47:47 +01:00
Miguel Palhas 38a65e585f docs(maestro): replace the sidebar's activity dot with a rail
The inline dot competed with the meta line's other fragments and left
the indent gutter unused. A rail moves the state signal into that
gutter instead, on both clients.
2026-09-02 16:47:42 +01:00
Miguel Palhas adbf57fc49 Merge remote-tracking branch 'origin/main' into colors
ci / web (push) Successful in 37s
ci / rust (push) Successful in 4m19s
ci / flake (push) Successful in 7m3s
# Conflicts:
#	web/src/catalog.ts
#	web/src/styles.css
2026-09-02 16:31:58 +01:00
Miguel PalhasandClaude Fable 5 7935663ed7 feat(maestro): graphite web default + palette picker
Graphite replaces the signal-green accent as the default web palette;
data-palette on <html> selects ice-cyan, periwinkle, amber-phosphor,
ember or orchid (each with dark + light token sets), picked from a new
sidebar-footer dropdown and persisted in localStorage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:14:12 +01:00
Miguel PalhasandClaude Fable 5 bff0eddc12 feat(maestro): graphite TUI default + five accent presets
Replaces the army-green dark/light palettes with neutral graphite and
adds ice-cyan, periwinkle, amber-phosphor, ember and orchid presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:06:31 +01:00
Miguel Palhas 3ce1a6f331 feat(maestro): add paging and symbol keys to the mobile key bar
ci / web (push) Successful in 1m22s
ci / rust (push) Successful in 7m24s
ci / flake (push) Successful in 8m50s
PgUp/PgDn and the `- / | ~` symbols the soft keyboard buries behind a page
switch join the bar, in an 8-column grid that keeps the cursor inverted-T.
Columns hold a thumb's width, so a narrow phone scrolls the bar sideways
instead of shrinking the caps.

Closes #7
2026-09-02 16:01:08 +01:00
Miguel Palhas 8cb6aeb61e Merge branch 'flugelhorn'
ci / rust (push) Successful in 5m20s
ci / web (push) Successful in 2m11s
ci / flake (push) Successful in 11m49s
2026-09-02 15:58:48 +01:00
Miguel Palhas 247060de68 feat(maestro): open the spawn form on the project picker
The picker now leads the form in both clients and takes focus on open, so
Tab walks from it into the command tiles. Opening the form also preselects
the project of the session you were last on — a worktree session seeds its
base repository — so 'n' then Enter spawns another session there.
2026-09-02 15:56:35 +01:00
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
48 changed files with 3173 additions and 268 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");
+16 -1
View File
@@ -174,10 +174,20 @@ pub struct TuiSection {
/// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme {
/// The terminal-native dark palette and the default.
/// The graphite dark palette and the default.
Dark,
/// A high-contrast light palette.
Light,
/// Ice Cyan — cyan accent on the graphite base.
IceCyan,
/// Periwinkle — indigo base, blue-violet accent.
Periwinkle,
/// Amber Phosphor — CRT amber on warm graphite.
AmberPhosphor,
/// Ember — warm orange-red accent.
Ember,
/// Orchid — muted violet accent.
Orchid,
/// Named ANSI colours, allowing the terminal to provide the colour set.
Ansi,
/// Solarized Dark.
@@ -204,6 +214,11 @@ pub enum TuiTheme {
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
("dark", TuiTheme::Dark),
("light", TuiTheme::Light),
("ice-cyan", TuiTheme::IceCyan),
("periwinkle", TuiTheme::Periwinkle),
("amber-phosphor", TuiTheme::AmberPhosphor),
("ember", TuiTheme::Ember),
("orchid", TuiTheme::Orchid),
("ansi", TuiTheme::Ansi),
("solarized-dark", TuiTheme::SolarizedDark),
("solarized-light", TuiTheme::SolarizedLight),
+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()
}
+99 -6
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,9 +601,27 @@ impl App {
Some(None)
}
/// Put the spawn form up with a fresh draft behind it.
/// 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.
fn open_spawn_modal(&mut self) {
self.draft = Some(DraftPane::default());
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.state.open_spawn_modal();
}
@@ -831,6 +878,9 @@ 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);
}
@@ -919,6 +969,7 @@ impl App {
None => "session".into(),
};
let link_status = self.link.status();
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: &self.home_dir,
@@ -928,6 +979,7 @@ impl App {
draft: self.draft.as_ref(),
help: &self.help,
rename_input: &self.rename_input,
link: &link_status,
};
let mut new_region = RegionMap::default();
@@ -1176,7 +1228,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]
@@ -1640,6 +1697,41 @@ 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.
@@ -1653,11 +1745,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); // preset → custom command
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
app.handle_draft_input(&InputKey::Tab); // → 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));
@@ -1772,6 +1864,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));
+10
View File
@@ -80,6 +80,11 @@ impl TuiConfig {
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
TuiTheme::Dark => Palette::DARK,
TuiTheme::Light => Palette::LIGHT,
TuiTheme::IceCyan => Palette::ICE_CYAN,
TuiTheme::Periwinkle => Palette::PERIWINKLE,
TuiTheme::AmberPhosphor => Palette::AMBER_PHOSPHOR,
TuiTheme::Ember => Palette::EMBER,
TuiTheme::Orchid => Palette::ORCHID,
TuiTheme::Ansi => Palette::ANSI,
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
@@ -172,6 +177,11 @@ mod tests {
for (theme, expected) in [
(TuiTheme::Dark, Palette::DARK),
(TuiTheme::Light, Palette::LIGHT),
(TuiTheme::IceCyan, Palette::ICE_CYAN),
(TuiTheme::Periwinkle, Palette::PERIWINKLE),
(TuiTheme::AmberPhosphor, Palette::AMBER_PHOSPHOR),
(TuiTheme::Ember, Palette::EMBER),
(TuiTheme::Orchid, Palette::ORCHID),
(TuiTheme::Ansi, Palette::ANSI),
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
+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());
}
}
+131 -49
View File
@@ -221,6 +221,11 @@ 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.
@@ -293,6 +298,7 @@ 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(),
@@ -315,7 +321,9 @@ impl Default for DraftPane {
branches_for: None,
needs_branches: false,
error: None,
focused_field: DraftField::Preset,
// The picker owns the keyboard on open: typing filters projects
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
home_dir: String::new(),
}
}
@@ -358,6 +366,26 @@ 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
@@ -705,14 +733,14 @@ impl DraftPane {
}
fn field_order(&self) -> Vec<DraftField> {
let mut order = vec![DraftField::Preset];
if self.is_custom() {
order.push(DraftField::CustomCommand);
}
order.push(DraftField::DirPicker);
let mut order = vec![DraftField::DirPicker];
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
}
order.push(DraftField::Preset);
if self.is_custom() {
order.push(DraftField::CustomCommand);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
@@ -1116,15 +1144,17 @@ impl DraftPane {
+ u16::from(self.error.is_some());
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
let mut constraints = vec![Constraint::Length(1)];
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1));
// 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 {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // command tiles
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_choice {
@@ -1163,36 +1193,6 @@ 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,6 +1280,36 @@ 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:");
@@ -1681,7 +1711,10 @@ mod tests {
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = DraftPane::default();
let mut draft = DraftPane {
focused_field: DraftField::Preset,
..DraftPane::default()
};
draft.on_input(&InputKey::Right);
assert_eq!(draft.command(), "opencode");
draft.on_input(&InputKey::Left);
@@ -1704,17 +1737,22 @@ mod tests {
draft.selected_preset = draft.presets.len() - 1;
assert!(draft.is_custom());
assert_eq!(
draft.field_order()[..2],
[DraftField::Preset, DraftField::CustomCommand],
"custom command sits between presets and the dir picker"
draft.field_order()[..3],
[
DraftField::DirPicker,
DraftField::Preset,
DraftField::CustomCommand
],
"the picker leads; custom command sits right after the tiles"
);
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::DirPicker);
assert_eq!(draft.focused_field, DraftField::Name);
}
#[test]
@@ -1777,8 +1815,11 @@ mod tests {
],
Utc::now(),
);
draft.next_field(); // Preset → DirPicker
assert_eq!(draft.focused_field, DraftField::DirPicker);
assert_eq!(
draft.focused_field,
DraftField::DirPicker,
"the form opens with the project filter focused"
);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_groups().len(),
@@ -1890,6 +1931,47 @@ 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();
@@ -2140,7 +2222,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, 1));
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
}
#[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,
+234 -49
View File
@@ -13,15 +13,17 @@
//!
//! Each surface carries its own activity colours. A state colour picked for
//! the dark card background is not legible on the selection background, so
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//! the selected card uses the selection surface's own state colours; the
//! rail keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
//! colours lifted or darkened where the originals fall short of the contrast
//! floors asserted below.
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
//! terminal-provided ANSI colours. Five house presets recolour the graphite
//! base with an accent (Ice Cyan, Periwinkle, Amber Phosphor, Ember, Orchid);
//! the remaining presets ([`Palette::presets`]) adapt well-known editor
//! themes — Solarized, Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord,
//! Dracula — with individual colours lifted or darkened where the originals
//! fall short of the contrast floors asserted below.
//!
//! Out of scope: PTY cell colours. Those belong to the program running in the
//! pane and pass through `pane::translate_color` verbatim.
@@ -135,73 +137,75 @@ pub struct Palette {
}
impl Palette {
/// The dark terminal-native palette and the default preset.
/// The graphite dark palette and the default preset: pure near-neutral
/// chrome with an ink-coloured accent, so activity colours are the only
/// hue on screen.
pub const DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1f, 0x23, 0x2b),
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
muted: Color::Rgb(0xae, 0xb7, 0xc4),
dim: Color::Rgb(0x85, 0x8f, 0x9e),
bg: Color::Rgb(0x1d, 0x1f, 0x22),
fg: Color::Rgb(0xe6, 0xe8, 0xeb),
muted: Color::Rgb(0xb1, 0xb4, 0xb9),
dim: Color::Rgb(0x86, 0x89, 0x8e),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xae, 0xb7, 0xc4),
idle: Color::Rgb(0xb1, 0xb4, 0xb9),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x3d, 0x51, 0x44),
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
bg: Color::Rgb(0x3f, 0x43, 0x48),
fg: Color::Rgb(0xf0, 0xf2, 0xf5),
muted: Color::Rgb(0xce, 0xd1, 0xd5),
dim: Color::Rgb(0xa5, 0xa8, 0xab),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
idle: Color::Rgb(0xd5, 0xd8, 0xdb),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
chrome_bg: Color::Rgb(0x1d, 0x1f, 0x22),
sidebar_focus_bg: Color::Rgb(0x28, 0x2a, 0x2d),
border_focused: Color::Rgb(0xe2, 0xe5, 0xe8),
border_unfocused: Color::Rgb(0x72, 0x75, 0x79),
focus_accent: Color::Rgb(0xe2, 0xe5, 0xe8),
group_row: Color::Rgb(0xba, 0xbe, 0xc3),
mode_normal_bg: Color::Rgb(0xe2, 0xe5, 0xe8),
mode_normal_fg: Color::Rgb(0x17, 0x1b, 0x20),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
mode_insert_fg: Color::Rgb(0x1d, 0x1f, 0x22),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// A high-contrast light peer to [`Self::DARK`].
/// A high-contrast light peer to [`Self::DARK`]: ink-on-paper graphite.
pub const LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xff, 0xff, 0xff),
fg: Color::Rgb(0x27, 0x31, 0x3d),
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
dim: Color::Rgb(0x68, 0x78, 0x8a),
fg: Color::Rgb(0x20, 0x24, 0x2a),
muted: Color::Rgb(0x56, 0x5b, 0x63),
dim: Color::Rgb(0x7b, 0x81, 0x89),
active: Color::Rgb(0x21, 0x7a, 0x3c),
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
idle: Color::Rgb(0x56, 0x5b, 0x63),
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
error: Color::Rgb(0xb4, 0x23, 0x18),
},
selection: Surface {
bg: Color::Rgb(0xb5, 0xcf, 0xba),
fg: Color::Rgb(0x1d, 0x2a, 0x24),
muted: Color::Rgb(0x3f, 0x55, 0x45),
dim: Color::Rgb(0x52, 0x68, 0x5a),
bg: Color::Rgb(0xc5, 0xc7, 0xca),
fg: Color::Rgb(0x15, 0x19, 0x1e),
muted: Color::Rgb(0x43, 0x48, 0x4f),
dim: Color::Rgb(0x5e, 0x64, 0x6b),
active: Color::Rgb(0x15, 0x5b, 0x2e),
idle: Color::Rgb(0x3f, 0x55, 0x45),
idle: Color::Rgb(0x38, 0x3e, 0x45),
awaiting: Color::Rgb(0x69, 0x46, 0x00),
error: Color::Rgb(0xa5, 0x1d, 0x14),
error: Color::Rgb(0x99, 0x1a, 0x12),
},
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
group_row: Color::Rgb(0x3f, 0x55, 0x45),
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
sidebar_focus_bg: Color::Rgb(0xef, 0xf0, 0xf2),
border_focused: Color::Rgb(0x36, 0x3b, 0x41),
border_unfocused: Color::Rgb(0x76, 0x7b, 0x82),
focus_accent: Color::Rgb(0x36, 0x3b, 0x41),
group_row: Color::Rgb(0x4e, 0x53, 0x5a),
mode_normal_bg: Color::Rgb(0x23, 0x27, 0x2c),
mode_normal_fg: Color::Rgb(0xf9, 0xfa, 0xfb),
mode_insert_bg: Color::Rgb(0x8f, 0x63, 0x00),
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
error_text: Color::Rgb(0xb4, 0x23, 0x18),
};
@@ -556,14 +560,195 @@ impl Palette {
error_text: Color::Rgb(0xff, 0x59, 0x59),
};
/// Ice Cyan — signal-cyan accent on the graphite slate base.
pub const ICE_CYAN: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1c, 0x20, 0x25),
fg: Color::Rgb(0xe4, 0xe8, 0xed),
muted: Color::Rgb(0xaf, 0xb5, 0xbc),
dim: Color::Rgb(0x84, 0x8a, 0x91),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xaf, 0xb5, 0xbc),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x24, 0x4d, 0x59),
fg: Color::Rgb(0xee, 0xf2, 0xf7),
muted: Color::Rgb(0xcd, 0xd1, 0xd7),
dim: Color::Rgb(0xa4, 0xa8, 0xae),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xd3, 0xd8, 0xde),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x1c, 0x20, 0x25),
sidebar_focus_bg: Color::Rgb(0x26, 0x2a, 0x30),
border_focused: Color::Rgb(0x61, 0xe3, 0xf1),
border_unfocused: Color::Rgb(0x70, 0x75, 0x7b),
focus_accent: Color::Rgb(0x61, 0xe3, 0xf1),
group_row: Color::Rgb(0xb8, 0xbe, 0xc6),
mode_normal_bg: Color::Rgb(0x61, 0xe3, 0xf1),
mode_normal_fg: Color::Rgb(0x0f, 0x22, 0x2b),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x1c, 0x20, 0x25),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// Periwinkle — indigo-tinted base with a soft blue-violet accent.
pub const PERIWINKLE: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1d, 0x1e, 0x29),
fg: Color::Rgb(0xe5, 0xe7, 0xf1),
muted: Color::Rgb(0xb1, 0xb3, 0xc2),
dim: Color::Rgb(0x86, 0x88, 0x96),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xb1, 0xb3, 0xc2),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x3b, 0x43, 0x6c),
fg: Color::Rgb(0xef, 0xf1, 0xfb),
muted: Color::Rgb(0xce, 0xd0, 0xdc),
dim: Color::Rgb(0xa5, 0xa7, 0xb2),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xd4, 0xd7, 0xe3),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x1d, 0x1e, 0x29),
sidebar_focus_bg: Color::Rgb(0x27, 0x29, 0x35),
border_focused: Color::Rgb(0x97, 0xa7, 0xfd),
border_unfocused: Color::Rgb(0x71, 0x74, 0x80),
focus_accent: Color::Rgb(0x97, 0xa7, 0xfd),
group_row: Color::Rgb(0xba, 0xbd, 0xcc),
mode_normal_bg: Color::Rgb(0x97, 0xa7, 0xfd),
mode_normal_fg: Color::Rgb(0x12, 0x14, 0x28),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x1d, 0x1e, 0x29),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// Amber Phosphor — CRT amber on warm graphite. The awaiting colour
/// shifts orange so it stays distinct from the amber accent.
pub const AMBER_PHOSPHOR: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x22, 0x1f, 0x1a),
fg: Color::Rgb(0xec, 0xe7, 0xe1),
muted: Color::Rgb(0xba, 0xb3, 0xaa),
dim: Color::Rgb(0x8f, 0x88, 0x7f),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xba, 0xb3, 0xaa),
awaiting: Color::Rgb(0xff, 0x9b, 0x50),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x55, 0x43, 0x26),
fg: Color::Rgb(0xf6, 0xf1, 0xeb),
muted: Color::Rgb(0xd6, 0xd0, 0xc9),
dim: Color::Rgb(0xad, 0xa7, 0xa0),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xdd, 0xd6, 0xcf),
awaiting: Color::Rgb(0xff, 0xb9, 0x72),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x22, 0x1f, 0x1a),
sidebar_focus_bg: Color::Rgb(0x2d, 0x29, 0x25),
border_focused: Color::Rgb(0xf9, 0xbb, 0x51),
border_unfocused: Color::Rgb(0x7a, 0x73, 0x6c),
focus_accent: Color::Rgb(0xf9, 0xbb, 0x51),
group_row: Color::Rgb(0xc4, 0xbc, 0xb3),
mode_normal_bg: Color::Rgb(0xf9, 0xbb, 0x51),
mode_normal_fg: Color::Rgb(0x2f, 0x1e, 0x01),
mode_insert_bg: Color::Rgb(0xff, 0x9b, 0x50),
mode_insert_fg: Color::Rgb(0x22, 0x1f, 0x1a),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// Ember — warm orange-red accent on a cleaner warm neutral.
pub const EMBER: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x23, 0x1e, 0x1b),
fg: Color::Rgb(0xed, 0xe6, 0xe3),
muted: Color::Rgb(0xbc, 0xb2, 0xad),
dim: Color::Rgb(0x90, 0x87, 0x82),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xbc, 0xb2, 0xad),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x5d, 0x3e, 0x30),
fg: Color::Rgb(0xf7, 0xf0, 0xed),
muted: Color::Rgb(0xd7, 0xcf, 0xcb),
dim: Color::Rgb(0xae, 0xa6, 0xa2),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xde, 0xd5, 0xd1),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x23, 0x1e, 0x1b),
sidebar_focus_bg: Color::Rgb(0x2e, 0x29, 0x26),
border_focused: Color::Rgb(0xf7, 0x89, 0x55),
border_unfocused: Color::Rgb(0x7b, 0x72, 0x6e),
focus_accent: Color::Rgb(0xf7, 0x89, 0x55),
group_row: Color::Rgb(0xc6, 0xbb, 0xb6),
mode_normal_bg: Color::Rgb(0xf7, 0x89, 0x55),
mode_normal_fg: Color::Rgb(0x2a, 0x14, 0x0a),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x23, 0x1e, 0x1b),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// Orchid — muted violet accent on a faintly violet base.
pub const ORCHID: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x21, 0x1e, 0x25),
fg: Color::Rgb(0xe9, 0xe6, 0xee),
muted: Color::Rgb(0xb6, 0xb2, 0xbd),
dim: Color::Rgb(0x8b, 0x87, 0x91),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xb6, 0xb2, 0xbd),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x51, 0x3c, 0x63),
fg: Color::Rgb(0xf3, 0xf0, 0xf8),
muted: Color::Rgb(0xd3, 0xcf, 0xd8),
dim: Color::Rgb(0xa9, 0xa6, 0xaf),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xd9, 0xd5, 0xdf),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x21, 0x1e, 0x25),
sidebar_focus_bg: Color::Rgb(0x2c, 0x28, 0x30),
border_focused: Color::Rgb(0xd1, 0x9f, 0xf5),
border_unfocused: Color::Rgb(0x76, 0x72, 0x7c),
focus_accent: Color::Rgb(0xd1, 0x9f, 0xf5),
group_row: Color::Rgb(0xc0, 0xbb, 0xc7),
mode_normal_bg: Color::Rgb(0xd1, 0x9f, 0xf5),
mode_normal_fg: Color::Rgb(0x22, 0x13, 0x2c),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x21, 0x1e, 0x25),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// The default preset, retained as a named compatibility constant.
pub const DEFAULT: Palette = Self::DARK;
/// All preset names and their complete palettes.
pub fn presets() -> [(&'static str, Palette); 12] {
pub fn presets() -> [(&'static str, Palette); 17] {
[
("dark", Self::DARK),
("light", Self::LIGHT),
("ice-cyan", Self::ICE_CYAN),
("periwinkle", Self::PERIWINKLE),
("amber-phosphor", Self::AMBER_PHOSPHOR),
("ember", Self::EMBER),
("orchid", Self::ORCHID),
("ansi", Self::ANSI),
("solarized-dark", Self::SOLARIZED_DARK),
("solarized-light", Self::SOLARIZED_LIGHT),
+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();
+48 -32
View File
@@ -1,7 +1,7 @@
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
//!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity rail + duration in the state's colour, plus the
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::{BTreeMap, HashSet};
@@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30;
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
const INDENT: &str = " ";
const META_INDENT: &str = " ";
/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
@@ -203,7 +206,9 @@ impl Sidebar {
);
let chevron = Span::styled(chevron, base);
let count = Span::styled(count, surface.muted_style().bg(row_bg));
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
// "1 +" is the blank leading column matching the session rows' rail.
let label_budget =
width.saturating_sub(1 + prefix.width() + chevron.width() + count.width());
let mut label = Vec::new();
if let Some(stem) = name.stem {
label.push(Span::styled(
@@ -213,7 +218,9 @@ impl Sidebar {
}
label.push(Span::styled(name.last, base));
let mut spans = vec![prefix, chevron];
// A blank leading column, matching the width the activity rail adds
// to session rows, so folder labels keep aligning under names.
let mut spans = vec![Span::styled(" ", base), prefix, chevron];
spans.extend(fit_to_width(label, label_budget));
spans.push(count);
pad_to_width(spans, width, base)
@@ -227,11 +234,13 @@ impl Sidebar {
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let prefix = INDENT.chars().count() + 2;
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
if edge { "▏ " } else { INDENT },
if edge {
@@ -250,7 +259,8 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
/// The adaptive second line: `[<duration>] [<detail>] <command> [<blocked work> | exited]`,
/// led by the activity rail (docs/SIDEBAR.md).
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
@@ -261,7 +271,7 @@ impl Sidebar {
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let dot_style =
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active()
.surface(highlighted && focused)
@@ -269,6 +279,7 @@ impl Sidebar {
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(RAIL, rail_style),
Span::styled(
if edge { " ▏ " } else { META_INDENT },
if edge {
@@ -277,11 +288,9 @@ impl Sidebar {
base
},
),
Span::styled("●", dot_style),
];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
spans.push(Span::styled(format_state_duration(d), rail_style));
}
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
@@ -320,8 +329,8 @@ impl Sidebar {
budget,
));
}
spans.extend(tail);
spans.extend(fit_to_width(vec![command], command_w));
spans.extend(tail);
spans
}
}
@@ -689,7 +698,7 @@ mod tests {
/// The additions and deletions are the only coloured fragments: they are
/// what a reader scans for, and colouring the rest would make the line
/// compete with the activity dot.
/// compete with the activity rail.
#[test]
fn the_diff_colours_only_its_additions_and_deletions() {
let mut s = session_named(1, "claude");
@@ -735,8 +744,8 @@ mod tests {
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[3].contains('●'),
"line 2 must carry the dot: {:?}",
all[3].contains(RAIL),
"line 2 must carry the rail: {:?}",
all[3]
);
assert!(
@@ -752,7 +761,7 @@ mod tests {
}
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
/// meta line, with the floored duration next to the dot.
/// meta line, with the floored duration next to the rail.
#[test]
fn long_held_session_gets_a_meta_line_with_duration() {
let mut s = session_named(1, "claude");
@@ -765,7 +774,7 @@ mod tests {
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
}
@@ -782,10 +791,10 @@ mod tests {
assert_eq!(fmt(7200), "2h");
}
/// The dot and duration carry the activity state's colour; the state word
/// is never spelled out (docs/SIDEBAR.md colour table).
/// The rail and duration carry the activity state's colour; the state
/// word is never spelled out (docs/SIDEBAR.md colour table).
#[test]
fn meta_line_dot_uses_the_contract_colour_per_state() {
fn meta_line_rail_uses_the_contract_colour_per_state() {
for state in [
ActivityState::Active,
ActivityState::Idle,
@@ -808,7 +817,7 @@ mod tests {
}
}
/// The selected card keeps carrying its state on the dot, but in the
/// The selected card keeps carrying its state on the rail, but in the
/// selection surface's own colours. A state colour picked for the dark
/// card background painted onto the selection background is the contrast
/// bug this palette fixes — this test fails if it comes back.
@@ -845,7 +854,7 @@ mod tests {
);
assert!(
on_selection(p.selection.activity(state)),
"{state:?}: the dot must still carry the state on the selected card"
"{state:?}: the rail must still carry the state on the selected card"
);
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
.expect("palette roles are RGB");
@@ -918,9 +927,11 @@ mod tests {
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
let ellipsis = meta.find('…').expect("truncated command must contain …");
let tail = meta.find("~120").expect("diff stat must survive: {meta:?}");
assert!(
meta.trim_end().ends_with('…'),
"truncated command must end with …: {meta:?}"
ellipsis < tail,
"command must truncate before the diff stat: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -945,7 +956,7 @@ mod tests {
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
assert!(
meta.contains("1m"),
"duration precedes the detail: {meta:?}"
@@ -1018,7 +1029,7 @@ mod tests {
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
/// `activity_since` and so contributes no duration — a card must not claim
/// the session has been idle since 1970. The dot still renders when some
/// the session has been idle since 1970. The rail still renders when some
/// other trigger puts line 2 on screen.
#[test]
fn epoch_activity_since_renders_no_duration() {
@@ -1035,10 +1046,10 @@ mod tests {
rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}"
);
for line in rendered.lines().filter(|l| l.contains('●')) {
for line in rendered.lines().filter(|l| l.contains(RAIL)) {
assert!(
!line
.split('●')
.split(RAIL)
.nth(1)
.unwrap_or("")
.trim_start()
@@ -1101,7 +1112,7 @@ mod tests {
}
#[test]
fn narrow_meta_line_keeps_dot_and_truncates_command() {
fn narrow_meta_line_keeps_rail_and_truncates_command() {
let s = session_named(1, "name");
let sb = Sidebar {
sessions: vec![s],
@@ -1110,8 +1121,8 @@ mod tests {
};
let all = rows(&draw(&sb, 9, 6));
assert!(
all[3].contains('●'),
"dot must survive narrow cards: {:?}",
all[3].contains(RAIL),
"rail must survive narrow cards: {:?}",
all[3]
);
assert!(
@@ -1122,7 +1133,7 @@ mod tests {
}
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
fn blocked_warning_survives_after_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay {
@@ -1137,7 +1148,12 @@ mod tests {
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
let ellipsis = meta.find('…').expect("command must truncate");
let tail = meta.find("~1").unwrap();
assert!(
ellipsis < tail,
"diff stat must follow the truncated command: {meta:?}"
);
}
#[test]
+13 -6
View File
@@ -154,15 +154,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
# want extra Insert-mode bindings)
```
`theme` selects the complete TUI palette. `dark` is the default and follows
Maestro's terminal-native dark product palette; `light` is its light peer;
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
The remaining presets adapt well-known editor themes, with individual colours
lifted or darkened where the originals fall short of the WCAG AA contrast
floors the palette tests assert:
`theme` selects the complete TUI palette. `dark` is the default: Maestro's
graphite palette, near-neutral chrome where the activity colours are the only
hue on screen; `light` is its light peer; `ansi` uses the terminal's named
ANSI colours for terminals without truecolour. Five house presets recolour
the graphite base with an accent, and the remaining presets adapt well-known
editor themes — in both groups individual colours are lifted or darkened
where they would fall short of the WCAG AA contrast floors the palette tests
assert:
| name | based on |
|-------------------|------------------|
| `ice-cyan` | graphite base, signal-cyan accent |
| `periwinkle` | indigo base, blue-violet accent |
| `amber-phosphor` | warm graphite, CRT-amber accent |
| `ember` | warm graphite, orange-red accent |
| `orchid` | violet base, muted violet accent |
| `solarized-dark` | Solarized Dark |
| `solarized-light` | Solarized Light |
| `catppuccin` | Catppuccin Mocha |
+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.
+34 -12
View File
@@ -37,23 +37,45 @@ itself is not.
Line 2 reads left to right:
```
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
[<duration>] [<detail>] <command> [<diff stat> | exited]
```
Its minimum content is therefore the dot and the command.
Its minimum content is therefore just the command.
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
state's colour, and `<duration>` follows in the same colour when shown. There
is no state word and no second badge: the dot exists so the state is still
legible when a row is dimmed or the terminal renders the duration in the
default fg. The dot is an unconditional part of line 2; the bracketed
fragments appear only when their trigger holds.
The activity state does not render as an inline glyph on line 2 — a **rail**
carries it instead: a coloured bar in the gutter to the left of the card, the
space nesting already reserves for indentation. The rail paints for every
card, `Idle` included, and is independent of whatever line 2 has to say; a
card with nothing else to report still shows its rail next to the bare
command. `<duration>` follows in the rail's colour when shown. There is no
state word and no second badge: the rail exists so the state is still legible
when a row is dimmed or the terminal renders the duration in the default fg.
In the **web client** the rail is a 5px bar positioned inside the row's own
indent gutter — it shifts outward with nesting depth exactly as the gutter
does — spanning the full card height. It carries motion, reserved for states
that need attention (DESIGN.md's "living signals" rule): a slow breathing
opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while
`Error`. `Idle` is solid and still, on purpose. `prefers-reduced-motion`
leaves every state as a solid bar.
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
both the name row and the meta row, so the two lines read as one continuous
bar. Folder headers gain a matching blank leading column so labels still
align under session names. The TUI has no render cadence to animate against,
so its rail is always static — colour is the whole signal there, as it
always was.
Either client's rail carries the accessibility duty the old inline dot did:
it is the element whose `title`/`aria-label` names the state, so status still
never relies on colour alone.
`<duration>` renders only once the session has been held in its current
activity state longer than **30 seconds** (`activity_since` from #102) — under
that threshold the duration is absent and the dot renders alone. A payload
that predates #102 (no usable `activity_since`) likewise contributes no
duration — the dot still renders. This rule now governs only the duration
that threshold the duration is absent and the rail carries the state alone. A
payload that predates #102 (no usable `activity_since`) likewise contributes
no duration — the rail still renders. This rule now governs only the duration
fragment; it no longer decides whether line 2 exists.
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
@@ -61,7 +83,7 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
+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";
+32 -7
View File
@@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
@@ -433,11 +433,17 @@ export function createAttachConnection(
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
* - `char` is a literal character (the symbols the soft keyboard buries behind
* a page switch), folded through a pending modifier like typed input.
* - `seq` is a plain key (Esc, Tab) sent verbatim.
*/
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
type Modifier = "ctrl" | "alt";
@@ -445,14 +451,17 @@ type Modifier = "ctrl" | "alt";
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
/**
* The keys the mobile soft keyboard hides, ordered to flow row-major into a
* 5-column / 2-row grid (NAP-342 review):
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
* 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End
* Ctrl Alt ← ↓ →
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as
* a real-keyboard inverted-T on the right, with Home/End filling its top corners.
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
* then the paging pair and the shell symbols that cost a soft-keyboard page
* switch to reach. Narrower screens scroll the bar sideways rather than
* shrinking the caps below a thumb's width.
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
@@ -460,11 +469,17 @@ const KEY_DEFS: KeyDef[] = [
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
@@ -521,6 +536,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar {
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
clearMods();
});
} else if ("tilde" in def) {
btn.addEventListener("click", () => {
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
clearMods();
});
} else if ("char" in def) {
btn.addEventListener("click", () => {
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
clearMods();
});
} else {
btn.addEventListener("click", () => {
send(def.seq);
+9 -7
View File
@@ -224,9 +224,10 @@ describe("session cards (#107)", () => {
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
@@ -238,9 +239,10 @@ describe("session cards (#107)", () => {
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
@@ -355,9 +357,9 @@ describe("session cards (#107)", () => {
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const dot = row.querySelector<HTMLElement>(".activity-dot");
expect(dot?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
+28 -8
View File
@@ -24,7 +24,9 @@ import {
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
import { createPaletteSelect, 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. */
@@ -56,8 +58,9 @@ const collapsed = new Set<string>();
* would otherwise replace the field the user is typing into. */
let renamingId: string | null = null;
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
// carries it. It survives only in tooltips/aria labels so meaning is not
// colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
@@ -65,24 +68,31 @@ const STATE_WORDS: Record<string, string> = {
error: "Error",
};
/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent
* gutter, so it shifts outward with nesting depth exactly as that gutter
* does. `depth * 16 + 8` is the same expression `renderNode` uses for the
* row's own padding — the rail sits just inside it. */
function activityRailMarkup(slug: string, stateWord: string, depth: number): string {
const left = depth * 16 + 2;
return `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
@@ -154,14 +164,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
const stateWord =
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
${activityRailMarkup(slug, stateWord, depth)}
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions">
@@ -336,6 +347,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 {
@@ -380,6 +394,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle());
footer.appendChild(createPaletteSelect());
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
@@ -390,6 +405,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 +636,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 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js";
import { applyModsToChar, cursorSeq, tildeSeq, toCtrl } from "./keys.js";
describe("toCtrl", () => {
it("maps letters to their control code, case-insensitively", () => {
@@ -43,3 +43,16 @@ describe("cursorSeq", () => {
expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A");
});
});
describe("tildeSeq", () => {
it("uses the bare CSI form when unmodified", () => {
expect(tildeSeq(5, false, false)).toBe("\x1b[5~");
expect(tildeSeq(6, false, false)).toBe("\x1b[6~");
});
it("keeps the parameter in place when modified, unlike the cursor keys", () => {
expect(tildeSeq(5, true, false)).toBe("\x1b[5;5~");
expect(tildeSeq(6, false, true)).toBe("\x1b[6;3~");
expect(tildeSeq(6, true, true)).toBe("\x1b[6;7~");
});
});
+14 -2
View File
@@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string
* pending — `\x1b[1;<n><final>`, n = 1 + alt(2) + ctrl(4) (xterm convention). */
export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string {
if (!ctrl && !alt) return `\x1b[${final}`;
const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
return `\x1b[1;${n}${final}`;
return `\x1b[1;${modParam(ctrl, alt)}${final}`;
}
/** Modifier parameter of the xterm CSI forms: n = 1 + alt(2) + ctrl(4). */
function modParam(ctrl: boolean, alt: boolean): number {
return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
}
/** Sequence for the `~`-terminated navigation keys (PgUp `5`, PgDn `6`), which
* carry modifiers as `\x1b[<code>;<n>~` rather than the cursor keys' `1;<n>`
* form. */
export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string {
if (!ctrl && !alt) return `\x1b[${code}~`;
return `\x1b[${code};${modParam(ctrl, alt)}~`;
}
+14 -7
View File
@@ -4,7 +4,7 @@
// critiqued as a whole — buttons (including terminal-native variants for
// review), inputs, chips, badges, session rows, and empty states.
import "./kit.css";
import { createThemeToggle } from "./theme.js";
import { createPaletteSelect, createThemeToggle } from "./theme.js";
function h(tag: string, className?: string, html?: string): HTMLElement {
const el = document.createElement(tag);
@@ -66,12 +66,15 @@ function leafRow(opts: {
(row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active");
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
const depth = opts.indent ?? 0;
row.style.paddingLeft = `${depth * 16 + 8}px`;
const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
const activity = opts.activity ?? "idle";
row.innerHTML = `
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
@@ -79,7 +82,7 @@ function leafRow(opts: {
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
@@ -102,7 +105,7 @@ export function renderKit(): HTMLElement {
back.className = "btn btn-ghost";
back.href = "?mock";
back.textContent = "← back to app";
headerActions.append(back, createThemeToggle());
headerActions.append(back, createThemeToggle(), createPaletteSelect());
header.appendChild(headerActions);
page.appendChild(header);
@@ -113,7 +116,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Color tokens",
"Slate neutrals (hue ~256) + signal-green = alive. Swap by theme toggle.",
"Graphite neutrals; activity colours are the only hue. Swap by theme toggle + palette picker.",
);
body.classList.add("kit-swatches");
const tokens = [
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
`<span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
const rail = h("span", "activity-rail");
rail.dataset.activity = "active";
rail.style.cssText = "position:static;display:inline-block;height:14px;";
body.append(
specimen("folder-count", h("span", "folder-count", "4")),
specimen("activity-rail · active", rail),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
@@ -225,7 +232,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
);
const list = h("div", "kit-tree");
list.append(
+21 -2
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 { spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarProjectSeed, 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,7 +255,14 @@ 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.
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
// 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 }),
});
}
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();
},
};
}
+14 -8
View File
@@ -122,12 +122,6 @@ 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…"
@@ -139,6 +133,12 @@ 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,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
// 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]);
}
form.requestSubmit();
});
+23 -2
View File
@@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
async function mount(
projects: Project[],
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
@@ -232,6 +235,24 @@ 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,6 +52,16 @@ 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,6 +59,9 @@ 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;
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
function session(overrides: Partial<Session> = {}): Session {
@@ -43,3 +43,23 @@ 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,3 +28,17 @@ 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();
},
};
}
+577 -59
View File
@@ -1,8 +1,12 @@
/* =========================================================================
maestro web — design system
Register: product (a tool; design serves the task).
Identity: precise, calm, alive. Monospace-as-UI; signal-green = "alive".
Identity: precise, calm, alive. Monospace-as-UI; graphite chrome where the
activity colours (green = alive) are the only hue on screen.
Theme: dark (default) + light, all via OKLCH custom-property tokens.
Palette: `data-palette` on <html> swaps the accent + base tint (see the
"palettes" section at the end of the token area); graphite is the bare
document.
========================================================================= */
:root {
@@ -63,24 +67,25 @@
/* ---- color: dark (default) ---- */
color-scheme: dark;
--bg: oklch(0.178 0.012 256);
--bg-grid: oklch(0.215 0.014 256);
--surface: oklch(0.214 0.014 256);
--surface-2: oklch(0.252 0.016 256);
--surface-3: oklch(0.292 0.018 256);
--border: oklch(0.318 0.018 256);
--border-strong: oklch(0.4 0.022 256);
--bg: oklch(0.172 0.006 256);
--bg-grid: oklch(0.21 0.007 256);
--surface: oklch(0.208 0.007 256);
--surface-2: oklch(0.246 0.008 256);
--surface-3: oklch(0.286 0.009 256);
--border: oklch(0.312 0.01 256);
--border-strong: oklch(0.395 0.012 256);
--ink: oklch(0.945 0.006 256);
--muted: oklch(0.722 0.014 256);
--faint: oklch(0.58 0.014 256);
--ink: oklch(0.945 0.004 256);
--muted: oklch(0.72 0.008 256);
--faint: oklch(0.58 0.008 256);
/* signal green = "alive", primary action, focus, selection */
--accent: oklch(0.83 0.16 152);
--accent-strong: oklch(0.88 0.17 152);
--accent-ink: oklch(0.2 0.03 256);
--accent-tint: oklch(0.83 0.16 152 / 0.12);
--accent-ring: oklch(0.83 0.16 152 / 0.55);
/* graphite: the accent is ink itself — primary action, focus, selection.
The activity colours below stay the only hue on screen. */
--accent: oklch(0.92 0.005 256);
--accent-strong: oklch(0.97 0.003 256);
--accent-ink: oklch(0.2 0.012 256);
--accent-tint: oklch(0.92 0.005 256 / 0.1);
--accent-ring: oklch(0.92 0.005 256 / 0.5);
--live: oklch(0.84 0.17 152);
--live-glow: oklch(0.84 0.17 152 / 0.55);
@@ -92,30 +97,30 @@
--shadow-1: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow-2:
0 12px 28px -8px oklch(0 0 0 / 0.6), 0 2px 8px oklch(0 0 0 / 0.4);
--term-bg: oklch(0.158 0.012 256);
--term-bg: oklch(0.155 0.006 256);
}
:root[data-theme="light"],
:root[data-theme="light"] body {
color-scheme: light;
--bg: oklch(0.985 0.003 256);
--bg-grid: oklch(0.93 0.006 256);
--bg: oklch(0.985 0.002 256);
--bg-grid: oklch(0.93 0.004 256);
--surface: oklch(1 0 0);
--surface-2: oklch(0.965 0.004 256);
--surface-3: oklch(0.93 0.006 256);
--border: oklch(0.892 0.008 256);
--border-strong: oklch(0.82 0.012 256);
--surface-2: oklch(0.965 0.003 256);
--surface-3: oklch(0.93 0.004 256);
--border: oklch(0.892 0.005 256);
--border-strong: oklch(0.82 0.008 256);
--ink: oklch(0.26 0.014 256);
--muted: oklch(0.47 0.016 256);
--faint: oklch(0.62 0.016 256);
--ink: oklch(0.26 0.012 256);
--muted: oklch(0.47 0.012 256);
--faint: oklch(0.62 0.012 256);
--accent: oklch(0.6 0.15 151);
--accent-strong: oklch(0.55 0.16 151);
--accent-ink: oklch(0.99 0.01 152);
--accent-tint: oklch(0.6 0.15 151 / 0.12);
--accent-ring: oklch(0.6 0.15 151 / 0.5);
--accent: oklch(0.27 0.01 256);
--accent-strong: oklch(0.2 0.01 256);
--accent-ink: oklch(0.985 0.002 256);
--accent-tint: oklch(0.27 0.01 256 / 0.1);
--accent-ring: oklch(0.27 0.01 256 / 0.45);
--live: oklch(0.6 0.16 151);
--live-glow: oklch(0.6 0.16 151 / 0.45);
@@ -136,23 +141,23 @@
:root:not([data-theme]) {
color-scheme: light;
--bg: oklch(0.985 0.003 256);
--bg-grid: oklch(0.93 0.006 256);
--bg: oklch(0.985 0.002 256);
--bg-grid: oklch(0.93 0.004 256);
--surface: oklch(1 0 0);
--surface-2: oklch(0.965 0.004 256);
--surface-3: oklch(0.93 0.006 256);
--border: oklch(0.892 0.008 256);
--border-strong: oklch(0.82 0.012 256);
--surface-2: oklch(0.965 0.003 256);
--surface-3: oklch(0.93 0.004 256);
--border: oklch(0.892 0.005 256);
--border-strong: oklch(0.82 0.008 256);
--ink: oklch(0.26 0.014 256);
--muted: oklch(0.47 0.016 256);
--faint: oklch(0.62 0.016 256);
--ink: oklch(0.26 0.012 256);
--muted: oklch(0.47 0.012 256);
--faint: oklch(0.62 0.012 256);
--accent: oklch(0.6 0.15 151);
--accent-strong: oklch(0.55 0.16 151);
--accent-ink: oklch(0.99 0.01 152);
--accent-tint: oklch(0.6 0.15 151 / 0.12);
--accent-ring: oklch(0.6 0.15 151 / 0.5);
--accent: oklch(0.27 0.01 256);
--accent-strong: oklch(0.2 0.01 256);
--accent-ink: oklch(0.985 0.002 256);
--accent-tint: oklch(0.27 0.01 256 / 0.1);
--accent-ring: oklch(0.27 0.01 256 / 0.45);
--live: oklch(0.6 0.16 151);
--live-glow: oklch(0.6 0.16 151 / 0.45);
@@ -169,6 +174,311 @@
}
}
/* ---- palettes ----
`data-palette` on <html> recolours the accent + base tint on top of the
light/dark machinery above. Graphite (the default) is the bare document.
Each palette ships a dark block, a pinned-light block, and a system-light
block; `--live`/`--danger`/`--warn` stay semantic and are not redefined
(except amber-phosphor, whose accent would collide with the stock warn). */
/* ice-cyan: signal-cyan accent on the slate base. */
:root[data-palette="ice-cyan"] {
--bg: oklch(0.178 0.012 256);
--bg-grid: oklch(0.215 0.014 256);
--surface: oklch(0.214 0.014 256);
--surface-2: oklch(0.252 0.016 256);
--surface-3: oklch(0.292 0.018 256);
--border: oklch(0.318 0.018 256);
--border-strong: oklch(0.4 0.022 256);
--ink: oklch(0.945 0.006 256);
--muted: oklch(0.722 0.014 256);
--faint: oklch(0.58 0.014 256);
--accent: oklch(0.85 0.115 205);
--accent-strong: oklch(0.89 0.12 205);
--accent-ink: oklch(0.24 0.03 230);
--accent-tint: oklch(0.85 0.115 205 / 0.12);
--accent-ring: oklch(0.85 0.115 205 / 0.55);
--term-bg: oklch(0.158 0.012 256);
}
:root[data-palette="ice-cyan"][data-theme="light"],
:root[data-palette="ice-cyan"][data-theme="light"] body {
--bg: oklch(0.985 0.003 256);
--bg-grid: oklch(0.93 0.006 256);
--surface: oklch(1 0 0);
--surface-2: oklch(0.965 0.004 256);
--surface-3: oklch(0.93 0.006 256);
--border: oklch(0.892 0.008 256);
--border-strong: oklch(0.82 0.012 256);
--ink: oklch(0.26 0.014 256);
--muted: oklch(0.47 0.016 256);
--faint: oklch(0.62 0.016 256);
--accent: oklch(0.52 0.1 220);
--accent-strong: oklch(0.47 0.11 220);
--accent-ink: oklch(0.99 0.005 220);
--accent-tint: oklch(0.52 0.1 220 / 0.12);
--accent-ring: oklch(0.52 0.1 220 / 0.5);
--term-bg: oklch(0.165 0.012 256);
}
@media (prefers-color-scheme: light) {
:root[data-palette="ice-cyan"]:not([data-theme]) {
--bg: oklch(0.985 0.003 256);
--bg-grid: oklch(0.93 0.006 256);
--surface: oklch(1 0 0);
--surface-2: oklch(0.965 0.004 256);
--surface-3: oklch(0.93 0.006 256);
--border: oklch(0.892 0.008 256);
--border-strong: oklch(0.82 0.012 256);
--ink: oklch(0.26 0.014 256);
--muted: oklch(0.47 0.016 256);
--faint: oklch(0.62 0.016 256);
--accent: oklch(0.52 0.1 220);
--accent-strong: oklch(0.47 0.11 220);
--accent-ink: oklch(0.99 0.005 220);
--accent-tint: oklch(0.52 0.1 220 / 0.12);
--accent-ring: oklch(0.52 0.1 220 / 0.5);
--term-bg: oklch(0.165 0.012 256);
}
}
/* periwinkle: indigo base, blue-violet accent. */
:root[data-palette="periwinkle"] {
--bg: oklch(0.175 0.018 278);
--bg-grid: oklch(0.212 0.021 278);
--surface: oklch(0.212 0.021 278);
--surface-2: oklch(0.25 0.024 278);
--surface-3: oklch(0.29 0.026 278);
--border: oklch(0.318 0.026 278);
--border-strong: oklch(0.4 0.028 278);
--ink: oklch(0.93 0.012 278);
--muted: oklch(0.71 0.02 278);
--faint: oklch(0.57 0.022 278);
--accent: oklch(0.75 0.125 275);
--accent-strong: oklch(0.79 0.13 275);
--accent-ink: oklch(0.18 0.04 278);
--accent-tint: oklch(0.75 0.125 275 / 0.14);
--accent-ring: oklch(0.75 0.125 275 / 0.55);
--term-bg: oklch(0.155 0.018 278);
}
:root[data-palette="periwinkle"][data-theme="light"],
:root[data-palette="periwinkle"][data-theme="light"] body {
--bg: oklch(0.982 0.004 278);
--bg-grid: oklch(0.93 0.008 278);
--surface: oklch(1 0 0);
--surface-2: oklch(0.96 0.006 278);
--surface-3: oklch(0.93 0.008 278);
--border: oklch(0.885 0.01 278);
--border-strong: oklch(0.82 0.014 278);
--ink: oklch(0.27 0.02 278);
--muted: oklch(0.47 0.02 278);
--faint: oklch(0.62 0.02 278);
--accent: oklch(0.5 0.17 272);
--accent-strong: oklch(0.45 0.17 272);
--accent-ink: oklch(0.99 0.005 272);
--accent-tint: oklch(0.5 0.17 272 / 0.12);
--accent-ring: oklch(0.5 0.17 272 / 0.5);
--term-bg: oklch(0.165 0.018 278);
}
@media (prefers-color-scheme: light) {
:root[data-palette="periwinkle"]:not([data-theme]) {
--bg: oklch(0.982 0.004 278);
--bg-grid: oklch(0.93 0.008 278);
--surface: oklch(1 0 0);
--surface-2: oklch(0.96 0.006 278);
--surface-3: oklch(0.93 0.008 278);
--border: oklch(0.885 0.01 278);
--border-strong: oklch(0.82 0.014 278);
--ink: oklch(0.27 0.02 278);
--muted: oklch(0.47 0.02 278);
--faint: oklch(0.62 0.02 278);
--accent: oklch(0.5 0.17 272);
--accent-strong: oklch(0.45 0.17 272);
--accent-ink: oklch(0.99 0.005 272);
--accent-tint: oklch(0.5 0.17 272 / 0.12);
--accent-ring: oklch(0.5 0.17 272 / 0.5);
--term-bg: oklch(0.165 0.018 278);
}
}
/* amber-phosphor: CRT amber on warm graphite. `--warn` shifts orange so the
awaiting colour stays distinct from the accent. */
:root[data-palette="amber-phosphor"] {
--bg: oklch(0.178 0.008 70);
--bg-grid: oklch(0.214 0.01 70);
--surface: oklch(0.214 0.01 70);
--surface-2: oklch(0.252 0.012 70);
--surface-3: oklch(0.292 0.013 70);
--border: oklch(0.318 0.014 70);
--border-strong: oklch(0.4 0.016 70);
--ink: oklch(0.94 0.008 78);
--muted: oklch(0.72 0.014 75);
--faint: oklch(0.58 0.014 75);
--accent: oklch(0.83 0.14 78);
--accent-strong: oklch(0.87 0.145 78);
--accent-ink: oklch(0.25 0.05 78);
--accent-tint: oklch(0.83 0.14 78 / 0.13);
--accent-ring: oklch(0.83 0.14 78 / 0.55);
--warn: oklch(0.76 0.15 55);
--term-bg: oklch(0.158 0.008 70);
}
:root[data-palette="amber-phosphor"][data-theme="light"],
:root[data-palette="amber-phosphor"][data-theme="light"] body {
--bg: oklch(0.983 0.004 78);
--bg-grid: oklch(0.93 0.007 78);
--surface: oklch(1 0 0);
--surface-2: oklch(0.962 0.005 78);
--surface-3: oklch(0.93 0.007 78);
--border: oklch(0.888 0.01 78);
--border-strong: oklch(0.82 0.012 78);
--ink: oklch(0.27 0.015 70);
--muted: oklch(0.47 0.016 70);
--faint: oklch(0.62 0.016 70);
--accent: oklch(0.6 0.13 70);
--accent-strong: oklch(0.55 0.13 70);
--accent-ink: oklch(0.99 0.01 78);
--accent-tint: oklch(0.6 0.13 70 / 0.12);
--accent-ring: oklch(0.6 0.13 70 / 0.5);
--warn: oklch(0.58 0.14 50);
--term-bg: oklch(0.165 0.008 70);
}
@media (prefers-color-scheme: light) {
:root[data-palette="amber-phosphor"]:not([data-theme]) {
--bg: oklch(0.983 0.004 78);
--bg-grid: oklch(0.93 0.007 78);
--surface: oklch(1 0 0);
--surface-2: oklch(0.962 0.005 78);
--surface-3: oklch(0.93 0.007 78);
--border: oklch(0.888 0.01 78);
--border-strong: oklch(0.82 0.012 78);
--ink: oklch(0.27 0.015 70);
--muted: oklch(0.47 0.016 70);
--faint: oklch(0.62 0.016 70);
--accent: oklch(0.6 0.13 70);
--accent-strong: oklch(0.55 0.13 70);
--accent-ink: oklch(0.99 0.01 78);
--accent-tint: oklch(0.6 0.13 70 / 0.12);
--accent-ring: oklch(0.6 0.13 70 / 0.5);
--warn: oklch(0.58 0.14 50);
--term-bg: oklch(0.165 0.008 70);
}
}
/* ember: warm orange-red accent. */
:root[data-palette="ember"] {
--bg: oklch(0.18 0.009 50);
--bg-grid: oklch(0.216 0.011 50);
--surface: oklch(0.216 0.011 50);
--surface-2: oklch(0.254 0.013 50);
--surface-3: oklch(0.294 0.014 50);
--border: oklch(0.32 0.015 50);
--border-strong: oklch(0.4 0.017 50);
--ink: oklch(0.94 0.007 50);
--muted: oklch(0.72 0.013 50);
--faint: oklch(0.58 0.013 50);
--accent: oklch(0.74 0.15 45);
--accent-strong: oklch(0.78 0.155 45);
--accent-ink: oklch(0.2 0.04 45);
--accent-tint: oklch(0.74 0.15 45 / 0.14);
--accent-ring: oklch(0.74 0.15 45 / 0.55);
--term-bg: oklch(0.158 0.009 50);
}
:root[data-palette="ember"][data-theme="light"],
:root[data-palette="ember"][data-theme="light"] body {
--bg: oklch(0.983 0.004 50);
--bg-grid: oklch(0.93 0.006 50);
--surface: oklch(1 0 0);
--surface-2: oklch(0.962 0.005 50);
--surface-3: oklch(0.93 0.006 50);
--border: oklch(0.888 0.009 50);
--border-strong: oklch(0.82 0.012 50);
--ink: oklch(0.27 0.014 50);
--muted: oklch(0.47 0.015 50);
--faint: oklch(0.62 0.015 50);
--accent: oklch(0.58 0.17 40);
--accent-strong: oklch(0.53 0.17 40);
--accent-ink: oklch(0.99 0.008 40);
--accent-tint: oklch(0.58 0.17 40 / 0.11);
--accent-ring: oklch(0.58 0.17 40 / 0.5);
--term-bg: oklch(0.165 0.009 50);
}
@media (prefers-color-scheme: light) {
:root[data-palette="ember"]:not([data-theme]) {
--bg: oklch(0.983 0.004 50);
--bg-grid: oklch(0.93 0.006 50);
--surface: oklch(1 0 0);
--surface-2: oklch(0.962 0.005 50);
--surface-3: oklch(0.93 0.006 50);
--border: oklch(0.888 0.009 50);
--border-strong: oklch(0.82 0.012 50);
--ink: oklch(0.27 0.014 50);
--muted: oklch(0.47 0.015 50);
--faint: oklch(0.62 0.015 50);
--accent: oklch(0.58 0.17 40);
--accent-strong: oklch(0.53 0.17 40);
--accent-ink: oklch(0.99 0.008 40);
--accent-tint: oklch(0.58 0.17 40 / 0.11);
--accent-ring: oklch(0.58 0.17 40 / 0.5);
--term-bg: oklch(0.165 0.009 50);
}
}
/* orchid: muted violet accent on a faintly violet base. */
:root[data-palette="orchid"] {
--bg: oklch(0.177 0.014 305);
--bg-grid: oklch(0.213 0.017 305);
--surface: oklch(0.213 0.017 305);
--surface-2: oklch(0.251 0.02 305);
--surface-3: oklch(0.291 0.021 305);
--border: oklch(0.318 0.022 305);
--border-strong: oklch(0.4 0.024 305);
--ink: oklch(0.935 0.01 305);
--muted: oklch(0.715 0.018 305);
--faint: oklch(0.575 0.018 305);
--accent: oklch(0.78 0.13 310);
--accent-strong: oklch(0.82 0.135 310);
--accent-ink: oklch(0.22 0.05 310);
--accent-tint: oklch(0.78 0.13 310 / 0.14);
--accent-ring: oklch(0.78 0.13 310 / 0.55);
--term-bg: oklch(0.155 0.014 305);
}
:root[data-palette="orchid"][data-theme="light"],
:root[data-palette="orchid"][data-theme="light"] body {
--bg: oklch(0.983 0.004 305);
--bg-grid: oklch(0.93 0.007 305);
--surface: oklch(1 0 0);
--surface-2: oklch(0.961 0.005 305);
--surface-3: oklch(0.93 0.007 305);
--border: oklch(0.886 0.01 305);
--border-strong: oklch(0.82 0.013 305);
--ink: oklch(0.27 0.018 305);
--muted: oklch(0.47 0.018 305);
--faint: oklch(0.62 0.018 305);
--accent: oklch(0.5 0.17 308);
--accent-strong: oklch(0.45 0.17 308);
--accent-ink: oklch(0.99 0.005 308);
--accent-tint: oklch(0.5 0.17 308 / 0.11);
--accent-ring: oklch(0.5 0.17 308 / 0.5);
--term-bg: oklch(0.165 0.014 305);
}
@media (prefers-color-scheme: light) {
:root[data-palette="orchid"]:not([data-theme]) {
--bg: oklch(0.983 0.004 305);
--bg-grid: oklch(0.93 0.007 305);
--surface: oklch(1 0 0);
--surface-2: oklch(0.961 0.005 305);
--surface-3: oklch(0.93 0.007 305);
--border: oklch(0.886 0.01 305);
--border-strong: oklch(0.82 0.013 305);
--ink: oklch(0.27 0.018 305);
--muted: oklch(0.47 0.018 305);
--faint: oklch(0.62 0.018 305);
--accent: oklch(0.5 0.17 308);
--accent-strong: oklch(0.45 0.17 308);
--accent-ink: oklch(0.99 0.005 308);
--accent-tint: oklch(0.5 0.17 308 / 0.11);
--accent-ring: oklch(0.5 0.17 308 / 0.5);
--term-bg: oklch(0.165 0.014 305);
}
}
/* ---------- reset / base ---------- */
* {
@@ -435,6 +745,163 @@ textarea {
flex: 1 1 auto;
}
/* Palette picker: a ghost-button-shaped <select>. */
.palette-select {
appearance: none;
height: var(--control-h);
padding: 0 var(--sp-2);
border: 1px solid transparent;
border-radius: var(--r-2);
background: transparent;
color: var(--muted);
font-family: var(--font-mono);
font-size: var(--fs-12);
cursor: pointer;
max-width: 9ch;
text-overflow: ellipsis;
transition:
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.palette-select:hover {
color: var(--ink);
border-color: var(--border-strong);
}
.palette-select:focus-visible {
border-color: var(--accent);
color: var(--ink);
}
/* ---------- 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;
@@ -926,12 +1393,34 @@ input.leaf-rename:focus {
color: var(--danger);
}
.activity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits
inside the row's own indent gutter (`.tree-leaf` is already positioned),
at a `left` set inline per nesting depth — see `activityRailMarkup` in
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
states that need attention (DESIGN.md's "living signals" rule). */
.activity-rail {
position: absolute;
top: 4px;
bottom: 4px;
width: 5px;
border-radius: 2px;
background: currentColor;
flex: 0 0 auto;
}
.activity-rail[data-activity="active"] {
color: var(--live);
animation: pulse 2.4s var(--ease-out) infinite;
}
.activity-rail[data-activity="idle"] {
color: var(--border-strong);
}
.activity-rail[data-activity="awaiting-input"] {
color: var(--warn);
animation: blink 1.2s steps(1) infinite;
}
.activity-rail[data-activity="error"] {
color: var(--danger);
animation: rail-flash 1.4s ease-out infinite;
}
.activity-duration {
@@ -1371,20 +1860,29 @@ input.leaf-rename:focus {
/* ---------- mobile special-key bar (Termux-style) ---------- */
/* Two compact rows (Termux layout) in a 5-column grid the 10 keys flow into:
Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T on
the right (Home/End fill the top corners). Layout is always defined; only
*visibility* is gated: hidden on precise-pointer devices (a physical keyboard
already has these keys), shown on touch where the soft keyboard omits them.
The shell tracks the visual viewport (see `#app`), so it lands above it. */
/* Two compact rows (Termux layout) in an 8-column grid the 16 keys flow into:
Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T in
the middle (Home/End fill the top corners), then PgUp/PgDn and the shell
symbols. Columns never shrink below a thumb's width — on a narrow phone the
bar scrolls sideways instead. Layout is always defined; only *visibility* is
gated: hidden on precise-pointer devices (a physical keyboard already has
these keys), shown on touch where the soft keyboard omits them. The shell
tracks the visual viewport (see `#app`), so it lands above it. */
.attach-keybar {
display: none;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(8, minmax(2.5rem, 1fr));
gap: var(--sp-1);
padding: var(--sp-1) var(--sp-2);
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--border);
background: var(--surface);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.attach-keybar::-webkit-scrollbar {
display: none;
}
/* Shown on touch only while *this* terminal is focused (`keybar-active`, toggled
@@ -2117,6 +2615,25 @@ input.leaf-rename:focus {
}
}
@keyframes rail-flash {
0%,
100% {
opacity: 1;
}
15% {
opacity: 0.25;
}
30% {
opacity: 1;
}
45% {
opacity: 0.25;
}
60% {
opacity: 1;
}
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
@@ -2166,7 +2683,8 @@ input.leaf-rename:focus {
}
.brand-dot,
[data-status="running"] .leaf-status::before,
.tally-live::before {
.tally-live::before,
.activity-rail {
animation: none;
}
}
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
createPaletteSelect,
initTheme,
PALETTES,
resolvedPalette,
setPalette,
} from "./theme.js";
// happy-dom's vitest environment ships no localStorage global; back it with a
// plain in-memory map so persistence is observable.
let store = new Map<string, string>();
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
});
describe("palette selection", () => {
beforeEach(() => {
store = new Map();
delete document.documentElement.dataset.palette;
delete document.documentElement.dataset.theme;
});
it("defaults to graphite with no data-palette attribute", () => {
initTheme();
expect(resolvedPalette()).toBe("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("setPalette stamps data-palette and persists", () => {
setPalette("ember");
expect(document.documentElement.dataset.palette).toBe("ember");
expect(resolvedPalette()).toBe("ember");
// A fresh boot re-applies the stored palette.
delete document.documentElement.dataset.palette;
initTheme();
expect(document.documentElement.dataset.palette).toBe("ember");
});
it("selecting graphite removes the attribute again", () => {
setPalette("orchid");
setPalette("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
initTheme();
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("ignores an unknown stored palette", () => {
localStorage.setItem("maestro-palette", "glorb");
initTheme();
expect(resolvedPalette()).toBe("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("the picker lists every palette and applies the chosen one", () => {
const select = createPaletteSelect();
expect([...select.options].map((o) => o.value)).toEqual([...PALETTES]);
expect(select.value).toBe("graphite");
select.value = "periwinkle";
select.dispatchEvent(new Event("change"));
expect(document.documentElement.dataset.palette).toBe("periwinkle");
expect(localStorage.getItem("maestro-palette")).toBe("periwinkle");
});
});
+64 -1
View File
@@ -1,10 +1,26 @@
// Theme: dark (default) + light, persisted. When the user hasn't pinned a
// theme we follow the OS via CSS `prefers-color-scheme`; pinning sets an
// explicit `data-theme` on <html> that overrides the media query.
//
// Palette: orthogonal to light/dark. Each palette ships both a dark and a
// light token set in styles.css; `data-palette` on <html> selects it, and
// graphite — the default — is the bare document with no attribute.
type Theme = "light" | "dark";
const KEY = "maestro-theme";
/** Every `data-palette` value, in picker order. Graphite is the default. */
export const PALETTES = [
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
] as const;
export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function stored(): Theme | null {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" ? v : null;
@@ -21,18 +37,46 @@ export function resolvedTheme(): Theme {
return stored() ?? systemTheme();
}
/** Apply a pinned theme at boot (no-op when following the system). */
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
}
/** The palette in force (stored or the graphite default). */
export function resolvedPalette(): Palette {
return document.documentElement.dataset.palette as Palette | undefined
?? storedPalette()
?? "graphite";
}
/** Pin a palette. Graphite is the bare document, so it clears the attribute. */
export function setPalette(p: Palette): void {
localStorage.setItem(PALETTE_KEY, p);
if (p === "graphite") delete document.documentElement.dataset.palette;
else document.documentElement.dataset.palette = p;
}
/** Apply the pinned theme + palette at boot (no-ops when unpinned). */
export function initTheme(): void {
// Dev-only `?theme=light|dark` override for visual review; stripped in prod.
if (import.meta.env.DEV) {
const q = new URLSearchParams(location.search).get("theme");
if (q === "light" || q === "dark") {
document.documentElement.dataset.theme = q;
applyStoredPalette();
return;
}
}
const pinned = stored();
if (pinned) document.documentElement.dataset.theme = pinned;
applyStoredPalette();
}
function applyStoredPalette(): void {
const p = storedPalette();
if (p && p !== "graphite") document.documentElement.dataset.palette = p;
}
function setTheme(t: Theme): void {
@@ -58,3 +102,22 @@ export function createThemeToggle(): HTMLButtonElement {
});
return btn;
}
/** A footer dropdown that picks the colour palette. */
export function createPaletteSelect(): HTMLSelectElement {
const select = document.createElement("select");
select.className = "palette-select";
select.setAttribute("aria-label", "Colour palette");
select.title = "Colour palette";
for (const p of PALETTES) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
select.appendChild(opt);
}
select.value = resolvedPalette();
select.addEventListener("change", () => {
setPalette(select.value as Palette);
});
return select;
}
+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