use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Widget,
};
pub const MAX_VISIBLE: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivityRow {
pub icon: &'static str,
pub label: String,
pub age: String,
pub activity: Option<String>,
pub status: ActivityStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityStatus {
Pending,
Running,
Cancelling,
}
pub struct ChildActivityOverlay<'a> {
rows: &'a [ActivityRow],
total: usize,
}
impl<'a> ChildActivityOverlay<'a> {
pub fn new(rows: &'a [ActivityRow], total: usize) -> Self {
Self { rows, total }
}
pub fn height_for(total: usize) -> u16 {
if total == 0 {
return 0;
}
let body = total.min(MAX_VISIBLE) as u16;
let hint = 1u16; body + hint
}
}
impl Widget for ChildActivityOverlay<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let prefix_w = 29usize;
let max_activity_w = (area.width as usize).saturating_sub(prefix_w);
for (row_idx, row) in self.rows.iter().enumerate() {
let y = area.y + row_idx as u16;
if y >= area.y + area.height {
break;
}
let icon_color = match row.status {
ActivityStatus::Pending => Color::DarkGray,
ActivityStatus::Running => Color::Yellow,
ActivityStatus::Cancelling => Color::Red,
};
let mut spans: Vec<Span<'static>> = vec![
Span::raw(" "),
Span::styled(format!("{} ", row.icon), Style::default().fg(icon_color)),
Span::styled(
format!("{:<10}", truncate_label(&row.label, 10)),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({:>6}) ", row.age),
Style::default().fg(Color::DarkGray),
),
];
if let Some(activity) = &row.activity {
let preview = truncate_with_ellipsis(activity, max_activity_w);
spans.push(Span::styled(
"ยท ".to_string(),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled(preview, Style::default().fg(Color::Gray)));
}
Line::from(spans).render(Rect::new(area.x, y, area.width, 1), buf);
}
let hint_y = area.y + self.rows.len() as u16;
if hint_y >= area.y + area.height {
return;
}
let overflow = self.total.saturating_sub(self.rows.len());
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
if overflow > 0 {
spans.push(Span::styled(
format!("+ {overflow} more"),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled(
" \u{b7} ",
Style::default().fg(Color::Rgb(80, 80, 80)),
));
}
spans.push(Span::styled(
"Esc cancel all",
Style::default().fg(Color::Rgb(80, 80, 80)),
));
Line::from(spans).render(Rect::new(area.x, hint_y, area.width, 1), buf);
}
}
fn truncate_label(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
s.to_string()
} else {
s.chars().take(max).collect()
}
}
fn truncate_with_ellipsis(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let flat = s.replace('\n', " ");
let count = flat.chars().count();
if count <= max {
flat
} else {
let mut out: String = flat.chars().take(max.saturating_sub(1)).collect();
out.push('\u{2026}');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(rows: &[ActivityRow], total: usize, width: u16) -> Vec<String> {
let height = ChildActivityOverlay::height_for(total).max(1);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
ChildActivityOverlay::new(rows, total).render(area, &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn agent_row(label: &str, age: &str, activity: Option<&str>) -> ActivityRow {
ActivityRow {
icon: "\u{1f916}",
label: label.into(),
age: age.into(),
activity: activity.map(str::to_string),
status: ActivityStatus::Running,
}
}
#[test]
fn height_zero_when_no_work() {
assert_eq!(ChildActivityOverlay::height_for(0), 0);
}
#[test]
fn height_one_row_includes_hint() {
assert_eq!(ChildActivityOverlay::height_for(1), 2);
}
#[test]
fn height_capped_at_max_visible_plus_hint() {
assert_eq!(
ChildActivityOverlay::height_for(MAX_VISIBLE + 10),
(MAX_VISIBLE + 1) as u16
);
}
#[test]
fn single_running_row_renders_label_and_activity() {
let rows = vec![agent_row("explore", "12s", Some("Read src/auth.rs"))];
let lines = render(&rows, 1, 80);
assert!(
lines[0].contains("explore"),
"row missing label: {:?}",
lines[0]
);
assert!(
lines[0].contains("Read src/auth.rs"),
"row missing activity: {:?}",
lines[0]
);
assert!(lines[0].contains("12s"), "row missing age: {:?}", lines[0]);
}
#[test]
fn row_without_activity_omits_separator() {
let rows = vec![agent_row("explore", " 1s", None)];
let lines = render(&rows, 1, 80);
assert!(
!lines[0].contains("\u{b7}"),
"body row should not show activity separator when activity=None: {:?}",
lines[0]
);
}
#[test]
fn overflow_hint_shows_count_when_more_than_visible() {
let rows: Vec<_> = (0..MAX_VISIBLE)
.map(|i| agent_row(&format!("ag{i}"), " 1s", Some("Read x.rs")))
.collect();
let lines = render(&rows, MAX_VISIBLE + 3, 80);
let hint = lines.last().unwrap();
assert!(hint.contains("+ 3 more"), "overflow hint: {hint:?}");
}
#[test]
fn hint_row_always_present_even_without_overflow() {
let rows = vec![agent_row("explore", " 1s", Some("x"))];
let lines = render(&rows, 1, 80);
let hint = lines.last().unwrap();
assert!(
hint.contains("Esc cancel all"),
"hint row should always show Esc keybinding: {hint:?}"
);
}
#[test]
fn long_activity_truncated_with_ellipsis() {
let long = "x".repeat(500);
let rows = vec![agent_row("a", " 1s", Some(&long))];
let lines = render(&rows, 1, 50);
assert!(
lines[0].contains('\u{2026}'),
"long activity should ellipsize: {:?}",
lines[0]
);
}
#[test]
fn newlines_in_activity_flattened_to_spaces() {
let rows = vec![agent_row("a", " 1s", Some("line1\nline2"))];
let lines = render(&rows, 1, 80);
assert!(
!lines[0].contains('\n'),
"newline should not survive into rendered cell"
);
}
#[test]
fn process_row_uses_distinct_icon() {
let rows = vec![ActivityRow {
icon: "\u{1f41a}", label: "process:42".into(),
age: " 8s".into(),
activity: Some("cargo build".into()),
status: ActivityStatus::Running,
}];
let lines = render(&rows, 1, 80);
assert!(
lines[0].contains("process:42"),
"process label missing: {:?}",
lines[0]
);
assert!(
lines[0].contains("cargo build"),
"process activity missing: {:?}",
lines[0]
);
}
#[test]
fn truncate_label_handles_unicode() {
let s = "๐ค๐ค๐ค๐ค๐ค";
let out = truncate_label(s, 3);
assert_eq!(out.chars().count(), 3);
}
#[test]
fn truncate_with_ellipsis_zero_max_returns_empty() {
assert_eq!(truncate_with_ellipsis("anything", 0), "");
}
#[test]
fn empty_rows_with_zero_total_renders_nothing_visible() {
let lines = render(&[], 0, 80);
assert!(lines[0].contains("Esc cancel all") || lines[0].is_empty());
}
}