use std::time::Instant;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, Paragraph};
use zeph_common::task_supervisor::{TaskSnapshot, TaskStatus};
use crate::theme::Theme;
use crate::widgets::spinner::breeze_frame;
fn status_color(status: &TaskStatus) -> Color {
match status {
TaskStatus::Running => Color::Yellow,
TaskStatus::Restarting { .. } => Color::Cyan,
TaskStatus::Completed => Color::Green,
TaskStatus::Failed { .. } => Color::Red,
TaskStatus::Aborted => Color::DarkGray,
_ => Color::White,
}
}
fn format_status(status: &TaskStatus) -> String {
match status {
TaskStatus::Running => "Running".to_owned(),
TaskStatus::Restarting { attempt, max } => format!("Restart {attempt}/{max}"),
TaskStatus::Completed => "Completed".to_owned(),
TaskStatus::Failed { .. } => "Failed".to_owned(),
TaskStatus::Aborted => "Aborted".to_owned(),
_ => "Unknown".to_owned(),
}
}
fn format_uptime(started_at: Instant) -> String {
let secs = started_at.elapsed().as_secs();
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}")
}
fn build_list_item(snapshot: &TaskSnapshot, tick: u8, ascii: bool) -> ListItem<'static> {
let color = status_color(&snapshot.status);
let is_active = matches!(
snapshot.status,
TaskStatus::Running | TaskStatus::Restarting { .. }
);
let spinner = if is_active {
breeze_frame(u64::from(tick), ascii)
} else {
" "
};
let uptime = format_uptime(snapshot.started_at);
let status_str = format_status(&snapshot.status);
let restarts = snapshot.restart_count;
let line = Line::from(vec![
Span::styled(format!(" {spinner} "), Style::default().fg(color)),
Span::styled(
format!("{:<20}", snapshot.name),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{status_str:<14}"), Style::default().fg(color)),
Span::styled(format!("{uptime} "), Style::default()),
Span::styled(format!("↺{restarts}"), Style::default().fg(Color::DarkGray)),
]);
ListItem::new(line)
}
pub fn render(
snapshots: &[TaskSnapshot],
tick: u8,
area: Rect,
frame: &mut Frame<'_>,
theme: &Theme,
ascii: bool,
) {
frame.render_widget(Clear, area);
let header_text = format!("tasks · {}", snapshots.len());
let header = Line::from(Span::styled(
header_text,
theme.system_message.add_modifier(Modifier::BOLD),
));
let splits = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
frame.render_widget(Paragraph::new(header), splits[0]);
if snapshots.is_empty() {
let paragraph =
Paragraph::new("No supervised tasks registered yet.").style(theme.system_message);
frame.render_widget(paragraph, splits[1]);
return;
}
let items: Vec<ListItem<'_>> = snapshots
.iter()
.map(|s| build_list_item(s, tick, ascii))
.collect();
let list = List::new(items);
frame.render_widget(list, splits[1]);
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use zeph_common::task_supervisor::{TaskSnapshot, TaskStatus};
use crate::test_utils::render_to_string;
fn running_snapshot(name: &'static str) -> TaskSnapshot {
TaskSnapshot {
name: std::sync::Arc::from(name),
status: TaskStatus::Running,
started_at: Instant::now(),
restart_count: 0,
}
}
fn completed_snapshot(name: &'static str) -> TaskSnapshot {
TaskSnapshot {
name: std::sync::Arc::from(name),
status: TaskStatus::Completed,
started_at: Instant::now(),
restart_count: 1,
}
}
fn failed_snapshot(name: &'static str) -> TaskSnapshot {
TaskSnapshot {
name: std::sync::Arc::from(name),
status: TaskStatus::Failed {
reason: "oops".into(),
},
started_at: Instant::now(),
restart_count: 3,
}
}
#[test]
fn render_empty_does_not_panic() {
let output = render_to_string(50, 6, |frame, area| {
let theme = crate::theme::Theme::default();
super::render(&[], 0, area, frame, &theme, false);
});
assert!(
output.contains("No supervised tasks"),
"expected placeholder: {output}"
);
}
#[test]
fn render_running_task_shows_name_and_status() {
let snapshots = [running_snapshot("config-watcher")];
let output = render_to_string(60, 5, |frame, area| {
let theme = crate::theme::Theme::default();
super::render(&snapshots, 0, area, frame, &theme, false);
});
assert!(output.contains("config-watcher"), "name missing: {output}");
assert!(output.contains("Running"), "status missing: {output}");
}
#[test]
fn render_completed_task_shows_status() {
let snapshots = [completed_snapshot("memory-loop")];
let output = render_to_string(60, 5, |frame, area| {
let theme = crate::theme::Theme::default();
super::render(&snapshots, 0, area, frame, &theme, false);
});
assert!(output.contains("memory-loop"), "name missing: {output}");
assert!(output.contains("Completed"), "status missing: {output}");
}
#[test]
fn render_failed_task_shows_status() {
let snapshots = [failed_snapshot("scheduler")];
let output = render_to_string(60, 5, |frame, area| {
let theme = crate::theme::Theme::default();
super::render(&snapshots, 0, area, frame, &theme, false);
});
assert!(output.contains("scheduler"), "name missing: {output}");
assert!(output.contains("Failed"), "status missing: {output}");
}
#[test]
fn render_multiple_tasks_snapshot() {
let snapshots = [
running_snapshot("config-watcher"),
completed_snapshot("memory-loop"),
failed_snapshot("scheduler"),
];
let output = render_to_string(70, 8, |frame, area| {
let theme = crate::theme::Theme::default();
super::render(&snapshots, 2, area, frame, &theme, false);
});
assert_snapshot!(output);
}
fn render_over_sentinel(snapshots: &[TaskSnapshot]) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
frame.buffer_mut()[(x, y)].set_symbol("#");
}
}
let theme = crate::theme::Theme::default();
super::render(snapshots, 0, area, frame, &theme, false);
})
.unwrap();
terminal.backend().buffer().clone()
}
#[test]
fn render_clears_stale_glyphs_before_drawing_empty_state() {
let buf = render_over_sentinel(&[]);
for cell in &buf.content {
assert_ne!(
cell.symbol(),
"#",
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
);
}
}
#[test]
fn render_clears_stale_glyphs_before_drawing_task_list() {
let snapshots = [running_snapshot("config-watcher")];
let buf = render_over_sentinel(&snapshots);
for cell in &buf.content {
assert_ne!(
cell.symbol(),
"#",
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
);
}
}
}