Compare commits

..
Author SHA1 Message Date
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 bc95d16a98 docs(maestro): note PTY colours are passthrough, not palette roles 2026-08-25 12:31:46 +01:00
Miguel Palhas f0108c5645 Merge branch 'blitz/tui-shell' into issue/118-palette-roles 2026-08-25 12:31:17 +01:00
Miguel Palhas c534cee7cc feat(maestro): palette of named colour roles for the TUI
Every colour the TUI painted on its own chrome was a named ratatui
colour spelled out at the use site, so nothing could be themed and the
selected card had broken contrast: the highlighted card's black-on-cyan
base was patched with an activity colour chosen for the dark card
background, leaving AwaitingInput yellow on light blue.

Roles now live in `palette.rs`, named for meaning. A palette holds one
role set per surface — card and selection — plus the panel border in
its focused and unfocused forms, a focus accent, a group-row style for
the sidebar grouping, the status-bar mode indicators and error text.
Each surface carries its own activity colours, so the selected card's
dot keeps carrying state without wearing a colour picked for another
background.

Values are `Color::Rgb`, so contrast is asserted rather than left to
the terminal theme: every role clears WCAG AA on its own surface, and a
sidebar test fails if a card-surface state colour is painted onto the
selection background.

PTY cell colours stay a passthrough of what the program in the pane
emits — they are not chrome and not a palette role.
2026-08-25 12:31:14 +01:00
15 changed files with 575 additions and 159 deletions
+2 -2
View File
@@ -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,
);
+1
View File
@@ -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;
+362
View File
@@ -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);
}
}
+3
View File
@@ -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,
+6 -12
View File
@@ -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}")),
+82 -44
View File
@@ -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
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}");
}
+3
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`).
+20 -11
View File
@@ -69,21 +69,30 @@ height.
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
-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) => {
+2 -3
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,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 {