zeph-tui 0.22.3

Ratatui-based TUI dashboard with real-time metrics for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Task registry panel widget.
//!
//! Renders a live view of all tasks tracked by [`TaskSupervisor`]. Each row
//! shows a spinner (for active tasks), the task name, current status, uptime
//! since the last (re)start, and the total restart count.
//!
//! # Uptime semantics
//!
//! The uptime column shows time elapsed since the task was **last started**
//! (or restarted). It resets on each restart. This is intentional: it lets
//! the operator see whether a task has been stable or has restarted recently.
//! Total lifetime cannot be derived exactly from the snapshot alone.
//!
//! [`TaskSupervisor`]: zeph_common::task_supervisor::TaskSupervisor

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 {
        // Pad to 3 spaces to match the 3-cell active frame, preventing column jitter.
        "   "
    };

    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)
}

/// Render the task registry panel into `area`.
///
/// Shows a spinner for running/restarting tasks, the task name, current
/// status (color-coded), uptime since last start, and restart count.
///
/// When `snapshots` is empty, displays a placeholder message instead.
///
/// # Arguments
///
/// * `snapshots` — point-in-time task list from `TaskSupervisor::snapshot()`.
/// * `tick` — current animation tick (wraps via `% SPINNER_FRAMES.len()`).
/// * `area` — terminal rect to render into.
/// * `frame` — ratatui frame for widget rendering.
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() {
        // Must not panic; displays placeholder text.
        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);
    }

    /// Fills the whole area with a sentinel glyph before calling `render`, in the same frame —
    /// mirroring the real bug shape (#6054): the task registry overlay shares its `Rect` with
    /// other sidebar widgets but, before the fix, never called `Clear`, so stale glyphs from
    /// whatever rendered underneath survived in every cell the panel's own content didn't touch.
    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"
            );
        }
    }
}