merge: sidebar-cards/108-tui-cards

This commit is contained in:
Miguel Palhas
2026-08-24 19:56:51 +01:00
+501 -69
View File
@@ -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<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),
]));
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<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 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<Span<'static>> = 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: `<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 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<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
}
/// 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<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
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<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 +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:?}"
);
}
}