From 43946c3c46145bbf2b0232e60292716a074ee48a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:22:06 +0100 Subject: [PATCH] feat(maestro): render agent-reported detail on TUI session cards --- crates/maestro-tui/src/sidebar.rs | 105 +++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 2737d12..0fe2dc3 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -160,12 +160,26 @@ impl Sidebar { 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 { - spans.push(Span::styled(format!(" ⚠ {b}"), muted)); + tail.push(Span::styled(format!(" ⚠ {b}"), muted)); } if dead { - spans.push(Span::styled(" exited", muted)); + tail.push(Span::styled(" exited", muted)); } + 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)], + budget, + )); + } + spans.extend(tail); Some(fit_to_width(spans, width)) } } @@ -531,6 +545,93 @@ mod tests { ); } + /// #112: the agent-reported detail sits right after the duration on the + /// meta line, in the muted style (docs/SIDEBAR.md). + #[test] + fn errored_session_shows_its_reported_detail_on_the_meta_line() { + let mut s = session_named(1, "claude"); + s.activity = ActivityState::Error; + s.activity_since = Utc::now() - Duration::seconds(90); + s.activity_detail = Some("cargo build failed".into()); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 40, 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"), + "duration precedes the detail: {meta:?}" + ); + assert!( + meta.contains("cargo build failed"), + "reported detail must render: {meta:?}" + ); + } + + /// A fresh Error report earns line 2 on its own (contract rule 4), with + /// the detail even though no duration has accrued. + #[test] + fn fresh_errored_session_earns_a_meta_line_for_its_detail() { + let mut s = session_named(1, "claude"); + s.activity = ActivityState::Error; + s.activity_since = Utc::now() - Duration::seconds(5); + s.activity_detail = Some("waiting for approval".into()); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 40, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + assert!( + all[idx + 1].contains("waiting for approval"), + "detail must render on a fresh report's own meta line: {:?}", + all[idx + 1] + ); + } + + /// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the + /// trailing fragments — a long detail never pushes blocked work or + /// `exited` off the line, and never wraps. + #[test] + fn long_detail_yields_to_blocked_work_and_exited() { + let mut s = session_named(1, "claude"); + s.status = SessionStatus::Dead; + s.activity_since = Utc::now() - Duration::seconds(5); + s.activity_detail = + Some("an absurdly long error message that cannot possibly fit ".repeat(3)); + s.blocked_work = Some(InTheWay { + unpushed_commits: 2, + ..InTheWay::default() + }); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 40, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + let meta = content(&all[idx + 1]); + assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}"); + assert!( + meta.contains("2 unpushed commits"), + "blocked work stays on the line: {meta:?}" + ); + assert!( + meta.contains("exited"), + "exited fragment stays on the line: {meta:?}" + ); + assert!( + content(&all[idx + 2]).trim().is_empty(), + "meta line must not wrap: {:?}", + all[idx + 2] + ); + } + /// docs/SIDEBAR.md: a payload predating #102 carries an epoch /// `activity_since` and so contributes no duration — a card must not claim /// the session has been idle since 1970. The dot still renders when some