Skip to main content

git_paw/
dashboard.rs

1//! Ratatui TUI status table for pane 0.
2//!
3//! Reads from [`BrokerState`] on a 1-second tick
4//! and renders a read-only agent status table. The v0.3.0 dashboard is
5//! display-only — the only interaction is quitting with `q`.
6
7use std::io::{self, Stdout};
8use std::sync::Arc;
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crossterm::event::{self, Event, KeyCode, KeyEventKind};
13use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
14use ratatui::Frame;
15use ratatui::Terminal;
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Alignment, Constraint, Layout};
18use ratatui::style::{Modifier, Style};
19use ratatui::widgets::{Paragraph, Row, Table};
20
21use crate::broker::delivery;
22use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
23use crate::error::PawError;
24
25/// Tick interval for the dashboard draw loop.
26const TICK_INTERVAL: Duration = Duration::from_secs(1);
27
28/// A formatted row for display in the agent status table.
29#[derive(Debug, Clone)]
30pub struct AgentRow {
31    /// The agent identifier (slugified branch name).
32    pub agent_id: String,
33    /// The CLI name (e.g. `"claude"`).
34    pub cli: String,
35    /// Status symbol and label (e.g. `"🔵 working"`).
36    pub status: String,
37    /// Relative time since last message (e.g. `"3m ago"`).
38    pub age: String,
39    /// One-line summary from the last message.
40    pub summary: String,
41}
42
43/// Maps an agent status label to a Unicode symbol.
44///
45/// | Input | Output |
46/// |---|---|
47/// | `"working"` | `"🔵"` |
48/// | `"done"` | `"🟢"` |
49/// | `"verified"` | `"🟢"` |
50/// | `"blocked"` | `"🟡"` |
51/// | anything else | `"⚪"` |
52pub fn status_symbol(status: &str) -> &'static str {
53    match status {
54        "working" => "🔵",
55        "done" | "verified" => "🟢",
56        "blocked" => "🟡",
57        _ => "⚪",
58    }
59}
60
61/// Formats an elapsed duration as a human-readable relative time string.
62///
63/// - Less than 60 seconds: `"Xs ago"` (e.g. `"30s ago"`)
64/// - 1 to 59 minutes: `"Xm ago"` (e.g. `"3m ago"`)
65/// - 60 minutes or more: `"Xh Ym ago"` (e.g. `"1h 15m ago"`)
66pub fn format_age(elapsed: Duration) -> String {
67    let secs = elapsed.as_secs();
68    if secs < 60 {
69        format!("{secs}s ago")
70    } else if secs < 3600 {
71        let mins = secs / 60;
72        format!("{mins}m ago")
73    } else {
74        let hours = secs / 3600;
75        let mins = (secs % 3600) / 60;
76        format!("{hours}h {mins}m ago")
77    }
78}
79
80/// Converts raw agent status entries into formatted display rows.
81///
82/// Pure function: performs no I/O, holds no locks, and is deterministic
83/// given the same inputs.
84pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
85    agents
86        .iter()
87        .map(|agent| {
88            let elapsed = now.saturating_duration_since(agent.last_seen);
89            let symbol = status_symbol(&agent.status);
90            AgentRow {
91                agent_id: agent.agent_id.clone(),
92                cli: agent.cli.clone(),
93                status: format!("{symbol} {}", agent.status),
94                age: format_age(elapsed),
95                summary: agent.summary.clone(),
96            }
97        })
98        .collect()
99}
100
101/// Produces a summary status line for the dashboard footer.
102///
103/// Returns a string like `"4 agents: 2 working, 1 done, 1 blocked"`.
104pub fn format_status_line(total: usize, working: usize, done: usize, blocked: usize) -> String {
105    format!("{total} agents: {working} working, {done} done, {blocked} blocked")
106}
107
108// ---------------------------------------------------------------------------
109// Terminal lifecycle
110// ---------------------------------------------------------------------------
111
112/// Guard that restores the terminal on drop, ensuring cleanup even on panic
113/// or early return.
114struct TerminalGuard {
115    terminal: Terminal<CrosstermBackend<Stdout>>,
116}
117
118impl Drop for TerminalGuard {
119    fn drop(&mut self) {
120        let _ = terminal::disable_raw_mode();
121        let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
122        let _ = self.terminal.show_cursor();
123    }
124}
125
126/// Enters raw mode and the alternate screen, returning a configured terminal.
127fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
128    terminal::enable_raw_mode()
129        .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
130    crossterm::execute!(io::stdout(), EnterAlternateScreen)
131        .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
132    Terminal::new(CrosstermBackend::new(io::stdout()))
133        .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
134}
135
136/// Disables raw mode, leaves the alternate screen, and shows the cursor.
137fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
138    terminal::disable_raw_mode()
139        .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
140    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
141        .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
142    terminal
143        .show_cursor()
144        .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
145}
146
147// ---------------------------------------------------------------------------
148// Draw
149// ---------------------------------------------------------------------------
150
151/// Renders one frame of the dashboard TUI.
152fn draw_frame(frame: &mut Frame, rows: &[AgentRow], status_line: &str) {
153    let chunks = Layout::vertical([
154        Constraint::Length(1),
155        Constraint::Min(0),
156        Constraint::Length(1),
157    ])
158    .split(frame.area());
159
160    let title =
161        Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
162    frame.render_widget(title, chunks[0]);
163
164    if rows.is_empty() {
165        let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
166        frame.render_widget(empty, chunks[1]);
167    } else {
168        let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
169            .style(Style::default().add_modifier(Modifier::BOLD));
170        let table_rows: Vec<Row> = rows
171            .iter()
172            .map(|r| {
173                Row::new([
174                    r.agent_id.as_str(),
175                    r.cli.as_str(),
176                    r.status.as_str(),
177                    r.age.as_str(),
178                    r.summary.as_str(),
179                ])
180            })
181            .collect();
182        let widths = [
183            Constraint::Min(15),
184            Constraint::Length(10),
185            Constraint::Length(15),
186            Constraint::Length(10),
187            Constraint::Min(20),
188        ];
189        let table = Table::new(table_rows, widths).header(header);
190        frame.render_widget(table, chunks[1]);
191    }
192
193    let status = Paragraph::new(status_line.to_string());
194    frame.render_widget(status, chunks[2]);
195}
196
197// ---------------------------------------------------------------------------
198// Main loop
199// ---------------------------------------------------------------------------
200
201/// Runs the dashboard TUI, polling broker state on a 1-second tick.
202///
203/// Takes ownership of [`BrokerHandle`] so the broker shuts down automatically
204/// when the dashboard exits. Press `q` to quit, or set `shutdown` to `true`
205/// to trigger a graceful exit (used by the SIGHUP handler when tmux kills the
206/// session).
207pub fn run_dashboard(
208    state: &Arc<BrokerState>,
209    _broker_handle: BrokerHandle,
210    shutdown: &std::sync::atomic::AtomicBool,
211) -> Result<(), PawError> {
212    // Install a panic hook that restores the terminal before printing the panic.
213    let original_hook = std::panic::take_hook();
214    std::panic::set_hook(Box::new(move |info| {
215        let _ = terminal::disable_raw_mode();
216        let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
217        original_hook(info);
218    }));
219
220    let terminal = setup_terminal()?;
221    let mut guard = TerminalGuard { terminal };
222
223    loop {
224        // Check for SIGHUP-triggered shutdown (e.g. tmux kill-session)
225        if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
226            break;
227        }
228
229        if event::poll(Duration::ZERO)
230            .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
231            && let Event::Key(key) = event::read()
232                .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?
233            && key.kind == KeyEventKind::Press
234            && key.code == KeyCode::Char('q')
235        {
236            break;
237        }
238
239        let agents = delivery::agent_status_snapshot(state);
240        let now = Instant::now();
241        let rows = format_agent_rows(&agents, now);
242        let working = agents.iter().filter(|a| a.status == "working").count();
243        let done = agents
244            .iter()
245            .filter(|a| a.status == "done" || a.status == "verified")
246            .count();
247        let blocked = agents.iter().filter(|a| a.status == "blocked").count();
248        let status_line = format_status_line(agents.len(), working, done, blocked);
249
250        guard
251            .terminal
252            .draw(|f| draw_frame(f, &rows, &status_line))
253            .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
254
255        thread::sleep(TICK_INTERVAL);
256    }
257
258    // Explicit restore for clean exit; guard also restores on drop as a safety net.
259    restore_terminal(&mut guard.terminal)?;
260    Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    // -----------------------------------------------------------------------
268    // status_symbol
269    // -----------------------------------------------------------------------
270
271    #[test]
272    fn status_symbol_working() {
273        assert_eq!(status_symbol("working"), "🔵");
274    }
275
276    #[test]
277    fn status_symbol_done() {
278        assert_eq!(status_symbol("done"), "🟢");
279    }
280
281    #[test]
282    fn status_symbol_verified() {
283        assert_eq!(status_symbol("verified"), "🟢");
284    }
285
286    #[test]
287    fn status_symbol_blocked() {
288        assert_eq!(status_symbol("blocked"), "🟡");
289    }
290
291    #[test]
292    fn status_symbol_idle() {
293        assert_eq!(status_symbol("idle"), "⚪");
294    }
295
296    #[test]
297    fn status_symbol_unknown() {
298        assert_eq!(status_symbol("something-unexpected"), "⚪");
299    }
300
301    // -----------------------------------------------------------------------
302    // format_age
303    // -----------------------------------------------------------------------
304
305    #[test]
306    fn format_age_zero_seconds() {
307        assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
308    }
309
310    #[test]
311    fn format_age_thirty_seconds() {
312        assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
313    }
314
315    #[test]
316    fn format_age_three_minutes() {
317        assert_eq!(format_age(Duration::from_secs(180)), "3m ago");
318    }
319
320    #[test]
321    fn format_age_one_hour_exact() {
322        assert_eq!(format_age(Duration::from_secs(3600)), "1h 0m ago");
323    }
324
325    #[test]
326    fn format_age_one_hour_fifteen_minutes() {
327        assert_eq!(format_age(Duration::from_secs(4500)), "1h 15m ago");
328    }
329
330    // -----------------------------------------------------------------------
331    // format_agent_rows
332    // -----------------------------------------------------------------------
333
334    #[test]
335    fn format_agent_rows_three_agents() {
336        let now = Instant::now();
337        let agents = vec![
338            AgentStatusEntry {
339                agent_id: "feat-a".to_string(),
340                cli: "claude".to_string(),
341                status: "working".to_string(),
342                last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
343                last_seen_seconds: 10,
344                summary: "msg a".to_string(),
345            },
346            AgentStatusEntry {
347                agent_id: "feat-b".to_string(),
348                cli: "cursor".to_string(),
349                status: "done".to_string(),
350                last_seen: now.checked_sub(Duration::from_secs(60)).unwrap(),
351                last_seen_seconds: 60,
352                summary: "msg b".to_string(),
353            },
354            AgentStatusEntry {
355                agent_id: "feat-c".to_string(),
356                cli: "claude".to_string(),
357                status: "blocked".to_string(),
358                last_seen: now.checked_sub(Duration::from_secs(300)).unwrap(),
359                last_seen_seconds: 300,
360                summary: String::new(),
361            },
362        ];
363        let rows = format_agent_rows(&agents, now);
364        assert_eq!(rows.len(), 3);
365        assert_eq!(rows[0].agent_id, "feat-a");
366        assert_eq!(rows[1].agent_id, "feat-b");
367        assert_eq!(rows[2].agent_id, "feat-c");
368    }
369
370    #[test]
371    fn format_agent_rows_single_done_three_minutes() {
372        let now = Instant::now();
373        let agents = vec![AgentStatusEntry {
374            agent_id: "feat-errors".to_string(),
375            cli: "claude".to_string(),
376            status: "done".to_string(),
377            last_seen: now.checked_sub(Duration::from_secs(180)).unwrap(),
378            last_seen_seconds: 180,
379            summary: "finished".to_string(),
380        }];
381        let rows = format_agent_rows(&agents, now);
382        assert_eq!(rows.len(), 1);
383        assert_eq!(rows[0].agent_id, "feat-errors");
384        assert_eq!(rows[0].age, "3m ago");
385        assert!(rows[0].status.contains("done"));
386    }
387
388    #[test]
389    fn format_agent_rows_empty_input() {
390        let rows = format_agent_rows(&[], Instant::now());
391        assert!(rows.is_empty());
392    }
393
394    // -----------------------------------------------------------------------
395    // format_status_line
396    // -----------------------------------------------------------------------
397
398    #[test]
399    fn format_status_line_mixed() {
400        assert_eq!(
401            format_status_line(4, 2, 1, 1),
402            "4 agents: 2 working, 1 done, 1 blocked"
403        );
404    }
405
406    #[test]
407    fn format_status_line_all_done() {
408        assert_eq!(
409            format_status_line(3, 0, 3, 0),
410            "3 agents: 0 working, 3 done, 0 blocked"
411        );
412    }
413
414    #[test]
415    fn format_status_line_zero_agents() {
416        assert_eq!(
417            format_status_line(0, 0, 0, 0),
418            "0 agents: 0 working, 0 done, 0 blocked"
419        );
420    }
421}