diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index cd17e98..a0fab9e 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,12 @@ 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 = " "; + /// Renderable sidebar state. #[derive(Debug, Clone)] pub struct Sidebar { @@ -41,36 +51,119 @@ 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), - ])); + lines.push(Line::from(self.name_row(s, width))); + if let Some(meta) = self.meta_row(s, width) { + lines.push(Line::from(meta)); + } } 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 style = if highlighted { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let row = vec![ + Span::raw(INDENT), + Span::raw(format!("{glyph} ")), + Span::raw(name_for(s)), + ]; + fit_to_width(row, width) + .into_iter() + .map(|sp| Span::styled(sp.content, style.patch(sp.style))) + .collect() + } + + /// 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 dot_style = Style::default().fg(activity_color(s.activity)); + let muted = Style::default(); + let duration = format_state_duration(now - s.activity_since); + let mut spans = vec![ + Span::raw(META_INDENT), + Span::styled("●", dot_style), + Span::raw(" "), + 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 } /// `metadata.name` if present, else the command line. @@ -85,8 +178,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 +199,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 +245,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 +264,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 +282,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 +306,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 +471,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,7 +481,7 @@ 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}"