Compare commits

...
Author SHA1 Message Date
Miguel Palhas 8157793282 feat(maestro): add TUI theme presets 2026-08-25 13:30:59 +01:00
Miguel Palhas a1816a0838 fix(maestro): preserve fixed card trailing fragments 2026-08-25 13:18:24 +01:00
Miguel Palhas e01b0616b1 merge: TUI shell/121 — TUI cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas 74d8d3c37a merge: TUI shell/120 — web cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas cc9b027bd1 feat(maestro): make TUI session cards two lines 2026-08-25 13:11:06 +01:00
Miguel Palhas 10a25e5b83 feat(maestro): keep web session cards two lines 2026-08-25 13:06:23 +01:00
Miguel Palhas 4c3f65027d fix(maestro): make TUI chrome contrast palette-controlled 2026-08-25 13:02:48 +01:00
Miguel Palhas 76eb3d1386 merge: TUI shell/119 — bare maestro opens the TUI 2026-08-25 12:55:25 +01:00
Miguel Palhas 877f409194 merge: TUI shell/117 — shared path-abbreviation helpers 2026-08-25 12:55:25 +01:00
Miguel Palhas 39d4014dcb merge: TUI shell/118 — TUI colours behind named palette roles
ci / web (push) Successful in 54s
ci / flake (push) Successful in 8m33s
ci / rust (push) Successful in 8m57s
Closes #118
2026-08-25 12:35:38 +01:00
Miguel Palhas f02510465c docs(maestro): bare maestro opens the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas 8e251bf8be feat(maestro): default a missing subcommand to the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas 975f3ef0c5 feat(maestro): move path-abbreviation helpers into maestro-protocol
Port abbreviateCwd, abbreviateFolderName, mergeLinear, and
sessionTreePath from web/src/{types,tree}.ts into a maestro-protocol
display module, so the TUI sidebar can group sessions with the same
labels the web UI uses (#117). abbreviate_folder_name returns the
collapsed stem and last segment separately instead of escaped HTML,
so a ratatui caller can style the stem without parsing markup.

maestro-cli's own abbreviate_dir moves into the new module; the CLI
and the worktrees list now call the shared implementation.

Closes #117
2026-08-25 12:31:43 +01:00
17 changed files with 904 additions and 233 deletions
+1 -15
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
@@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
.unwrap_or_default()
}
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
+1 -1
View File
@@ -6,10 +6,10 @@
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
+59 -4
View File
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
pub const DIR_NAME: &str = "maestro";
@@ -146,11 +147,14 @@ pub struct WebSection {
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
///
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
/// applied **over** the defaults, so a partial override is enough.
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
/// are applied **over** the defaults, so a partial override is enough.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TuiSection {
/// Named palette preset. Defaults to the dark preset.
#[serde(default, deserialize_with = "deserialize_theme")]
pub theme: Option<TuiTheme>,
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
#[serde(default)]
pub leader: Option<String>,
@@ -167,6 +171,33 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>,
}
/// 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.
Dark,
/// A high-contrast light palette.
Light,
/// Named ANSI colours, allowing the terminal to provide the colour set.
Ansi,
}
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
raw.map(|name| match name.as_str() {
"dark" => Ok(TuiTheme::Dark),
"light" => Ok(TuiTheme::Light),
"ansi" => Ok(TuiTheme::Ansi),
_ => Err(D::Error::custom(format!(
"invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\""
))),
})
.transpose()
}
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
///
/// Defined in shared config so every binary sees the same list: the web reads
@@ -596,6 +627,30 @@ bind_addr = "127.0.0.1:9090"
);
}
#[test]
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
assert_eq!(TuiSection::default().theme, None);
let cfg = file_with("[tui]\ntheme = \"light\"\n");
assert_eq!(cfg.tui.theme, Some(TuiTheme::Light));
let cfg = file_with("[tui]\ntheme = \"ansi\"\n");
assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi));
}
#[test]
fn tui_theme_rejects_unknown_names_with_the_field_name() {
let err =
toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"solarized\"\n").unwrap_err();
let message = err.to_string();
assert!(
message.contains("[tui].theme"),
"diagnostic should name the field: {message}"
);
assert!(
message.contains("solarized"),
"diagnostic should name the invalid value: {message}"
);
}
fn file_with(body: &str) -> MaestroConfigFile {
toml::from_str(body).expect("parse")
}
+321
View File
@@ -0,0 +1,321 @@
//! Path-abbreviation helpers shared by every client that renders a session's
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
//! and `sessionTreePath` — keep them in sync.
use std::path::{Path, PathBuf};
use crate::Session;
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
}
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
/// and — when there was something to collapse — the shortened lead-in a
/// caller can render dimmed (the web's `.folder-stem`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbbreviatedFolderName {
/// The collapsed earlier segments, joined by `/`, with no trailing
/// slash. `None` when `name` had two or fewer segments — nothing to
/// gain from collapsing, so `last` carries the name unabbreviated.
pub stem: Option<String>,
pub last: String,
}
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
/// last segment verbatim, collapse every earlier one to its first character.
/// Two-or-fewer segments are left alone. Mirrors the web's
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
/// caller styles `stem` dim itself rather than parsing markup out of it.
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
let parts: Vec<&str> = name.split('/').collect();
if parts.len() <= 2 {
return AbbreviatedFolderName {
stem: None,
last: name.to_string(),
};
}
let last = parts[parts.len() - 1].to_string();
let stem = parts[..parts.len() - 1]
.iter()
.map(|p| {
if *p == "~" {
"~".to_string()
} else {
p.chars().next().map(String::from).unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join("/");
AbbreviatedFolderName {
stem: Some(stem),
last,
}
}
/// The directory a session lives in: its live `current_cwd` if the daemon
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
fn session_cwd(s: &Session) -> Option<PathBuf> {
if let Some(cur) = &s.current_cwd {
if !cur.as_os_str().is_empty() {
return Some(cur.clone());
}
}
if !s.cwd.as_os_str().is_empty() {
return Some(s.cwd.clone());
}
None
}
/// Where a session sits in the directory tree. A worktree session belongs to
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
/// session of the same name, and the repository stops being where you look
/// for the work. Mirrors the web's `sessionTreePath`.
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
if let Some(wt) = &s.worktree {
return Some(wt.base_repo.clone());
}
session_cwd(s)
}
/// One node of a directory tree grouping sessions by path, generic over the
/// leaf payload since building the tree itself (grouping key, sort order,
/// filtering) is not this crate's job — only [`merge_linear`] is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode<T> {
Folder(TreeFolder<T>),
Leaf(T),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeFolder<T> {
pub name: String,
pub path: String,
pub children: Vec<TreeNode<T>>,
}
/// Fold a folder with a single folder-child into one node (`a` + `b` →
/// `a/b`), repeating down the chain, then recurse into the surviving
/// branches. The merged node keeps the deepest path so collapse/expand state
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
let mut cur = folder;
loop {
let single_folder_child =
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
if !single_folder_child {
break;
}
let child = match cur.children.pop().expect("checked len == 1 above") {
TreeNode::Folder(f) => f,
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
};
cur = TreeFolder {
name: format!("{}/{}", cur.name, child.name),
path: child.path,
children: child.children,
};
}
cur.children = cur
.children
.into_iter()
.map(|c| match c {
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
leaf => leaf,
})
.collect();
cur
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SessionStatus, SessionWorktree};
use chrono::{TimeZone, Utc};
use std::collections::HashMap;
use uuid::Uuid;
fn session(cwd: &str) -> Session {
Session {
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
#[test]
fn abbreviate_dir_collapses_home() {
assert_eq!(
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
"~/maestro"
);
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
}
#[test]
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
"/srv/work/api"
);
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), ""),
"/srv/work/api"
);
}
#[test]
fn abbreviate_folder_name_collapses_the_stem() {
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
assert_eq!(
got,
AbbreviatedFolderName {
stem: Some("~/g/n".into()),
last: "maestro".into(),
}
);
}
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
#[test]
fn abbreviate_folder_name_two_segment_no_op() {
assert_eq!(
abbreviate_folder_name("~/work"),
AbbreviatedFolderName {
stem: None,
last: "~/work".into(),
}
);
assert_eq!(
abbreviate_folder_name("work"),
AbbreviatedFolderName {
stem: None,
last: "work".into(),
}
);
}
#[test]
fn session_tree_path_prefers_worktree_base_repo() {
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
s.worktree = Some(SessionWorktree {
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
});
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/agent-skills"))
);
}
#[test]
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
let s = session("/home/n/tea/arr");
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr"))
);
}
#[test]
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
let mut s = session("/home/n/tea/arr");
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr/crates"))
);
}
fn leaf(name: &str) -> TreeNode<String> {
TreeNode::Leaf(name.into())
}
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
TreeFolder {
name: name.into(),
path: path.into(),
children,
}
}
/// The web's mergeLinear test: keeps the deepest path so collapse state
/// survives growth.
#[test]
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a/b");
assert_eq!(merged.path, "a/b");
}
#[test]
fn merge_linear_stops_where_a_directory_branches() {
let tree = folder(
"git",
"git",
vec![
TreeNode::Folder(folder("one", "git/one", vec![])),
TreeNode::Folder(folder("two", "git/two", vec![])),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.name, "git");
assert_eq!(merged.children.len(), 2);
}
#[test]
fn merge_linear_stops_at_a_leaf() {
let tree = folder("a", "a", vec![leaf("session")]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a");
assert_eq!(merged.children, vec![leaf("session")]);
}
#[test]
fn merge_linear_recurses_into_surviving_branches() {
let tree = folder(
"root",
"root",
vec![
TreeNode::Folder(folder(
"a",
"root/a",
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
)),
leaf("other"),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.children.len(), 2);
let TreeNode::Folder(nested) = &merged.children[0] else {
panic!("expected folder")
};
assert_eq!(nested.name, "a/b");
}
}
+1
View File
@@ -9,6 +9,7 @@
#[cfg(feature = "client")]
pub mod client;
pub mod display;
pub mod routes;
#[cfg(feature = "client")]
+31 -1
View File
@@ -8,13 +8,15 @@
use std::time::Duration;
use maestro_config::TuiSection;
use maestro_config::{TuiSection, TuiTheme};
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
use crate::palette::Palette;
#[derive(Debug)]
pub struct TuiConfig {
pub palette: Palette,
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
@@ -76,6 +78,11 @@ impl TuiConfig {
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
/// applied first, then user overrides on top.
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
TuiTheme::Dark => Palette::DARK,
TuiTheme::Light => Palette::LIGHT,
TuiTheme::Ansi => Palette::ANSI,
};
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
@@ -131,6 +138,7 @@ impl TuiConfig {
}
Ok(TuiConfig {
palette,
leader,
chord_timeout,
keymap,
@@ -143,6 +151,28 @@ impl TuiConfig {
mod tests {
use super::*;
use crate::action::Action;
use maestro_config::{TuiSection, TuiTheme};
#[test]
fn absent_theme_selects_the_dark_default() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(cfg.palette, Palette::DARK);
}
#[test]
fn named_themes_select_the_matching_complete_palette() {
for (theme, expected) in [
(TuiTheme::Dark, Palette::DARK),
(TuiTheme::Light, Palette::LIGHT),
(TuiTheme::Ansi, Palette::ANSI),
] {
let section = TuiSection {
theme: Some(theme),
..Default::default()
};
assert_eq!(TuiConfig::load(&section).unwrap().palette, expected);
}
}
#[test]
fn defaults_cover_core_actions() {
+1
View File
@@ -90,6 +90,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
Err(_) => maestro_config::MaestroConfigFile::default(),
};
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
palette::set_active(tui_cfg.palette);
let catalog = CatalogStore::default();
let ws_url = daemon
+245 -82
View File
@@ -16,9 +16,8 @@
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! One palette ships today, reached through [`active`]. Selecting between
//! presets from config is a separate issue; [`active`] is the seam it
//! replaces.
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
//! [`Palette::ANSI`] uses terminal-provided ANSI colours.
//!
//! Out of scope: PTY cell colours. Those belong to the program running in the
//! pane and pass through `pane::translate_color` verbatim.
@@ -106,6 +105,10 @@ pub struct Palette {
pub card: Surface,
/// The surface the highlighted card swaps in.
pub selection: Surface,
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
/// paint this background too; otherwise its contrast depends on the
/// operator's terminal theme.
pub chrome_bg: Color,
/// Panel border while that panel holds focus.
pub border_focused: Color,
/// Panel border while it does not.
@@ -126,39 +129,162 @@ pub struct Palette {
}
impl Palette {
/// The one palette that ships today.
pub const DEFAULT: Palette = Palette {
/// The dark terminal-native palette and the default preset.
pub const DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x14, 0x16, 0x1b),
fg: Color::Rgb(0xd6, 0xdc, 0xe5),
muted: Color::Rgb(0x9a, 0xa3, 0xb2),
dim: Color::Rgb(0x6b, 0x72, 0x80),
active: Color::Rgb(0x3f, 0xb9, 0x50),
idle: Color::Rgb(0x8b, 0x94, 0x9e),
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
error: Color::Rgb(0xf8, 0x51, 0x49),
bg: Color::Rgb(0x1f, 0x23, 0x2b),
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
muted: Color::Rgb(0xae, 0xb7, 0xc4),
dim: Color::Rgb(0x85, 0x8f, 0x9e),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xae, 0xb7, 0xc4),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x2b, 0x3f, 0x6e),
fg: Color::Rgb(0xf0, 0xf4, 0xfa),
muted: Color::Rgb(0xc0, 0xc9, 0xda),
dim: Color::Rgb(0x93, 0xa0, 0xbc),
active: Color::Rgb(0x7e, 0xe7, 0x87),
idle: Color::Rgb(0xb6, 0xc0, 0xd4),
awaiting: Color::Rgb(0xf2, 0xcc, 0x60),
error: Color::Rgb(0xff, 0x94, 0x92),
bg: Color::Rgb(0x3d, 0x51, 0x44),
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
border_unfocused: Color::Rgb(0x30, 0x36, 0x3d),
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
group_row: Color::Rgb(0x8f, 0xa3, 0xc8),
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11),
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11),
error_text: Color::Rgb(0xf8, 0x51, 0x49),
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
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),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// A high-contrast light peer to [`Self::DARK`].
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),
active: Color::Rgb(0x21, 0x7a, 0x3c),
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
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),
active: Color::Rgb(0x15, 0x5b, 0x2e),
idle: Color::Rgb(0x3f, 0x55, 0x45),
awaiting: Color::Rgb(0x69, 0x46, 0x00),
error: Color::Rgb(0xa5, 0x1d, 0x14),
},
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
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),
error_text: Color::Rgb(0xb4, 0x23, 0x18),
};
/// A palette of named ANSI colours for terminals without truecolour.
pub const ANSI: Palette = Palette {
card: Surface {
bg: Color::Black,
fg: Color::White,
muted: Color::Gray,
dim: Color::DarkGray,
active: Color::Green,
idle: Color::Gray,
awaiting: Color::Yellow,
error: Color::Red,
},
selection: Surface {
bg: Color::Blue,
fg: Color::White,
muted: Color::White,
dim: Color::LightBlue,
active: Color::LightGreen,
idle: Color::White,
awaiting: Color::LightYellow,
error: Color::LightRed,
},
chrome_bg: Color::Black,
border_focused: Color::LightGreen,
border_unfocused: Color::DarkGray,
focus_accent: Color::LightGreen,
group_row: Color::LightCyan,
mode_normal_bg: Color::Green,
mode_normal_fg: Color::Black,
mode_insert_bg: Color::Yellow,
mode_insert_fg: Color::Black,
error_text: Color::LightRed,
};
/// 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); 3] {
[
("dark", Self::DARK),
("light", Self::LIGHT),
("ansi", Self::ANSI),
]
}
/// Resolve a config name to a palette.
pub fn named(name: &str) -> Option<Palette> {
match name {
"dark" => Some(Self::DARK),
"light" => Some(Self::LIGHT),
"ansi" => Some(Self::ANSI),
_ => None,
}
}
/// Every role, including the backgrounds it is painted against.
pub fn roles(&self) -> [(&'static str, Color); 26] {
[
("card.bg", self.card.bg),
("card.fg", self.card.fg),
("card.muted", self.card.muted),
("card.dim", self.card.dim),
("card.active", self.card.active),
("card.idle", self.card.idle),
("card.awaiting", self.card.awaiting),
("card.error", self.card.error),
("selection.bg", self.selection.bg),
("selection.fg", self.selection.fg),
("selection.muted", self.selection.muted),
("selection.dim", self.selection.dim),
("selection.active", self.selection.active),
("selection.idle", self.selection.idle),
("selection.awaiting", self.selection.awaiting),
("selection.error", self.selection.error),
("chrome_bg", self.chrome_bg),
("border_focused", self.border_focused),
("border_unfocused", self.border_unfocused),
("focus_accent", self.focus_accent),
("group_row", self.group_row),
("mode_normal_bg", self.mode_normal_bg),
("mode_normal_fg", self.mode_normal_fg),
("mode_insert_bg", self.mode_insert_bg),
("mode_insert_fg", self.mode_insert_fg),
("error_text", self.error_text),
]
}
/// The surface a card sits on: the selection surface when it is the
/// highlighted card, the card surface otherwise.
pub fn surface(&self, selected: bool) -> &Surface {
@@ -172,15 +298,18 @@ impl Palette {
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
Style::default().fg(self.border_focused)
Style::default().bg(self.chrome_bg).fg(self.border_focused)
} else {
Style::default().fg(self.border_unfocused)
Style::default()
.bg(self.chrome_bg)
.fg(self.border_unfocused)
}
}
/// Style for a sidebar group heading.
pub fn group_row_style(&self) -> Style {
Style::default()
.bg(self.chrome_bg)
.fg(self.group_row)
.add_modifier(Modifier::BOLD)
}
@@ -200,7 +329,7 @@ impl Palette {
/// Style for error text outside a card.
pub fn error_style(&self) -> Style {
Style::default().fg(self.error_text)
Style::default().bg(self.chrome_bg).fg(self.error_text)
}
}
@@ -210,9 +339,20 @@ impl Default for Palette {
}
}
/// The palette in force. Config-driven preset selection lands here.
fn active_slot() -> &'static std::sync::RwLock<Palette> {
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
}
/// Set the palette used by the running TUI.
pub(crate) fn set_active(palette: Palette) {
*active_slot().write().expect("active palette lock poisoned") = palette;
}
/// The palette in force. Config-driven preset selection lands here at TUI
/// startup, before the first frame is drawn.
pub fn active() -> Palette {
Palette::DEFAULT
*active_slot().read().expect("active palette lock poisoned")
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
@@ -256,30 +396,43 @@ mod tests {
/// decoration and only has to clear the 3:1 non-text threshold.
#[test]
fn every_role_is_legible_on_its_own_surface() {
let p = active();
for (name, surface) in [("card", &p.card), ("selection", &p.selection)] {
for (role, color) in surface.roles() {
let ratio = contrast_ratio(surface.bg, color)
.unwrap_or_else(|| panic!("{name}.{role} must be an RGB value"));
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{name}.{role} contrast {ratio:.2} is below {floor}"
);
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
for (role, color) in surface.roles() {
let ratio = contrast_ratio(surface.bg, color)
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
);
}
}
}
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
assert_eq!(p.roles().len(), 26, "{name} role set is incomplete");
}
assert_eq!(Palette::named("dark"), Some(Palette::DARK));
assert_eq!(Palette::named("light"), Some(Palette::LIGHT));
assert_eq!(Palette::named("ansi"), Some(Palette::ANSI));
assert_eq!(Palette::named("unknown"), None);
}
/// The selection background exists to be seen as a different surface, not
/// merely a slightly different shade of the card.
#[test]
fn selection_background_is_distinguishable_from_the_card_background() {
let p = active();
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
assert!(
ratio >= 1.6,
"card and selection backgrounds differ by only {ratio:.2}"
);
for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
assert!(
ratio >= 1.6,
"{name} card and selection backgrounds differ by only {ratio:.2}"
);
}
}
/// The bug this palette fixes: a state colour chosen for the dark card
@@ -287,43 +440,53 @@ mod tests {
/// selection surface carries its own — distinct — state colours.
#[test]
fn selection_state_colours_are_not_the_card_state_colours() {
let p = active();
for state in STATES {
assert_ne!(
p.card.activity(state),
p.selection.activity(state),
"{state:?} must have a selection-specific colour"
);
for (name, p) in Palette::presets() {
for state in STATES {
assert_ne!(
p.card.activity(state),
p.selection.activity(state),
"{name} {state:?} must have a selection-specific colour"
);
}
}
}
/// Other panels' roles are RGB too, so a preset swap moves all of them.
/// Foregrounds are checked against the chrome background they are
/// actually painted on; they must never rely on the terminal default.
#[test]
fn chrome_roles_are_rgb_values() {
let p = active();
for (name, color) in [
("border_focused", p.border_focused),
("border_unfocused", p.border_unfocused),
("focus_accent", p.focus_accent),
("group_row", p.group_row),
("mode_normal_bg", p.mode_normal_bg),
("mode_normal_fg", p.mode_normal_fg),
("mode_insert_bg", p.mode_insert_bg),
("mode_insert_fg", p.mode_insert_fg),
("error_text", p.error_text),
] {
assert!(
relative_luminance(color).is_some(),
"{name} must be an RGB value"
);
}
// The mode indicator paints fg on bg; both pairs must stay readable.
for (name, fg, bg) in [
("normal", p.mode_normal_fg, p.mode_normal_bg),
("insert", p.mode_insert_fg, p.mode_insert_bg),
] {
let ratio = contrast_ratio(fg, bg).unwrap();
assert!(ratio >= 4.5, "mode {name} contrast {ratio:.2} is below 4.5");
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
for (name, color) in p.roles().into_iter().skip(16) {
assert!(
relative_luminance(color).is_some(),
"{preset}.{name} must be an RGB value"
);
}
// The mode indicator paints fg on bg; both pairs must stay readable.
for (name, fg, bg) in [
("normal", p.mode_normal_fg, p.mode_normal_bg),
("insert", p.mode_insert_fg, p.mode_insert_bg),
] {
let ratio = contrast_ratio(fg, bg).unwrap();
assert!(
ratio >= 4.5,
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
);
}
for (name, fg, floor) in [
("border_focused", p.border_focused, 3.0),
("border_unfocused", p.border_unfocused, 3.0),
("focus_accent", p.focus_accent, 3.0),
("group_row", p.group_row, 4.5),
("error_text", p.error_text, 4.5),
] {
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
assert!(
ratio >= floor,
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
);
}
}
}
+129 -39
View File
@@ -2,8 +2,7 @@
//!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! blocked-work and exited fragments when they apply. Absent entirely when
//! it has nothing to say.
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::HashSet;
@@ -18,8 +17,8 @@ use uuid::Uuid;
use crate::palette;
/// Time-in-state alone earns a meta line only once the session has sat in
/// its current activity state this long (docs/SIDEBAR.md).
/// Time-in-state earns a duration fragment once the session has sat in its
/// current activity state this long (docs/SIDEBAR.md).
const META_LINE_AFTER_SECS: i64 = 30;
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
@@ -74,9 +73,8 @@ impl Sidebar {
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
lines.push(Line::from(self.name_row(s, width)));
if let Some(meta) = self.meta_row(s, width) {
lines.push(Line::from(pad_to_width(meta, width, base)));
}
let meta = self.meta_row(s, width);
lines.push(Line::from(pad_to_width(meta, width, base)));
}
f.render_widget(Paragraph::new(lines), inner);
}
@@ -123,8 +121,8 @@ impl Sidebar {
spans
}
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
@@ -132,14 +130,6 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let errored = s.activity == ActivityState::Error;
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
&& blocked.is_none()
&& !dead
&& !errored
{
return None;
}
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
@@ -148,13 +138,10 @@ impl Sidebar {
Span::styled(META_INDENT, base),
Span::styled("●", dot_style),
];
if let Some(d) = held {
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));
}
// The trailing fragments are measured up front so a long detail
// truncates ahead of them instead of pushing them off the line
// (docs/SIDEBAR.md).
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
@@ -162,17 +149,29 @@ impl Sidebar {
if dead {
tail.push(Span::styled(" exited", muted));
}
let command = Span::styled(format!(" {}", s.command), muted);
// Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the
// unconditional command and truncate the status tail first. Detail
// then receives only space left after the full command reservation;
// when the full command cannot fit, it is the final truncation target.
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let available = width.saturating_sub(head_w);
let command_floor = usize::from(available > 0);
let tail = fit_to_width(tail, available.saturating_sub(command_floor));
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let command_w = command.width().min(available.saturating_sub(tail_w));
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let budget = width.saturating_sub(head_w + tail_w);
let budget = available.saturating_sub(tail_w + command_w);
spans.extend(fit_to_width(
vec![Span::styled(format!(" {detail}"), muted)],
budget,
));
}
spans.extend(tail);
Some(fit_to_width(spans, width))
spans.extend(fit_to_width(vec![command], command_w));
spans
}
}
@@ -406,10 +405,10 @@ mod tests {
}
}
/// A freshly-active session stays one line — never an empty placeholder
/// row (docs/SIDEBAR.md).
/// A fresh session still gets its unconditional second line, but no
/// duration until it has been held in state for 30 seconds.
#[test]
fn freshly_active_session_stays_one_line() {
fn fresh_session_renders_two_lines_without_duration() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(10);
let sb = Sidebar {
@@ -421,8 +420,18 @@ mod tests {
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
content(&all[2]).is_empty(),
"line 2 must be absent, got {:?}",
all[2].contains('●'),
"line 2 must carry the dot: {:?}",
all[2]
);
assert!(
all[2].contains("bash"),
"line 2 must carry the command: {:?}",
all[2]
);
assert!(
!all[2].contains("10s"),
"fresh cards have no duration: {:?}",
all[2]
);
}
@@ -553,11 +562,12 @@ mod tests {
);
}
/// The sidebar is narrow: the meta line truncates with `…` and never
/// wraps onto a third row (docs/SIDEBAR.md).
/// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_truncates_instead_of_wrapping() {
fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into();
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
@@ -573,7 +583,7 @@ mod tests {
let meta = content(&all[idx + 1]);
assert!(
meta.trim_end().ends_with('…'),
"truncated meta line must end with …: {meta:?}"
"truncated command must end with …: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -688,10 +698,6 @@ mod tests {
rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}"
);
assert!(
!rendered.contains('h') || !rendered.contains("● "),
"no duration may follow the dot for an epoch stamp:\n{rendered}"
);
for line in rendered.lines().filter(|l| l.contains('●')) {
assert!(
!line
@@ -736,6 +742,90 @@ mod tests {
);
}
#[test]
fn command_renders_on_line_two_even_when_name_is_metadata() {
let s = session_named(1, "friendly name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[1].contains("friendly name"),
"name must stay on line 1: {:?}",
all[1]
);
assert!(
all[2].contains("bash"),
"command must be on line 2: {:?}",
all[2]
);
}
#[test]
fn narrow_meta_line_keeps_dot_and_truncates_command() {
let s = session_named(1, "name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 10, 6));
assert!(
all[2].contains('●'),
"dot must survive narrow cards: {:?}",
all[2]
);
assert!(
all[2].contains('…'),
"command should truncate: {:?}",
all[2]
);
}
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into();
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
..Default::default()
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
let meta = content(&all[2]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
}
#[test]
fn long_detail_yields_to_status_and_command() {
let mut s = session_named(1, "name");
s.activity_detail = Some("a very long agent-reported detail".into());
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
..Default::default()
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
assert!(
all[2].contains("bash"),
"command must survive: {:?}",
all[2]
);
assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]);
}
/// Reversed video makes a card unreadable against a background; cards
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
#[test]
@@ -775,7 +865,7 @@ mod tests {
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
let p = palette::active();
assert_eq!(
+37 -12
View File
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
#[command(
name = "maestro",
version,
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
arg_required_else_help = true
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
)]
struct Cli {
/// Override the daemon HTTP base clients talk to (e.g.
@@ -37,8 +36,11 @@ struct Cli {
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
/// the terminal UI. Builds without the `tui` feature have no UI surface,
/// so they print help and exit 0 instead.
#[command(subcommand)]
command: Command,
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
@@ -89,7 +91,22 @@ fn main() -> ExitCode {
}
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
let command = match cli.command {
Some(command) => command,
None => {
#[cfg(feature = "tui")]
{
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
}
#[cfg(not(feature = "tui"))]
{
use clap::CommandFactory;
let _ = <Cli as CommandFactory>::command().print_help();
return Ok(());
}
}
};
match command {
#[cfg(feature = "cli")]
Command::List(args) => {
maestro_cli::dispatch(
@@ -166,19 +183,19 @@ fn init_tracing(cli: &Cli) {
}
#[cfg(feature = "cli")]
fn is_cli_subcommand(command: &Command) -> bool {
fn is_cli_subcommand(command: &Option<Command>) -> bool {
matches!(
command,
Command::List(_)
| Command::Spawn(_)
| Command::Attach(_)
| Command::Kill(_)
| Command::Hooks(_)
Some(Command::List(_))
| Some(Command::Spawn(_))
| Some(Command::Attach(_))
| Some(Command::Kill(_))
| Some(Command::Hooks(_))
)
}
#[cfg(not(feature = "cli"))]
fn is_cli_subcommand(_command: &Command) -> bool {
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
false
}
@@ -216,12 +233,20 @@ mod tests {
}
/// Parse argv the way `main` does, returning just the dispatched subcommand.
fn command_of(argv: &[&str]) -> Command {
fn command_of(argv: &[&str]) -> Option<Command> {
Cli::try_parse_from(argv)
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
.command
}
#[test]
fn bare_invocation_parses_to_no_subcommand() {
// The TUI-vs-help default is applied in `dispatch`; parsing must not
// reject an empty argv.
assert!(command_of(&["maestro"]).is_none());
assert!(command_of(&["maestro", "--verbose"]).is_none());
}
#[cfg(feature = "cli")]
#[test]
fn cli_subcommands_are_classified_as_cli() {
+27 -3
View File
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
assert!(text.contains("maestro"), "--version output: {text}");
}
/// A bare `maestro` must never fall back to help on a build that has a TUI:
/// the missing subcommand defaults to `tui` (#119). Point at a port with
/// nothing listening so the TUI's health probe fails fast instead of blocking
/// on a terminal.
#[cfg(feature = "tui")]
#[test]
fn no_args_prints_help_and_fails() {
// `arg_required_else_help = true` => exit non-zero but show usage.
fn bare_invocation_dispatches_to_the_tui() {
let (ok, text) = run(&["--daemon-url", "http://127.0.0.1:9"]);
assert!(
!ok,
"TUI against a dead daemon should exit non-zero:\n{text}"
);
assert!(
text.contains("daemon unreachable"),
"bare invocation should have run the TUI's daemon probe:\n{text}"
);
assert!(
!text.contains("Usage"),
"bare invocation must not print help when a TUI exists:\n{text}"
);
}
/// Builds without the `tui` feature keep the historical behaviour: no args =>
/// help, exit 0 (#119).
#[cfg(not(feature = "tui"))]
#[test]
fn bare_invocation_prints_help_and_exits_zero() {
let (ok, text) = run(&[]);
assert!(!ok, "bare invocation should fail:\n{text}");
assert!(ok, "bare invocation should exit 0:\n{text}");
assert!(text.contains("Usage"), "expected usage text:\n{text}");
}
+9
View File
@@ -119,6 +119,9 @@ The wizard always installs the default-feature build.
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
in the default feature set, so a vanilla `cargo install maestro` builds it.
A bare `maestro` opens the TUI too — the missing subcommand defaults to `tui`
(#119), so `maestro --daemon-url http://host:7070` works as well. Builds
without the `tui` feature have no UI to open and print help instead (exit 0).
Drop the `tui` feature to skip it on hosts that don't need the UI surface
(`--no-default-features --features cli,daemon`).
@@ -131,6 +134,7 @@ cover every v1 action — only override what you want to change:
```toml
[tui]
theme = "dark" # dark (default), light, or ansi
leader = "ctrl-a" # one byte that escapes Insert mode
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
@@ -149,6 +153,11 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
# 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.
An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
-41
View File
@@ -4,9 +4,7 @@ import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
activity: "Active",
...overrides,
};
}
describe("showsMetaLine", () => {
const fresh = new Date(Date.now() - 10_000).toISOString();
it("stays hidden for a freshly-active session", () => {
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
});
it("appears once the state has been held past 30 seconds", () => {
const held = new Date(Date.now() - 31_000).toISOString();
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
});
it("appears for blocked work, exited sessions and reported errors", () => {
expect(
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
});
it("needs no activity timestamp when another trigger holds", () => {
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
});
});
+3 -15
View File
@@ -2,11 +2,10 @@
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
import type { ActivityState, IsoTimestamp } from "./types.js";
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
/** A state held shorter than this has no duration fragment. */
export const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// implausibly old is treated as "never observed" rather than a huge duration.
@@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string {
return "idle";
}
}
/** Line 2 renders only when at least one contract trigger holds. */
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
const held = activityHeldMs(s.activity_since, now);
return (
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
sessionBlockers(s) !== null ||
s.status === "Dead" ||
s.activity === "Error"
);
}
+16 -2
View File
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("stays a single line while freshly active", () => {
it("always renders line 2 for a freshly spawned session", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
+11 -14
View File
@@ -4,7 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -28,16 +33,13 @@ const STATE_WORDS: Record<string, string> = {
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
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 ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
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>`,
@@ -49,9 +51,7 @@ function metaLine(s: Session, now: number): string {
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
showCmd
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
: "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
@@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// the actions reserve their width, so the name truncates before it can ever
// reach them. Line 2 renders only when a contract trigger holds.
const showMeta = showsMetaLine(s, now);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
@@ -118,7 +115,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${showMeta ? metaLine(s, now) : ""}
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
+12 -4
View File
@@ -736,7 +736,6 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
@@ -787,12 +786,13 @@ textarea {
font-weight: 400;
}
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
/* #120: every session card has two lines, so its row height is stable. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
height: 49px;
}
.card-line1 {
@@ -855,7 +855,8 @@ textarea {
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
flex: 0 1 auto;
/* Consume only the space left after the trailing status + command. */
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -866,12 +867,19 @@ textarea {
signals severity and "exited" dims with the row. */
.card-flag {
color: var(--faint);
flex: 0 0 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Status fragments stay intact. The unconditional command is the final
truncation target when even the complete trailing set cannot fit. */
.leaf-cmd {
flex: 0 1 auto;
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
name (#107). They reserve their width even while hover-faded. */
.leaf-actions {