From a1816a0838493e15985ae91d477f1309742d4afd Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 13:18:24 +0100 Subject: [PATCH] fix(maestro): preserve fixed card trailing fragments --- crates/maestro-tui/src/sidebar.rs | 65 +++++++++++++++++++++++++------ web/src/styles.css | 13 ++++++- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 13e3ca0..960de55 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -151,26 +151,27 @@ impl Sidebar { } let command = Span::styled(format!(" {}", s.command), muted); - // Reserve the trailing fragments so detail truncates before them; - // the command is itself the final truncation target on narrow cards. + // Reserve the triggered status fragments before detail and command. + // If the row is exceptionally narrow, keep one column for the + // unconditional command and truncate the status tail first. Detail + // then receives only space left after the full command reservation; + // when the full command cannot fit, it is the final truncation target. let head_w: usize = spans.iter().map(|sp| sp.width()).sum(); - let tail_w: usize = tail.iter().map(|sp| sp.width()).sum(); - let command_w = command.width(); let available = width.saturating_sub(head_w); - if tail_w + command_w > available { - spans.extend(fit_to_width(vec![command], available)); - return spans; - } + let command_floor = usize::from(available > 0); + let tail = fit_to_width(tail, available.saturating_sub(command_floor)); + let tail_w: usize = tail.iter().map(|sp| sp.width()).sum(); + let command_w = command.width().min(available.saturating_sub(tail_w)); if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) { - let budget = width.saturating_sub(head_w + tail_w); + let budget = available.saturating_sub(tail_w + command_w); spans.extend(fit_to_width( vec![Span::styled(format!(" {detail}"), muted)], budget, )); } spans.extend(tail); - spans.push(command); - fit_to_width(spans, width) + spans.extend(fit_to_width(vec![command], command_w)); + spans } } @@ -783,6 +784,48 @@ mod tests { ); } + #[test] + fn blocked_warning_survives_before_a_truncated_command() { + let mut s = session_named(1, "name"); + s.command = "a command that cannot fit".into(); + s.blocked_work = Some(InTheWay { + uncommitted_files: 1, + ..Default::default() + }); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 29, 6)); + assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]); + let meta = content(&all[2]); + assert!(meta.ends_with('…'), "command must truncate: {meta:?}"); + } + + #[test] + fn long_detail_yields_to_status_and_command() { + let mut s = session_named(1, "name"); + s.activity_detail = Some("a very long agent-reported detail".into()); + s.blocked_work = Some(InTheWay { + uncommitted_files: 1, + ..Default::default() + }); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 40, 6)); + assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]); + assert!( + all[2].contains("bash"), + "command must survive: {:?}", + all[2] + ); + assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]); + } + /// Reversed video makes a card unreadable against a background; cards /// use explicit backgrounds instead (docs/SIDEBAR.md, #108). #[test] diff --git a/web/src/styles.css b/web/src/styles.css index f7e99f9..55b0124 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -791,7 +791,8 @@ textarea { flex-direction: column; align-items: stretch; gap: 1px; - height: 40px; + /* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */ + height: 49px; } .card-line1 { @@ -854,7 +855,8 @@ textarea { ellipsis so it never pushes the trailing fragments off the line. */ .activity-detail { color: var(--faint); - flex: 0 1 auto; + /* Consume only the space left after the trailing status + command. */ + flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -865,12 +867,19 @@ textarea { signals severity and "exited" dims with the row. */ .card-flag { color: var(--faint); + flex: 0 0 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Status fragments stay intact. The unconditional command is the final + truncation target when even the complete trailing set cannot fit. */ +.leaf-cmd { + flex: 0 1 auto; +} + /* Actions live in line 1's flow at its right edge — never overlaid on the name (#107). They reserve their width even while hover-faded. */ .leaf-actions {