diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 41be194..9c35c28 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -184,8 +184,16 @@ impl Sidebar { grouped .into_iter() .map(|(key, mut sessions)| { - sessions - .sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id))); + // docs/SIDEBAR.md, "Dormant cards": a dormant session takes + // the lowest precedence, name notwithstanding, so a reboot + // that leaves a tree dormant never buries live work spawned + // into it. + sessions.sort_by(|a, b| { + dormant_rank(a) + .cmp(&dormant_rank(b)) + .then_with(|| name_for(a).cmp(&name_for(b))) + .then_with(|| a.id.cmp(&b.id)) + }); SidebarGroup { collapsed: collapsed.contains(&key), key, @@ -276,11 +284,28 @@ impl Sidebar { let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let fill = self.card_fill(s, focused); let base = card_base(fill, focused); + let palette = palette::active(); + let surface = palette.surface(fill.filled()); let prefix = 1 + INDENT.chars().count() + 2; let name_budget = width.saturating_sub(prefix); + // docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant + // session's activity to Idle and there is nothing running to report + // otherwise, so the rail carries no colour — the muted role, not the + // idle one. + let dormant = s.status == SessionStatus::Dormant; + let muted_style = base.patch( + surface + .muted_style() + .bg(base.bg.unwrap_or(palette::active().chrome_bg)), + ); + let edge = highlighted && !focused; - let rail_style = base.fg(activity_color(s.activity, fill)); + let rail_style = if dormant { + base.fg(surface.muted) + } else { + base.fg(activity_color(s.activity, fill)) + }; let mut spans: Vec> = vec![ Span::styled(RAIL, rail_style), Span::styled( @@ -291,18 +316,19 @@ impl Sidebar { base }, ), - Span::styled(format!("{glyph} "), base), + Span::styled( + format!("{glyph} "), + if dormant { muted_style } else { base }, + ), ]; // #131: a name the daemon generated is provisional — the session's // first prompt may replace it — so it reads muted, like the meta line, // rather than as a name someone chose. See docs/SIDEBAR.md. - let name_style = if maestro_protocol::display::is_provisional_name(s) { - base.patch( - palette::active() - .surface(fill.filled()) - .muted_style() - .bg(base.bg.unwrap_or(palette::active().chrome_bg)), - ) + // A dormant card (#168, #169) is a muted variant of the whole card — + // the same muted foreground `exited` already uses — regardless of + // whether the name itself is provisional. + let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) { + muted_style } else { base }; @@ -328,11 +354,18 @@ impl Sidebar { let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let fill = self.card_fill(s, focused); let base = card_base(fill, focused); - let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill))); let muted = palette::active() .surface(fill.filled()) .muted_style() .bg(base.bg.unwrap_or(palette::active().chrome_bg)); + // docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the + // duration that follows it (rendered in the rail's own colour) is + // muted rather than the idle role. + let rail_style = if dormant { + base.patch(muted) + } else { + base.patch(Style::default().fg(activity_color(s.activity, fill))) + }; let edge = highlighted && !focused; let mut spans = vec![ Span::styled(RAIL, rail_style), @@ -458,6 +491,10 @@ fn pad_to_width(mut spans: Vec>, width: usize, base: Style) -> Vec spans } +fn dormant_rank(s: &Session) -> u8 { + u8::from(s.status == SessionStatus::Dormant) +} + /// `metadata.name` if present, else the command line. pub(crate) fn name_for(s: &Session) -> String { custom_name_for(s).unwrap_or_else(|| s.command.clone()) @@ -1108,6 +1145,100 @@ mod tests { ); } + /// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts + /// after every live one in its folder, name notwithstanding. + #[test] + fn dormant_sessions_sort_after_live_ones_in_their_folder() { + let mut dormant = session_named(1, "aaa-dormant"); + dormant.status = SessionStatus::Dormant; + let live = session_named(2, "zzz-live"); + let sb = Sidebar { + sessions: vec![dormant, live], + highlight: None, + attached: Default::default(), + pane_session: None, + }; + let names: Vec = sb.groups(&HashSet::new())[0] + .sessions + .iter() + .map(name_for) + .collect(); + assert_eq!(names, vec!["zzz-live", "aaa-dormant"]); + } + + /// Dormant sessions still sort by name against each other. + #[test] + fn dormant_sessions_still_sort_by_name_against_each_other() { + let mut b = session_named(1, "b"); + b.status = SessionStatus::Dormant; + let mut a = session_named(2, "a"); + a.status = SessionStatus::Dormant; + let sb = Sidebar { + sessions: vec![b, a], + highlight: None, + attached: Default::default(), + pane_session: None, + }; + let names: Vec = sb.groups(&HashSet::new())[0] + .sessions + .iter() + .map(name_for) + .collect(); + assert_eq!(names, vec!["a", "b"]); + } + + /// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of + /// the normal card — the same muted foreground `exited` already uses — + /// covering the glyph and the name, not only the meta line. + #[test] + fn dormant_card_renders_its_name_and_glyph_muted() { + let mut s = session_named(1, "claude"); + s.status = SessionStatus::Dormant; + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + pane_session: None, + }; + let buf = draw(&sb, 44, 8); + let palette = palette::active(); + let surface = palette.surface(false); + let colour_of = |needle: char| { + (0..buf.area.height) + .flat_map(|y| (0..buf.area.width).map(move |x| (x, y))) + .find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string()) + .map(|(x, y)| buf[(x, y)].fg) + .expect("must be drawn") + }; + assert_eq!(colour_of('c'), surface.muted, "the dormant name"); + assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph"); + } + + /// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle + /// and there is nothing running to report, so the rail is driven by the + /// muted role directly rather than `activity_color`. + #[test] + fn dormant_card_rail_carries_no_colour() { + let mut s = session_named(1, "claude"); + s.status = SessionStatus::Dormant; + s.activity = ActivityState::Idle; + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + pane_session: None, + }; + let buf = draw(&sb, 44, 8); + let palette = palette::active(); + let surface = palette.surface(false); + let rail_colour = (0..buf.area.height) + .flat_map(|y| (0..buf.area.width).map(move |x| (x, y))) + .find(|&(x, y)| buf[(x, y)].symbol() == RAIL) + .map(|(x, y)| buf[(x, y)].fg) + .expect("rail must be drawn"); + assert_eq!(rail_colour, surface.muted); + } + /// docs/SIDEBAR.md: line 2 names the program, never its arguments. #[test] fn meta_line_drops_the_commands_arguments() {