From bb28fe1d6e193931c080fe72f3f26cd9a392c70b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:48:23 +0100 Subject: [PATCH 1/3] 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. --- crates/maestro-tui/src/sidebar.rs | 380 ++++++++++++++++++++++++------ 1 file changed, 311 insertions(+), 69 deletions(-) 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}" From 323ead5a83afaae62844f29184dfc7da215bdf38 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:50:37 +0100 Subject: [PATCH 2/3] feat(maestro): give TUI cards explicit card and focus backgrounds Reversed video made a highlighted card unreadable. Cards now sit on a black card background painted edge to edge; the highlighted card swaps it for black-on-cyan, matching the status bar's focus treatment. --- crates/maestro-tui/src/sidebar.rs | 133 +++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 12 deletions(-) diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index a0fab9e..fd0cd5e 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -22,6 +22,18 @@ const META_LINE_AFTER_SECS: i64 = 30; const INDENT: &str = " "; const META_INDENT: &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 { @@ -57,9 +69,15 @@ impl Sidebar { Style::default().add_modifier(Modifier::BOLD), ))]; for s in &self.sessions { - lines.push(Line::from(self.name_row(s, width))); + let highlighted = self.highlight == Some(s.id); + let base = card_base(highlighted); + lines.push(Line::from(pad_to_width( + self.name_row(s, width), + width, + base, + ))); if let Some(meta) = self.meta_row(s, width) { - lines.push(Line::from(meta)); + lines.push(Line::from(pad_to_width(meta, width, base))); } } f.render_widget(Paragraph::new(lines), inner); @@ -72,11 +90,7 @@ impl Sidebar { (SessionStatus::Running, false) => "○", }; let highlighted = self.highlight == Some(s.id); - let style = if highlighted { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; + let base = card_base(highlighted); let row = vec![ Span::raw(INDENT), Span::raw(format!("{glyph} ")), @@ -84,7 +98,7 @@ impl Sidebar { ]; fit_to_width(row, width) .into_iter() - .map(|sp| Span::styled(sp.content, style.patch(sp.style))) + .map(|sp| Span::styled(sp.content, base.patch(sp.style))) .collect() } @@ -100,13 +114,14 @@ impl Sidebar { 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 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::raw(META_INDENT), + Span::styled(META_INDENT, base), Span::styled("●", dot_style), - Span::raw(" "), + Span::styled(" ", base), Span::styled(duration, dot_style), ]; if let Some(b) = blocked { @@ -166,6 +181,17 @@ fn fit_to_width(spans: Vec>, max: usize) -> Vec> { 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>, width: usize, base: Style) -> Vec> { + 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. fn name_for(s: &Session) -> String { if let Some(serde_json::Value::String(name)) = s.metadata.get("name") { @@ -487,4 +513,87 @@ mod tests { "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}" + ); + } + } } From c7c5b2e0ec25ae137b978da8f957f7b28867bcb3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:53:27 +0100 Subject: [PATCH 3/3] feat(maestro): show spawn-similar and kill affordances on the highlighted card Line-1 actions anchor to the card's top-right corner on the highlighted row only, reserving their width so the name truncates with an ellipsis before it can ever reach them. --- crates/maestro-tui/src/sidebar.rs | 107 ++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index fd0cd5e..9e2bda0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -21,6 +21,8 @@ use uuid::Uuid; 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 @@ -71,11 +73,7 @@ impl Sidebar { for s in &self.sessions { let highlighted = self.highlight == Some(s.id); let base = card_base(highlighted); - lines.push(Line::from(pad_to_width( - self.name_row(s, width), - width, - base, - ))); + 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))); } @@ -91,15 +89,41 @@ impl Sidebar { }; let highlighted = self.highlight == Some(s.id); let base = card_base(highlighted); - let row = vec![ - Span::raw(INDENT), - Span::raw(format!("{glyph} ")), - Span::raw(name_for(s)), + // 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> = vec![ + Span::styled(INDENT, base), + Span::styled(format!("{glyph} "), base), ]; - fit_to_width(row, width) - .into_iter() - .map(|sp| Span::styled(sp.content, base.patch(sp.style))) - .collect() + 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: ` [ | exited]`. @@ -596,4 +620,61 @@ mod tests { ); } } + + /// 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:?}" + ); + } }