Compare commits

...
Author SHA1 Message Date
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
Miguel Palhas c2ac3f7eb6 merge: TUI shell/116 — line 2 is unconditional
ci / web (push) Successful in 28s
ci / rust (push) Successful in 6m44s
ci / flake (push) Successful in 8m9s
Closes #116
2026-08-25 12:26:24 +01:00
Miguel Palhas c118020b29 docs(maestro): make sidebar card line 2 unconditional
Line 2 was rendered only past a 30s activity hold, blocked work,
exit, or reported Error, and <command> was gated behind a
metadata.name comparison and TUI-optional. Both worked against
scanability: cards changed height as sessions aged, and fresh
sessions hid their command.

Card is now always two lines. The 30s hold rule survives, demoted
to gating only the <duration> fragment. <command> is unconditional
in both clients.

Closes #116
2026-08-25 12:24:56 +01:00
Miguel Palhas 7cb14c7aae chore: empty commit to test the nix-autodeploy webhook
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m39s
ci / flake (push) Successful in 7m9s
2026-08-25 10:37:16 +01:00
Miguel Palhas 80a9399905 feat(maestro): session cards in the sidebar
ci / web (push) Successful in 29s
ci / rust (push) Successful in 4m25s
ci / flake (push) Successful in 6m34s
Turns each sidebar row into a card: name plus actions on line 1, an
adaptive meta line below carrying a colour-coded activity dot, the time
held in that state, any agent-reported detail, and the blocked-work or
exited markers that used to crowd the name row.

Renders the activity dimension the daemon already inferred and adds an
Error state fed by a Claude Code hook, which maestro installs globally
and addresses per session through MAESTRO_SESSION_ID / MAESTRO_URL.
Both clients implement one contract, docs/SIDEBAR.md.

Closes #101, #102, #103, #104, #105, #106, #107, #108, #110, #111, #112,
#113, #114, #115.
2026-08-24 23:26:59 +01:00
Miguel Palhas 9d9a2e723d merge: sidebar-cards/115-fourth-flake 2026-08-24 23:13:38 +01:00
Miguel Palhas bbafff5a98 fix(maestro): add settle-finished observable for natural-exit test
The no-retained-store test asserts a negative — the tree survives — so
waiting on that negative proves nothing: settle could still remove the
tree one instant after the assertion passes. mark_session_dead now flips
a per-record settled flag once the exit rule has been applied or refused,
exposed as SessionManager::is_settled; the test waits on it before
asserting.

Refs #115
2026-08-24 23:12:46 +01:00
Miguel Palhas 17e1dc4049 merge: sidebar-cards/114-hook-error 2026-08-24 23:01:14 +01:00
Miguel Palhas 436cb194c3 merge: sidebar-cards/113-more-flakes 2026-08-24 23:00:54 +01:00
Miguel Palhas f689c916bf fix(maestro): poll awaiting-input until leaderless state holds
The pipeline leader exits as soon as both stages are forked, which can
beat the surviving stage opening the tty and parking on read — the one-
shot assertion then ran against a group with no parked member yet.
Poll for the answer, accepting it only while the foreground group is
still leaderless so a shell that regained the tty cannot satisfy it.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 88233cb96d fix(maestro): sync watcher re-emit test to real event flow
Two races. First, the watcher updates the snapshot and emits
SessionUpdated as two steps, so the drain could run between them: the
legitimate breakdown emission then arrived inside the quiet window.
Subscribe before the watcher starts and consume through the breakdown
event so the window begins after the last real change.

Second, the assertion treated any SessionUpdated as a failed
suppression, but the activity pass may legitimately announce an
Active→Idle transition in that window (ACTIVE_WINDOW is 3s), carrying
today's blocked_work unchanged. Only a changed indicator counts as a
re-emit now.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas efbccb4e11 fix(maestro): wait for settle in pristine natural-exit test
Dead status is observable a moment before the settle step removes the
worktree, so the assertions raced the removal under parallel load. Wait
for the tree to be gone and the store to stay empty, same as the
retained-store wait cfebfd4 added to the guarded sibling.

