diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index cd17e98..9e2bda0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -1,10 +1,14 @@ -//! Sidebar widget — session list, navigation-only. +//! Sidebar widget — session cards per `docs/SIDEBAR.md`. //! -//! Status glyphs: ● attached / ○ live / ✗ dead. +//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name. +//! Line 2: activity dot + duration in the state's colour, plus the +//! blocked-work and exited fragments when they apply. Absent entirely when +//! it has nothing to say. use std::collections::HashSet; -use maestro_protocol::{Session, SessionStatus}; +use chrono::Utc; +use maestro_protocol::{ActivityState, Session, SessionStatus}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -12,6 +16,26 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use uuid::Uuid; +/// 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; +const INDENT: &str = " "; +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; +fn card_base(highlighted: bool) -> Style { + if highlighted { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().bg(CARD_BG) + } +} + /// Renderable sidebar state. #[derive(Debug, Clone)] pub struct Sidebar { @@ -41,36 +65,155 @@ impl Sidebar { let inner = block.inner(area); f.render_widget(block, area); + let width = inner.width as usize; let mut lines: Vec = vec![Line::from(Span::styled( "sessions", Style::default().add_modifier(Modifier::BOLD), ))]; for s in &self.sessions { - let glyph = match (s.status, self.attached.contains(&s.id)) { - (SessionStatus::Dead, _) => "✗", - (SessionStatus::Running, true) => "●", - (SessionStatus::Running, false) => "○", - }; - let label = name_for(s); - // #83: a session whose worktree blocks a kill gets a warning - // glyph; the kill confirm names the specifics. - let blocked = s - .blocked_work - .filter(|b| !b.is_clear()) - .map(|_| " ⚠") - .unwrap_or_default(); - let style = if self.highlight == Some(s.id) { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{glyph} {label}{blocked}"), style), - ])); + let highlighted = self.highlight == Some(s.id); + let base = card_base(highlighted); + lines.push(Line::from(self.name_row(s, width))); + if let Some(meta) = self.meta_row(s, width) { + lines.push(Line::from(pad_to_width(meta, width, base))); + } } f.render_widget(Paragraph::new(lines), inner); } + + fn name_row(&self, s: &Session, width: usize) -> Vec> { + let glyph = match (s.status, self.attached.contains(&s.id)) { + (SessionStatus::Dead, _) => "✗", + (SessionStatus::Running, true) => "●", + (SessionStatus::Running, false) => "○", + }; + let highlighted = self.highlight == Some(s.id); + let base = card_base(highlighted); + // Line-1 actions are keyboard affordances shown on the highlighted + // row only; they reserve their width so the name truncates with `…` + // before it can ever reach them (docs/SIDEBAR.md). + let actions_width = if highlighted { + ACTIONS.chars().count() + } else { + 0 + }; + let usable = width.saturating_sub(actions_width); + let prefix = INDENT.chars().count() + 2; + // Keep one column of separation between a truncated name and the + // actions so the ellipsis never touches them. + let sep = if highlighted { 1 } else { 0 }; + let name_budget = usable.saturating_sub(prefix + sep); + + let mut spans: Vec> = vec![ + Span::styled(INDENT, base), + Span::styled(format!("{glyph} "), base), + ]; + spans.extend( + fit_to_width(vec![Span::raw(name_for(s))], name_budget) + .into_iter() + .map(|sp| Span::styled(sp.content, base.patch(sp.style))), + ); + 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)), + )); + } + if highlighted { + spans.push(Span::styled(ACTIONS, base)); + } + spans + } + + /// The adaptive second line: ` [ | exited]`. + fn meta_row(&self, s: &Session, width: usize) -> Option>> { + let now = Utc::now(); + let held_secs = (now - s.activity_since).num_seconds(); + // #83: a worktree that blocks a kill is summarised here rather than + // on line 1; the kill confirm names the specifics. + let blocked = s.blocked_work.filter(|b| !b.is_clear()); + let dead = s.status == SessionStatus::Dead; + let errored = s.activity == ActivityState::Error; + if held_secs < META_LINE_AFTER_SECS && blocked.is_none() && !dead && !errored { + 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 duration = format_state_duration(now - s.activity_since); + let mut spans = vec![ + Span::styled(META_INDENT, base), + Span::styled("●", dot_style), + Span::styled(" ", base), + Span::styled(duration, dot_style), + ]; + if let Some(b) = blocked { + spans.push(Span::styled(format!(" ⚠ {b}"), muted)); + } + if dead { + spans.push(Span::styled(" exited", muted)); + } + Some(fit_to_width(spans, width)) + } +} + +/// 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, + } +} + +/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours, +/// measured in the current activity state. No days, no rounding up. +fn format_state_duration(elapsed: chrono::Duration) -> String { + let secs = elapsed.num_seconds().max(0); + match secs { + 0..=59 => format!("{secs}s"), + 60..=3599 => format!("{}m", secs / 60), + _ => format!("{}h", secs / 3600), + } +} + +/// Truncate a span list to `max` columns, ending it with `…` when cut. The +/// sidebar is narrow: card lines truncate, they never wrap. +fn fit_to_width(spans: Vec>, max: usize) -> Vec> { + let mut out: Vec> = Vec::with_capacity(spans.len()); + let mut used = 0usize; + for span in spans { + let w = span.width(); + if used + w <= max { + used += w; + out.push(span); + continue; + } + let budget = max.saturating_sub(used + 1); + let ellipsis = "…"; + if budget > 0 { + let text: String = span.content.chars().take(budget).collect(); + out.push(Span::styled(format!("{text}{ellipsis}"), span.style)); + } else if used < max { + out.push(Span::styled(ellipsis, span.style)); + } + return out; + } + out +} + +/// Pad a card row out to the full sidebar width so the card background +/// paints edge to edge rather than stopping at the last glyph. +fn pad_to_width(mut spans: Vec>, width: usize, base: Style) -> Vec> { + 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 } /// `metadata.name` if present, else the command line. @@ -85,8 +228,10 @@ fn name_for(s: &Session) -> String { #[cfg(test)] mod tests { use super::*; - use chrono::Utc; + use chrono::Duration; + use maestro_protocol::InTheWay; use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; use ratatui::Terminal; use std::collections::HashMap; @@ -104,26 +249,43 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), - last_activity_at: chrono::Utc::now(), - activity_since: chrono::Utc::now(), + last_activity_at: Utc::now(), + activity_since: Utc::now(), current_cwd: None, blocked_work: None, } } - fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String { + fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer { let backend = TestBackend::new(w, h); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| sidebar.render(f, f.area())).unwrap(); - let buf = terminal.backend().buffer().clone(); - let mut out = String::new(); - for y in 0..buf.area.height { - for x in 0..buf.area.width { - out.push_str(buf[(x, y)].symbol()); - } - out.push('\n'); - } - out + terminal.backend().buffer().clone() + } + + fn rows(buf: &Buffer) -> Vec { + (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect() + } + + fn render_to_string(buf: &Buffer) -> String { + rows(buf).join("\n") + } + + fn row_containing<'a>(rows: &'a [String], needle: &str) -> &'a str { + rows.iter() + .find(|l| l.contains(needle)) + .unwrap_or_else(|| panic!("no row containing {needle:?} in {rows:#?}")) + } + + /// Row text without the trailing space padding and right border. + fn content(row: &str) -> &str { + row.trim_end().trim_end_matches('│').trim_end() } #[test] @@ -133,7 +295,7 @@ mod tests { highlight: None, attached: Default::default(), }; - let rendered = render_to_string(&sb, 28, 12); + let rendered = render_to_string(&draw(&sb, 28, 12)); assert!( rendered.contains("claude"), "missing session name:\n{rendered}" @@ -152,11 +314,8 @@ mod tests { highlight: None, attached: [sid].into_iter().collect(), }; - let rendered = render_to_string(&sb, 28, 6); - let line = rendered - .lines() - .find(|l| l.contains("claude")) - .expect("claude row"); + let all = rows(&draw(&sb, 28, 6)); + let line = row_containing(&all, "claude"); assert!(line.contains('●'), "expected filled glyph in: {line:?}"); assert!( !line.contains('○'), @@ -173,21 +332,18 @@ mod tests { highlight: None, attached: Default::default(), }; - let rendered = render_to_string(&sb, 28, 6); - let line = rendered - .lines() - .find(|l| l.contains("claude")) - .expect("claude row"); + let buf = draw(&sb, 28, 6); + let all = rows(&buf); + let line = row_containing(&all, "claude"); assert!(line.contains('✗'), "expected dead glyph in: {line:?}"); } - /// #83: a session whose worktree blocks a kill carries the warning glyph; - /// a clear or unprobed one does not. + /// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not + /// on the name row; a clear or unprobed worktree never warns. #[test] - fn blocked_session_gets_a_warning_glyph() { - use maestro_protocol::InTheWay; - + fn blocked_session_warns_on_its_meta_line() { let mut blocked = session_named(1, "claude"); + blocked.activity_since = Utc::now() - Duration::seconds(5); blocked.blocked_work = Some(InTheWay { unpushed_commits: 4, ..InTheWay::default() @@ -200,21 +356,158 @@ mod tests { highlight: None, attached: Default::default(), }; - let rendered = render_to_string(&sb, 40, 8); - let line = rendered - .lines() - .find(|l| l.contains("claude")) - .expect("blocked row"); - assert!(line.contains('⚠'), "expected warning glyph in: {line:?}"); + let all = rows(&draw(&sb, 40, 10)); + + let meta = all + .iter() + .find(|l| l.contains("⚠")) + .expect("blocked card must warn somewhere"); + assert!( + meta.contains("4 unpushed commits"), + "warning must carry InTheWay's summary: {meta:?}" + ); + let name_row = row_containing(&all, "claude"); + assert!( + !name_row.contains('⚠'), + "warning moved off the name row: {name_row:?}" + ); for name in ["bash", "vim"] { - let line = rendered - .lines() - .find(|l| l.contains(name)) - .expect("clear row"); - assert!(!line.contains('⚠'), "{name} must not warn: {line:?}"); + let idx = all.iter().position(|l| l.contains(name)).unwrap(); + assert!( + !all[idx + 1].contains('⚠'), + "{name} must not warn: {:?}", + all[idx + 1] + ); } } + /// A freshly-active session stays one line — never an empty placeholder + /// row (docs/SIDEBAR.md). + #[test] + fn freshly_active_session_stays_one_line() { + let mut s = session_named(1, "claude"); + s.activity_since = Utc::now() - Duration::seconds(10); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + assert_eq!(content(&all[0]), "sessions"); + assert!(row_containing(&all, "claude").contains('○')); + assert!( + content(&all[2]).is_empty(), + "line 2 must be absent, got {:?}", + all[2] + ); + } + + /// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the + /// meta line, with the floored duration next to the dot. + #[test] + fn long_held_session_gets_a_meta_line_with_duration() { + let mut s = session_named(1, "claude"); + s.activity_since = Utc::now() - Duration::seconds(90); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + let meta = &all[idx + 1]; + assert!(meta.contains('●'), "meta line carries the dot: {meta:?}"); + assert!(meta.contains("1m"), "expected 1m in: {meta:?}"); + } + + #[test] + fn duration_format_floors_whole_units() { + let fmt = |secs| format_state_duration(Duration::seconds(secs)); + assert_eq!(fmt(0), "0s"); + assert_eq!(fmt(45), "45s"); + assert_eq!(fmt(59), "59s"); + assert_eq!(fmt(60), "1m"); + assert_eq!(fmt(240), "4m"); + assert_eq!(fmt(3599), "59m"); + assert_eq!(fmt(3600), "1h"); + assert_eq!(fmt(7200), "2h"); + } + + /// The dot and duration carry the activity state's colour; the state word + /// 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), + ] { + 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: None, + attached: Default::default(), + }; + let buf = draw(&sb, 28, 8); + let found = (0..buf.area.height) + .any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().fg == Some(expected))); + assert!(found, "expected some cell in fg {expected:?} for {state:?}"); + } + } + + /// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line, + /// even freshly dead, with the muted `exited` fragment. + #[test] + fn dead_session_shows_exited_on_its_meta_line() { + let mut s = session_named(1, "claude"); + s.status = SessionStatus::Dead; + s.activity_since = Utc::now() - Duration::seconds(5); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + assert!( + all[idx + 1].contains("exited"), + "expected exited fragment in: {:?}", + all[idx + 1] + ); + } + + /// The sidebar is narrow: the meta line truncates with `…` and never + /// wraps onto a third row (docs/SIDEBAR.md). + #[test] + fn meta_line_truncates_instead_of_wrapping() { + let mut s = session_named(1, "claude"); + s.activity_since = Utc::now() - Duration::seconds(5); + s.blocked_work = Some(InTheWay { + uncommitted_files: 120, + ..InTheWay::default() + }); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + let meta = content(&all[idx + 1]); + assert!( + meta.trim_end().ends_with('…'), + "truncated meta line must end with …: {meta:?}" + ); + assert!( + content(&all[idx + 2]).trim().is_empty(), + "meta line must not wrap: {:?}", + all[idx + 2] + ); + } + #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { @@ -228,9 +521,8 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), - last_activity_at: chrono::Utc::now(), - activity_since: chrono::Utc::now(), - + last_activity_at: Utc::now(), + activity_since: Utc::now(), current_cwd: None, blocked_work: None, }; @@ -239,10 +531,150 @@ mod tests { highlight: None, attached: Default::default(), }; - let rendered = render_to_string(&sb, 28, 6); + let rendered = render_to_string(&draw(&sb, 28, 6)); assert!( rendered.contains("vim"), "missing fallback command name:\n{rendered}" ); } + + /// Reversed video makes a card unreadable against a background; cards + /// use explicit backgrounds instead (docs/SIDEBAR.md, #108). + #[test] + fn no_cell_is_ever_reversed() { + let mut highlighted = session_named(1, "claude"); + highlighted.activity_since = Utc::now() - Duration::seconds(120); + let sb = Sidebar { + sessions: vec![highlighted], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let buf = draw(&sb, 28, 8); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + assert!( + !buf[(x, y)] + .style() + .add_modifier + .contains(Modifier::REVERSED), + "reversed video at ({x},{y})" + ); + } + } + } + + /// The highlighted card gets the focus background; every other card gets + /// a card background distinct from the panel's default. + #[test] + fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() { + let one = session_named(1, "claude"); + let two = session_named(2, "bash"); + let sb = Sidebar { + sessions: vec![one, two], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let buf = draw(&sb, 28, 8); + let all = rows(&buf); + let claude_y = all.iter().position(|l| l.contains("claude")).unwrap(); + let bash_y = all.iter().position(|l| l.contains("bash")).unwrap(); + + assert_eq!( + buf[(0, claude_y as u16)].style().bg, + Some(Color::Cyan), + "focused card must sit on the focus background" + ); + assert_eq!( + buf[(0, claude_y as u16)].style().fg, + Some(Color::Black), + "focused card text must stay readable on cyan" + ); + assert_eq!( + buf[(0, bash_y as u16)].style().bg, + Some(Color::Black), + "unfocused cards must still have a card background" + ); + // The panel itself (header row) stays on the terminal default. + assert!( + matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)), + "panel must keep the terminal default background" + ); + } + + /// The card background paints the full card width, not just the glyphs. + #[test] + fn card_background_covers_the_full_card_width() { + let s = session_named(1, "claude"); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let buf = draw(&sb, 28, 8); + let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width; + for x in 0..27 { + assert_eq!( + buf[(x, y)].style().bg, + Some(Color::Black), + "gap in card background at col {x}" + ); + } + } + + /// Line 1's actions live on the highlighted row's top-right corner. + #[test] + fn highlighted_row_anchors_the_actions_top_right() { + let sb = Sidebar { + sessions: vec![session_named(1, "claude")], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let line = content(row_containing(&all, "claude")); + assert!( + line.ends_with("⧉ ✕"), + "actions must sit at the right edge: {line:?}" + ); + } + + #[test] + fn unhighlighted_rows_show_no_actions() { + let one = session_named(1, "claude"); + let two = session_named(2, "bash"); + let sb = Sidebar { + sessions: vec![one, two], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let line = row_containing(&all, "bash"); + assert!( + !line.contains('⧉') && !line.contains('✕'), + "actions are highlight-only: {line:?}" + ); + } + + /// The name truncates with `…` before it can ever reach the reserved + /// action width (docs/SIDEBAR.md — overlap is a defect). + #[test] + fn name_truncates_before_reaching_the_actions() { + let mut s = session_named(1, "claude"); + if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") { + *name = "an-unreasonably-long-session-name".into(); + } + let sb = Sidebar { + sessions: vec![s], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let all = rows(&draw(&sb, 28, 8)); + let line = row_containing(&all, "an-unreasonably"); + let name_end = line.find('…').expect("name must truncate with …"); + let actions_start = line.find('⧉').expect("actions must still render"); + assert!(name_end < actions_start, "name overlaps actions: {line:?}"); + assert!( + line.split_at(actions_start).0.ends_with(' '), + "no gap before the actions: {line:?}" + ); + } }