diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 4f606f3..13e3ca0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -2,8 +2,7 @@ //! //! 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. +//! adaptive detail, blocked-work, exited, and command fragments. use std::collections::HashSet; @@ -18,8 +17,8 @@ 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). +/// Time-in-state earns a duration fragment once the session has sat in its +/// current activity state this long (docs/SIDEBAR.md). const META_LINE_AFTER_SECS: i64 = 30; /// A payload predating #102 defaults `activity_since` to the Unix epoch. Per /// docs/SIDEBAR.md such a session contributes no duration — the dot still @@ -74,9 +73,8 @@ impl Sidebar { 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))); - } + let meta = self.meta_row(s, width); + lines.push(Line::from(pad_to_width(meta, width, base))); } f.render_widget(Paragraph::new(lines), inner); } @@ -123,8 +121,8 @@ impl Sidebar { spans } - /// The adaptive second line: ` [ | exited]`. - fn meta_row(&self, s: &Session, width: usize) -> Option>> { + /// The adaptive second line: ` [] [] [ | exited] `. + fn meta_row(&self, s: &Session, width: usize) -> Vec> { let now = Utc::now(); let held = held_for(s, now); let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0); @@ -132,14 +130,6 @@ impl Sidebar { // 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.is_none() || held_secs < META_LINE_AFTER_SECS) - && blocked.is_none() - && !dead - && !errored - { - return None; - } 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))); @@ -148,13 +138,10 @@ impl Sidebar { Span::styled(META_INDENT, base), Span::styled("●", dot_style), ]; - if let Some(d) = held { + if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) { spans.push(Span::styled(" ", base)); spans.push(Span::styled(format_state_duration(d), dot_style)); } - // The trailing fragments are measured up front so a long detail - // truncates ahead of them instead of pushing them off the line - // (docs/SIDEBAR.md). let mut tail: Vec> = Vec::new(); if let Some(b) = blocked { tail.push(Span::styled(format!(" ⚠ {b}"), muted)); @@ -162,9 +149,19 @@ impl Sidebar { if dead { tail.push(Span::styled(" exited", muted)); } + let command = Span::styled(format!(" {}", s.command), muted); + + // Reserve the trailing fragments so detail truncates before them; + // the command is itself the final truncation target on narrow cards. + let head_w: usize = spans.iter().map(|sp| sp.width()).sum(); + let tail_w: usize = tail.iter().map(|sp| sp.width()).sum(); + let command_w = command.width(); + let available = width.saturating_sub(head_w); + if tail_w + command_w > available { + spans.extend(fit_to_width(vec![command], available)); + return spans; + } if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) { - let head_w: usize = spans.iter().map(|sp| sp.width()).sum(); - let tail_w: usize = tail.iter().map(|sp| sp.width()).sum(); let budget = width.saturating_sub(head_w + tail_w); spans.extend(fit_to_width( vec![Span::styled(format!(" {detail}"), muted)], @@ -172,7 +169,8 @@ impl Sidebar { )); } spans.extend(tail); - Some(fit_to_width(spans, width)) + spans.push(command); + fit_to_width(spans, width) } } @@ -406,10 +404,10 @@ mod tests { } } - /// A freshly-active session stays one line — never an empty placeholder - /// row (docs/SIDEBAR.md). + /// A fresh session still gets its unconditional second line, but no + /// duration until it has been held in state for 30 seconds. #[test] - fn freshly_active_session_stays_one_line() { + fn fresh_session_renders_two_lines_without_duration() { let mut s = session_named(1, "claude"); s.activity_since = Utc::now() - Duration::seconds(10); let sb = Sidebar { @@ -421,8 +419,18 @@ mod tests { 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].contains('●'), + "line 2 must carry the dot: {:?}", + all[2] + ); + assert!( + all[2].contains("bash"), + "line 2 must carry the command: {:?}", + all[2] + ); + assert!( + !all[2].contains("10s"), + "fresh cards have no duration: {:?}", all[2] ); } @@ -553,11 +561,12 @@ mod tests { ); } - /// The sidebar is narrow: the meta line truncates with `…` and never - /// wraps onto a third row (docs/SIDEBAR.md). + /// The sidebar is narrow: the command truncates with `…` and never wraps + /// onto a third row (docs/SIDEBAR.md). #[test] - fn meta_line_truncates_instead_of_wrapping() { + fn meta_line_command_truncates_instead_of_wrapping() { let mut s = session_named(1, "claude"); + s.command = "a command that cannot fit".into(); s.activity_since = Utc::now() - Duration::seconds(5); s.blocked_work = Some(InTheWay { uncommitted_files: 120, @@ -573,7 +582,7 @@ mod tests { let meta = content(&all[idx + 1]); assert!( meta.trim_end().ends_with('…'), - "truncated meta line must end with …: {meta:?}" + "truncated command must end with …: {meta:?}" ); assert!( content(&all[idx + 2]).trim().is_empty(), @@ -688,10 +697,6 @@ mod tests { rendered.contains("exited"), "dead session must still show line 2:\n{rendered}" ); - assert!( - !rendered.contains('h') || !rendered.contains("● "), - "no duration may follow the dot for an epoch stamp:\n{rendered}" - ); for line in rendered.lines().filter(|l| l.contains('●')) { assert!( !line @@ -736,6 +741,48 @@ mod tests { ); } + #[test] + fn command_renders_on_line_two_even_when_name_is_metadata() { + let s = session_named(1, "friendly name"); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 40, 6)); + assert!( + all[1].contains("friendly name"), + "name must stay on line 1: {:?}", + all[1] + ); + assert!( + all[2].contains("bash"), + "command must be on line 2: {:?}", + all[2] + ); + } + + #[test] + fn narrow_meta_line_keeps_dot_and_truncates_command() { + let s = session_named(1, "name"); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 10, 6)); + assert!( + all[2].contains('●'), + "dot must survive narrow cards: {:?}", + all[2] + ); + assert!( + all[2].contains('…'), + "command should truncate: {:?}", + all[2] + ); + } + /// Reversed video makes a card unreadable against a background; cards /// use explicit backgrounds instead (docs/SIDEBAR.md, #108). #[test] @@ -775,7 +822,7 @@ mod tests { 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(); + let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap(); let p = palette::active(); assert_eq!(