Fixes #113
2026-08-24 22:41:54 +01:00
10 changed files with 578 additions and 104 deletions
+26
View File
@@ -62,6 +62,11 @@ pub struct SessionRecord {
/// resumes and [`Session::activity_detail`] is cleared with it. This
/// field is daemon-local bookkeeping only — it never rides the wire.
pub reported_activity: Option<crate::activity::ReportedActivity>,
/// Flipped once the natural-exit settle step (#82) has been applied or
/// refused for this session. `Dead` is observable before settling ends
/// (#115), so this is the observable that says its outcomes are safe to
/// assert on. Daemon-local bookkeeping; never rides the wire.
pub settled: bool,
}
#[derive(Debug, thiserror::Error)]
@@ -294,6 +299,17 @@ impl SessionManager {
sessions.get(&id).map(|r| r.session.status)
}
/// Whether the natural-exit settle step (#82) has finished for this
/// session: `Some(true)` once applied or refused, `Some(false)` while
/// still pending, `None` if the session is unknown. `Dead` is observable
/// before settling ends (#115), so anything asserting on a dead
/// session's worktree waits on this rather than inferring settle
/// completion from its side effects.
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.settled)
}
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
@@ -429,6 +445,7 @@ impl SessionManager {
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
reported_activity: None,
settled: false,
},
);
}
@@ -657,6 +674,7 @@ impl SessionManager {
worktree_branch: None,
bridge,
reported_activity: None,
settled: false,
},
);
let _ = self
@@ -1069,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
}
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+19 -4
View File
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
"never observed a foreground group whose leader had exited"
);
assert!(
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
"a surviving pipeline member still holds the tty"
);
// The leader can be gone while the surviving stage is still setting up:
// `true` exits as soon as both stages are forked, before the reader has
// opened the tty and parked. Poll for the answer, but only accept it
// while the state this test exists for still holds — otherwise a shell
// that regained the terminal would satisfy it vacuously.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut holds = false;
while std::time::Instant::now() < deadline {
let groups = ProcGroups::snapshot();
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
});
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
holds = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(holds, "a surviving pipeline member still holds the tty");
}
+55 -6
View File
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
// Subscribe before the watcher starts so every pass's emission is
// captured — otherwise the breakdown event can land between the
// snapshot observation below and the drain, and the quiet window
// would see it as an illegitimate re-emit.
let mut events = fx.manager.events();
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
})
.await;
// A steady state must not re-emit every tick (#83 change-detection).
let mut events = fx.manager.events();
// Consume through the emission that carried the breakdown (#83
// change-detection): the quiet window below must start after the last
// legitimate change, not race it into the channel.
loop {
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
Ok(Ok(CatalogEvent::SessionUpdated { session }))
if matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
) =>
{
break;
}
Ok(Ok(_)) => continue,
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
}
}
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
"an unchanged worktree must not re-emit SessionUpdated"
);
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
// The activity pass may still legitimately announce e.g. Active→Idle
// (its debounce is time-based, not worktree-based), and its payload
// carries today's blocked_work unchanged.
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
assert!(
matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
),
"an unchanged worktree must not re-emit a changed indicator"
);
}
}
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
@@ -848,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
})
.await;
// (`Dead` is observable a moment before the settle step removes the tree.)
wait_for_async(Duration::from_secs(3), || async {
!wt_path.exists()
&& fx
.retained_store()
.load_all()
.map(|all| all.is_empty())
.unwrap_or(false)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
@@ -905,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
})
.await;
// The settle step has been applied or refused either way — here refused,
// since there is no store to record the tree into. Only from this point
// is "the tree survived" a settled fact rather than a race (#115).
wait_for_async(Duration::from_secs(3), || async {
manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
+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;
+338
View File
@@ -0,0 +1,338 @@
//! 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,
/// 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),
},
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
border_unfocused: Color::Rgb(0x30, 0x36, 0x3d),
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
group_row: Color::Rgb(0x8f, 0xa3, 0xc8),
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11),
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11),
error_text: Color::Rgb(0xf8, 0x51, 0x49),
};
/// 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().fg(self.border_focused)
} else {
Style::default().fg(self.border_unfocused)
}
}
/// Style for a sidebar group heading.
pub fn group_row_style(&self) -> Style {
Style::default()
.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().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.
#[test]
fn chrome_roles_are_rgb_values() {
let p = active();
for (name, color) in [
("border_focused", p.border_focused),
("border_unfocused", p.border_unfocused),
("focus_accent", p.focus_accent),
("group_row", p.group_row),
("mode_normal_bg", p.mode_normal_bg),
("mode_normal_fg", p.mode_normal_fg),
("mode_insert_bg", p.mode_insert_bg),
("mode_insert_fg", p.mode_insert_fg),
("error_text", p.error_text),
] {
assert!(
relative_luminance(color).is_some(),
"{name} must be an RGB value"
);
}
// The mode indicator paints fg on bg; both pairs must stay readable.
for (name, fg, bg) in [
("normal", p.mode_normal_fg, p.mode_normal_bg),
("insert", p.mode_insert_fg, p.mode_insert_bg),
] {
let ratio = contrast_ratio(fg, bg).unwrap();
assert!(ratio >= 4.5, "mode {name} contrast {ratio:.2} is below 4.5");
}
}
#[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}"
);
}
+46 -36
View File
@@ -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