feat(maestro): render an adaptive meta line under TUI session cards
Per docs/SIDEBAR.md: line 2 carries the activity dot + floored duration in the state's colour, plus the blocked-work and exited fragments; it appears only when time-in-state exceeds 30s, work is blocked, the session is dead, or an Error state is in force. Blocked work moves off line 1. Card lines truncate with an ellipsis instead of wrapping.
This commit is contained in:
@@ -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<Line> = 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<Span<'static>> {
|
||||
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: `<dot> <duration> [<blocked work> | exited]`.
|
||||
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
|
||||
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<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
||||
let mut out: Vec<Span<'static>> = 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<String> {
|
||||
(0..buf.area.height)
|
||||
.map(|y| {
|
||||
(0..buf.area.width)
|
||||
.map(|x| buf[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.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}"
|
||||
|
||||
Reference in New Issue
Block a user