From c7c5b2e0ec25ae137b978da8f957f7b28867bcb3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:53:27 +0100 Subject: [PATCH] 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:?}" + ); + } }