//! dashboard: live TUI over the whole fleet (issue #33) — five tabs //! (overview, activity, stats, sessions, projects), 1 s refresh, keyboard //! navigation, clean exit. When stdout is not a terminal (or --json), a //! plain snapshot is printed instead of entering the TUI. use super::*; use crate::dashboard::{collect, ActivityRow, DashData, OverviewRow, SessionRow, StatsRow}; use crate::projects::ProjectSummary; use crate::output::print_json; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; use ratatui::{DefaultTerminal, Frame}; use std::io::IsTerminal; use std::time::Duration; pub const TABS: [&str; 5] = ["1 overview", "2 activity", "3 stats", "4 sessions", "5 projects"]; struct Ui { tab: usize, scroll: [usize; 5], } pub fn run(app: &App) -> Result { if app.json() { print_json(&collect(app)); return Ok(0); } if !std::io::stdout().is_terminal() { print_snapshot(app)?; return Ok(0); } let mut terminal = ratatui::init(); let result = run_loop(app, &mut terminal); ratatui::restore(); // Leave the prompt on a fresh line when launched from the REPL. println!(); result } fn run_loop(app: &App, terminal: &mut DefaultTerminal) -> Result { let mut ui = Ui { tab: 0, scroll: [0; 5] }; let mut data = collect(app); loop { terminal.draw(|f| draw(f, &data, &ui))?; if !event::poll(Duration::from_secs(1))? { // 1 s refresh: repaint with fresh data. data = collect(app); continue; } match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(0), KeyCode::Char('h') | KeyCode::Left => ui.tab = ui.tab.saturating_sub(1), KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => { ui.tab = (ui.tab + 1) % TABS.len() } KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { ui.tab = (c as usize - '1' as usize).min(TABS.len() - 1); } KeyCode::Char('k') | KeyCode::Up => { ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(1) } KeyCode::Char('j') | KeyCode::Down => ui.scroll[ui.tab] += 1, KeyCode::PageUp => ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(10), KeyCode::PageDown => ui.scroll[ui.tab] += 10, KeyCode::Home => ui.scroll[ui.tab] = 0, KeyCode::Char('r') => data = collect(app), _ => {} }, Event::Resize(_, _) => data = collect(app), _ => {} } } } fn draw(f: &mut Frame, data: &DashData, ui: &Ui) { let [header, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(f.area()); let counts = &data.counts; let title = Line::from(vec![ Span::styled( " agent-manager ", Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ), Span::raw(format!( " {} installed · {} running · {} sessions{}", counts.installed, counts.running, counts.sessions_count, counts .last_used .as_deref() .map(|t| format!(" · last used {t}")) .unwrap_or_default(), )), ]); let tabs = Tabs::new(TABS) .block( Block::default() .borders(Borders::ALL) .title(title) .title_alignment(Alignment::Left), ) .select(ui.tab) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); f.render_widget(tabs, header); match ui.tab { 0 => draw_overview(f, data, ui, body), 1 => draw_activity(f, data, ui, body), 2 => draw_stats(f, data, ui, body), 3 => draw_sessions(f, data, ui, body), _ => draw_projects(f, data, ui, body), } } fn status_style(status: &str) -> Style { match status { "running" => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), "installed" | "external" => Style::default().fg(Color::Cyan), "failed" | "interrupted" => Style::default().fg(Color::Red), "not-installed" | "not-installable" => Style::default().fg(Color::DarkGray), _ => Style::default().fg(Color::Yellow), } } fn block<'a>(title: &str, hint: &str, scroll: usize) -> Block<'a> { Block::default() .borders(Borders::ALL) .title(format!(" {title} ")) .title_bottom(format!(" {hint} · scroll {scroll} ")) .title_alignment(Alignment::Left) } fn fmt_duration(secs: u64) -> String { if secs < 60 { format!("{secs}s") } else if secs < 3600 { format!("{}m{:02}s", secs / 60, secs % 60) } else if secs < 86400 { format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}d{:02}h", secs / 86400, (secs % 86400) / 3600) } } fn overview_line(r: &OverviewRow) -> Line<'static> { let star = if r.favorite { "★ " } else { " " }; let status = r.status.as_str(); let version = r.version.as_deref().unwrap_or("—"); let uptime = r .uptime_s .map(|s| format!("up {}", fmt_duration(s))) .unwrap_or_default(); let pid = r .pid .map(|p| format!("pid {p}")) .unwrap_or_default(); Line::from(vec![ Span::styled(format!("{star}{:<22}", r.name), Style::default().add_modifier(Modifier::BOLD)), Span::styled(format!("{:<16}", status), status_style(status)), Span::raw(format!("{:<12} ", version)), Span::raw(format!("{pid:<10} {uptime}")), ]) } fn draw_overview(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) { let mut lines: Vec = data .overview .iter() .map(overview_line) .collect(); if lines.is_empty() { lines.push(Line::from("no agents in the catalog")); } let p = Paragraph::new(lines) .block(block("overview", "j/k scroll · q quit", ui.scroll[0])) .scroll((ui.scroll[0] as u16, 0)); f.render_widget(p, area); } fn activity_line(r: &ActivityRow) -> Line<'static> { Line::from(vec![ Span::styled(format!("{:<21} ", r.ts), Style::default().fg(Color::DarkGray)), Span::styled( format!("{:<10} ", r.kind), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), ), Span::styled(format!("{:<22} ", r.agent.as_deref().unwrap_or("-")), Style::default().fg(Color::Yellow)), Span::raw(r.detail.clone()), ]) } fn draw_activity(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) { let lines: Vec = data.activity.iter().map(activity_line).collect(); let p = Paragraph::new(if lines.is_empty() { vec![Line::from("the event journal is empty — every am action will appear here")] } else { lines }) .block(block("activity — event journal", "j/k scroll · q quit", ui.scroll[1])) .scroll((ui.scroll[1] as u16, 0)); f.render_widget(p, area); } fn stats_line(r: &StatsRow) -> Line<'static> { let rate = if r.launches > 0 { format!("{:>6.0}% ok", 100.0 * (r.launches.saturating_sub(r.failures)) as f64 / r.launches as f64) } else { " —".to_string() }; Line::from(vec![ Span::styled(format!("{:<22}", r.agent), Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!("{:>8} launches ", r.launches)), Span::raw(format!("{:>8} failures ", r.failures)), Span::raw(format!("{:>10} ", fmt_duration(r.total_duration_s))), Span::styled(rate, status_style(if r.failures > 0 { "failed" } else { "running" })), ]) } fn draw_stats(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) { let lines: Vec = data.stats.iter().map(stats_line).collect(); let p = Paragraph::new(if lines.is_empty() { vec![Line::from("no usage data yet — start an agent to fill the journal")] } else { lines }) .block(block("stats — per agent", "j/k scroll · q quit", ui.scroll[2])) .scroll((ui.scroll[2] as u16, 0)); f.render_widget(p, area); } fn session_line(r: &SessionRow) -> Line<'static> { let dur = r .duration_s .map(fmt_duration) .unwrap_or_else(|| "running…".to_string()); Line::from(vec![ Span::styled(format!("{:<18} ", r.id), Style::default().fg(Color::Yellow)), Span::styled(format!("{:<11} ", r.status), status_style(&r.status)), Span::styled( format!("{:<22} ", r.agent.as_deref().unwrap_or("repl")), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!( "{:<26} ", r.project.as_deref().unwrap_or("-") )), Span::styled(format!("{:<10} ", r.started_at), Style::default().fg(Color::DarkGray)), Span::raw(dur), ]) } fn draw_sessions(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) { let lines: Vec = data.sessions.iter().map(session_line).collect(); let p = Paragraph::new(if lines.is_empty() { vec![Line::from("no sessions recorded yet")] } else { lines }) .block(block("sessions", "j/k scroll · q quit", ui.scroll[3])) .scroll((ui.scroll[3] as u16, 0)); f.render_widget(p, area); } fn project_line(r: &ProjectSummary) -> Line<'static> { let mut spans: Vec> = Vec::new(); spans.push(Span::styled( format!("{:<28} ", r.root), Style::default().add_modifier(Modifier::BOLD), )); spans.push(Span::styled( format!("{:<22} ", r.dominant.as_deref().unwrap_or("-")), Style::default().fg(Color::Cyan), )); spans.push(Span::raw(format!( "{:>4} sessions {:>5} launches {:>10} ", r.sessions, r.launches, fmt_duration(r.total_duration_s) ))); spans.push(Span::styled( r.last_activity.as_deref().unwrap_or("-").to_string(), Style::default().fg(Color::DarkGray), )); Line::from(spans) } fn draw_projects(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) { let lines: Vec = data.projects.iter().map(project_line).collect(); let p = Paragraph::new(if lines.is_empty() { vec![Line::from("no project activity recorded yet")] } else { lines }) .block(block("projects", "j/k scroll · q quit", ui.scroll[4])) .scroll((ui.scroll[4] as u16, 0)); f.render_widget(p, area); } /// Plain-text snapshot (piped stdout): same data, no TUI. fn print_snapshot(app: &App) -> Result { let data = collect(app); let c = &data.counts; println!( "agent-manager dashboard — {} installed · {} running · {} sessions", c.installed, c.running, c.sessions_count ); println!(); println!("overview"); for r in &data.overview { let star = if r.favorite { "★ " } else { " " }; let uptime = r .uptime_s .map(|s| format!(" (up {})", fmt_duration(s))) .unwrap_or_default(); println!( " {star}{:<22} {:<16} {:<12} {}{}", r.name, r.status, r.version.as_deref().unwrap_or("—"), r.pid.map(|p| format!("pid {p}")).unwrap_or_default(), uptime, ); } println!(); println!("activity (last {})", data.activity.len()); for r in data.activity.iter().take(20) { println!( " {:<21} {:<10} {:<22} {}", r.ts, r.kind, r.agent.as_deref().unwrap_or("-"), r.detail ); } println!(); println!("stats"); for r in &data.stats { println!( " {:<22} {:>8} launches {:>8} failures {:>10}", r.agent, r.launches, r.failures, fmt_duration(r.total_duration_s), ); } println!(); println!("sessions"); for r in data.sessions.iter().take(20) { println!( " {:<18} {:<11} {:<22} {}", r.id, r.status, r.agent.as_deref().unwrap_or("repl"), r.duration_s.map(fmt_duration).unwrap_or_else(|| "running…".to_string()), ); } println!(); println!("projects"); for r in &data.projects { println!( " {:<28} {:<22} {} sessions {} launches", r.root, r.dominant.as_deref().unwrap_or("-"), r.sessions, r.launches, ); } Ok(0) }