Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a25e5b83 | ||
|
|
4c3f65027d | ||
|
|
76eb3d1386 | ||
|
|
877f409194 | ||
|
|
39d4014dcb | ||
|
|
f02510465c | ||
|
|
8e251bf8be | ||
|
|
bc95d16a98 | ||
|
|
975f3ef0c5 | ||
|
|
f0108c5645 | ||
|
|
c534cee7cc | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 |
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
pub mod display;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
|
||||
@@ -41,7 +41,7 @@ use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
@@ -1510,7 +1510,7 @@ impl DraftPane {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!("error: {error}"),
|
||||
Style::default().fg(Color::Red),
|
||||
crate::palette::active().error_style(),
|
||||
))),
|
||||
row,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod projects;
|
||||
pub mod render;
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Named colour roles for the TUI.
|
||||
//!
|
||||
//! Every colour the TUI paints on its own chrome comes from here, named for
|
||||
//! what it means rather than what it looks like: the card surface, the
|
||||
//! selection surface, the panel border in its focused and unfocused forms,
|
||||
//! the text weights (`fg` / `muted` / `dim`), and one role per
|
||||
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
|
||||
//! a colour out.
|
||||
//!
|
||||
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
|
||||
//! than of the user's terminal theme — and can therefore be asserted (see
|
||||
//! [`contrast_ratio`] and this module's tests).
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! One palette ships today, reached through [`active`]. Selecting between
|
||||
//! presets from config is a separate issue; [`active`] is the seam it
|
||||
//! replaces.
|
||||
//!
|
||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||
//! pane and pass through `pane::translate_color` verbatim.
|
||||
|
||||
use maestro_protocol::ActivityState;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// A background and the foreground roles legible on it.
|
||||
///
|
||||
/// The sidebar has two: the card surface every card sits on, and the
|
||||
/// selection surface the highlighted card swaps in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Surface {
|
||||
/// The surface itself.
|
||||
pub bg: Color,
|
||||
/// Primary text on this surface.
|
||||
pub fg: Color,
|
||||
/// Secondary text — the blocked-work, exited, detail and command
|
||||
/// fragments of a card's meta line.
|
||||
pub muted: Color,
|
||||
/// The quietest legible text on this surface, for decoration that must
|
||||
/// not compete with content.
|
||||
pub dim: Color,
|
||||
/// `ActivityState::Active`.
|
||||
pub active: Color,
|
||||
/// `ActivityState::Idle`.
|
||||
pub idle: Color,
|
||||
/// `ActivityState::AwaitingInput`.
|
||||
pub awaiting: Color,
|
||||
/// `ActivityState::Error`.
|
||||
pub error: Color,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// The bare surface: a background with no foreground override.
|
||||
pub fn base(&self) -> Style {
|
||||
Style::default().bg(self.bg)
|
||||
}
|
||||
|
||||
/// Primary text on this surface.
|
||||
pub fn text_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.fg)
|
||||
}
|
||||
|
||||
/// Secondary text on this surface.
|
||||
pub fn muted_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.muted)
|
||||
}
|
||||
|
||||
/// The quietest text on this surface.
|
||||
pub fn dim_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.dim)
|
||||
}
|
||||
|
||||
/// The colour carrying `state` on this surface.
|
||||
pub fn activity(&self, state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => self.active,
|
||||
ActivityState::Idle => self.idle,
|
||||
ActivityState::AwaitingInput => self.awaiting,
|
||||
ActivityState::Error => self.error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every foreground role on this surface, paired with its role name.
|
||||
/// Used by the contrast tests, and by anything that needs to reason about
|
||||
/// the surface as a whole.
|
||||
pub fn roles(&self) -> [(&'static str, Color); 7] {
|
||||
[
|
||||
("fg", self.fg),
|
||||
("muted", self.muted),
|
||||
("dim", self.dim),
|
||||
("active", self.active),
|
||||
("idle", self.idle),
|
||||
("awaiting", self.awaiting),
|
||||
("error", self.error),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The full set of colour roles the TUI paints with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
/// The surface an unselected card sits on.
|
||||
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.
|
||||
pub border_unfocused: Color,
|
||||
/// Focus affordances that are not the border itself — markers, titles and
|
||||
/// the like on the panel that holds focus.
|
||||
pub focus_accent: Color,
|
||||
/// Sidebar group headings.
|
||||
pub group_row: Color,
|
||||
/// Status-bar mode indicator, normal mode.
|
||||
pub mode_normal_bg: Color,
|
||||
pub mode_normal_fg: Color,
|
||||
/// Status-bar mode indicator, insert mode.
|
||||
pub mode_insert_bg: Color,
|
||||
pub mode_insert_fg: Color,
|
||||
/// Error text outside a card — form errors, failed actions.
|
||||
pub error_text: Color,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The one palette that ships today.
|
||||
pub const DEFAULT: 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),
|
||||
},
|
||||
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),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x14, 0x16, 0x1b),
|
||||
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
border_unfocused: Color::Rgb(0x6b, 0x72, 0x80),
|
||||
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),
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
if selected {
|
||||
&self.selection
|
||||
} else {
|
||||
&self.card
|
||||
}
|
||||
}
|
||||
|
||||
/// Border style for a panel, by whether it holds focus.
|
||||
pub fn border_style(&self, focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().bg(self.chrome_bg).fg(self.border_focused)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Style for the status bar's mode indicator.
|
||||
pub fn mode_style(&self, insert: bool) -> Style {
|
||||
if insert {
|
||||
Style::default()
|
||||
.fg(self.mode_insert_fg)
|
||||
.bg(self.mode_insert_bg)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(self.mode_normal_fg)
|
||||
.bg(self.mode_normal_bg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style for error text outside a card.
|
||||
pub fn error_style(&self) -> Style {
|
||||
Style::default().bg(self.chrome_bg).fg(self.error_text)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Palette {
|
||||
fn default() -> Self {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette in force. Config-driven preset selection lands here.
|
||||
pub fn active() -> Palette {
|
||||
Palette::DEFAULT
|
||||
}
|
||||
|
||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||
/// luminance — they are whatever the terminal theme says — so they return
|
||||
/// `None` and cannot be contrast-checked.
|
||||
pub fn relative_luminance(color: Color) -> Option<f64> {
|
||||
let Color::Rgb(r, g, b) = color else {
|
||||
return None;
|
||||
};
|
||||
fn channel(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
|
||||
/// (black on white). `None` if either colour is not RGB.
|
||||
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
Some((hi + 0.05) / (lo + 0.05))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STATES: [ActivityState; 4] = [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug this palette fixes: a state colour chosen for the dark card
|
||||
/// background is not legible on the selection background, so the
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [
|
||||
("chrome_bg", p.chrome_bg),
|
||||
("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 (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,
|
||||
"{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_ratio_matches_known_values() {
|
||||
let black = Color::Rgb(0, 0, 0);
|
||||
let white = Color::Rgb(255, 255, 255);
|
||||
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
|
||||
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
|
||||
assert_eq!(contrast_ratio(Color::Cyan, white), None);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// PTY cell colours pass through verbatim: they belong to the program
|
||||
/// running in the pane, not to the TUI's own chrome, so they are deliberately
|
||||
/// not palette roles (see `crate::palette`).
|
||||
fn translate_color(c: vt100::Color) -> Option<Color> {
|
||||
match c {
|
||||
vt100::Color::Default => None,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! hit-test mouse clicks.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
@@ -11,6 +11,7 @@ use ratatui::Frame;
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
@@ -193,14 +194,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// Border style for a panel based on whether it currently has focus.
|
||||
/// Focused = cyan; unfocused = the terminal default.
|
||||
/// Border style for a panel based on whether it currently has focus. Both
|
||||
/// forms are palette roles (`border_focused` / `border_unfocused`).
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
palette::active().border_style(focused)
|
||||
}
|
||||
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
@@ -219,10 +216,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: back to normal",
|
||||
};
|
||||
let mode_style = match state.mode {
|
||||
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
|
||||
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
let line = ratatui::text::Line::from(vec![
|
||||
Span::styled(format!(" {mode_label} "), mode_style),
|
||||
Span::raw(format!(" {focus_label} • {hint}")),
|
||||
|
||||
@@ -16,6 +16,8 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
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).
|
||||
const META_LINE_AFTER_SECS: i64 = 30;
|
||||
@@ -30,16 +32,12 @@ const META_INDENT: &str = " ";
|
||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||
const ACTIONS: &str = "⧉ ✕";
|
||||
|
||||
/// Every card sits on this background so cards read as cards against the
|
||||
/// panel; the highlighted card swaps it for the focus background instead
|
||||
/// (`Modifier::REVERSED` made the card unreadable against a background).
|
||||
const CARD_BG: Color = Color::Black;
|
||||
/// Every card sits on the palette's card surface so cards read as cards
|
||||
/// against the panel; the highlighted card swaps in the selection surface
|
||||
/// instead (`Modifier::REVERSED` made the card unreadable against a
|
||||
/// background).
|
||||
fn card_base(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().bg(CARD_BG)
|
||||
}
|
||||
palette::active().surface(highlighted).text_style()
|
||||
}
|
||||
|
||||
/// Renderable sidebar state.
|
||||
@@ -60,11 +58,7 @@ impl Sidebar {
|
||||
/// the v1 layout — a single vertical rule keeps the panel boundary
|
||||
/// without stealing space.
|
||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let border_style = palette::active().border_style(focused);
|
||||
let block = Block::default()
|
||||
.borders(Borders::RIGHT)
|
||||
.border_style(border_style);
|
||||
@@ -121,10 +115,7 @@ impl Sidebar {
|
||||
);
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(fill),
|
||||
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
|
||||
));
|
||||
spans.push(Span::styled(" ".repeat(fill), base));
|
||||
}
|
||||
if highlighted {
|
||||
spans.push(Span::styled(ACTIONS, base));
|
||||
@@ -149,9 +140,10 @@ impl Sidebar {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = card_base(self.highlight == Some(s.id));
|
||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
|
||||
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
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)));
|
||||
let muted = palette::active().surface(highlighted).muted_style();
|
||||
let mut spans = vec![
|
||||
Span::styled(META_INDENT, base),
|
||||
Span::styled("●", dot_style),
|
||||
@@ -184,15 +176,12 @@ impl Sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
|
||||
/// word is never spelled out — this colour carries it.
|
||||
fn activity_color(state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => Color::Green,
|
||||
ActivityState::Idle => Color::DarkGray,
|
||||
ActivityState::AwaitingInput => Color::Yellow,
|
||||
ActivityState::Error => Color::Red,
|
||||
}
|
||||
/// Palette role carrying an [`ActivityState`], per docs/SIDEBAR.md. The
|
||||
/// state word is never spelled out — this colour carries it. The highlighted
|
||||
/// card takes its state colours from the selection surface: a colour picked
|
||||
/// for the dark card background is not legible on the selection background.
|
||||
fn activity_color(state: ActivityState, highlighted: bool) -> Color {
|
||||
palette::active().surface(highlighted).activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -246,8 +235,7 @@ fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
||||
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
||||
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
spans.push(Span::styled(" ".repeat(fill), pad_style));
|
||||
spans.push(Span::styled(" ".repeat(fill), base));
|
||||
}
|
||||
spans
|
||||
}
|
||||
@@ -474,12 +462,13 @@ mod tests {
|
||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||
#[test]
|
||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||
for (state, expected) in [
|
||||
(ActivityState::Active, Color::Green),
|
||||
(ActivityState::Idle, Color::DarkGray),
|
||||
(ActivityState::AwaitingInput, Color::Yellow),
|
||||
(ActivityState::Error, Color::Red),
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
let expected = palette::active().card.activity(state);
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = state;
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
@@ -495,6 +484,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected card keeps carrying its state on the dot, 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.
|
||||
#[test]
|
||||
fn selected_card_never_paints_a_card_state_colour_on_the_selection_background() {
|
||||
let p = palette::active();
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = state;
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let on_selection = |fg: Color| {
|
||||
(0..buf.area.height).any(|y| {
|
||||
(0..buf.area.width).any(|x| {
|
||||
let st = buf[(x, y)].style();
|
||||
st.bg == Some(p.selection.bg) && st.fg == Some(fg)
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
!on_selection(p.card.activity(state)),
|
||||
"{state:?}: card surface colour {:?} painted onto the selection background",
|
||||
p.card.activity(state)
|
||||
);
|
||||
assert!(
|
||||
on_selection(p.selection.activity(state)),
|
||||
"{state:?}: the dot 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");
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{state:?} on the selection background is only {ratio:.2}:1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
||||
/// even freshly dead, with the muted `exited` fragment.
|
||||
#[test]
|
||||
@@ -740,20 +777,21 @@ mod tests {
|
||||
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
|
||||
|
||||
let p = palette::active();
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().bg,
|
||||
Some(Color::Cyan),
|
||||
"focused card must sit on the focus background"
|
||||
Some(p.selection.bg),
|
||||
"focused card must sit on the selection surface"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().fg,
|
||||
Some(Color::Black),
|
||||
"focused card text must stay readable on cyan"
|
||||
Some(p.selection.fg),
|
||||
"focused card text must use the selection foreground role"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, bash_y as u16)].style().bg,
|
||||
Some(Color::Black),
|
||||
"unfocused cards must still have a card background"
|
||||
Some(p.card.bg),
|
||||
"unfocused cards must still sit on the card surface"
|
||||
);
|
||||
// The panel itself (header row) stays on the terminal default.
|
||||
assert!(
|
||||
@@ -776,7 +814,7 @@ mod tests {
|
||||
for x in 0..27 {
|
||||
assert_eq!(
|
||||
buf[(x, y)].style().bg,
|
||||
Some(Color::Black),
|
||||
Some(palette::active().card.bg),
|
||||
"gap in card background at col {x}"
|
||||
);
|
||||
}
|
||||
|
||||
+37
-12
@@ -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() {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
+46
-36
@@ -5,8 +5,7 @@ sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
||||
Read this before changing either. Both clients implement exactly what is
|
||||
written here; if it needs to change, change it here first.
|
||||
|
||||
A card has **line 1** always and **line 2** only when line 2 has something to
|
||||
say.
|
||||
A card is always two lines: **line 1** and **line 2**.
|
||||
|
||||
## Line 1 — name + actions
|
||||
|
||||
@@ -25,32 +24,32 @@ badge move off this row into line 2.
|
||||
|
||||
## Line 2 — meta line (adaptive)
|
||||
|
||||
Rendered only when at least one of these holds:
|
||||
Line 2 always renders — never an empty placeholder row either, since its
|
||||
unconditional fragments (see below) always have something to show. Its
|
||||
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||
itself is not.
|
||||
|
||||
1. The session has been held in its current activity state longer than
|
||||
**30 seconds** (`activity_since` from #102). A freshly-active session
|
||||
stays one line.
|
||||
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
|
||||
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
|
||||
and as `⚠ <summary>` on the TUI.
|
||||
3. It has exited: `status == Dead`.
|
||||
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
|
||||
|
||||
Otherwise line 2 is absent — never an empty placeholder row.
|
||||
|
||||
When present, line 2 reads left to right:
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
|
||||
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
|
||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||
state's colour, and `<duration>` follows in the same colour. 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
|
||||
and duration are unconditional parts of line 2; the bracketed fragments appear
|
||||
only when their trigger holds. A payload that predates #102 (no usable
|
||||
`activity_since`) contributes no duration — the dot still renders.
|
||||
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.
|
||||
|
||||
`<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
|
||||
fragment; it no longer decides whether line 2 exists.
|
||||
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||
@@ -60,29 +59,40 @@ truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<command>` is optional and muted: the session's command line, shown only when
|
||||
the rendered name came from `metadata.name` and differs from the command (the
|
||||
web has always surfaced this; the TUI may omit it).
|
||||
`<command>` is unconditional and muted: the session's command line, shown on
|
||||
every card in both clients — no omission in the TUI, no name-vs-command
|
||||
comparison gating it. A session named by its command line then shows the same
|
||||
string on both lines; that is accepted, the price of a row that never changes
|
||||
height.
|
||||
|
||||
## Colour per activity state
|
||||
|
||||
The state word is never spelled out — the colour carries it.
|
||||
|
||||
| Activity state | Web token | ratatui colour |
|
||||
| ---------------- | --------- | ----------------- |
|
||||
| `Active` | `--live` | `Color::Green` |
|
||||
| `Idle` | `--muted` | `Color::DarkGray` |
|
||||
| `AwaitingInput` | `--warn` | `Color::Yellow` |
|
||||
| `Error` | `--danger`| `Color::Red` |
|
||||
| Activity state | Web token | TUI palette role |
|
||||
| ---------------- | --------- | ---------------- |
|
||||
| `Active` | `--live` | `active` |
|
||||
| `Idle` | `--muted` | `idle` |
|
||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||
| `Error` | `--danger`| `error` |
|
||||
|
||||
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
||||
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
|
||||
named colours above; do not introduce RGB values or a new palette module for
|
||||
this.
|
||||
and `prefers-color-scheme` variants for each). The TUI names roles, not
|
||||
colours: a role's value comes from the active palette
|
||||
(`maestro-tui/src/palette.rs`), never from a colour spelled out at the use
|
||||
site.
|
||||
|
||||
A palette holds one role set per **surface** — the card surface every card
|
||||
sits on, and the selection surface the highlighted card swaps in — so each
|
||||
state role resolves to the value picked for the surface it is painted on. The
|
||||
highlighted card therefore takes its state colours from the selection
|
||||
surface; a colour chosen for the card background is never painted onto the
|
||||
selection background. Contrast is a property of the palette (values are RGB)
|
||||
and is asserted in the palette's tests, not left to the terminal theme.
|
||||
|
||||
The duration text inherits its state's colour; the blocked-work, exited and
|
||||
command fragments use the muted colour (`--faint` / default style), since the
|
||||
⚠ glyph already signals severity.
|
||||
command fragments use the surface's `muted` role, since the ⚠ glyph already
|
||||
signals severity.
|
||||
|
||||
## Duration format
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
+2
-3
@@ -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,12 @@ 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;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.card-line1 {
|
||||
|
||||
Reference in New Issue
Block a user