1use 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
25const TICK_INTERVAL: Duration = Duration::from_secs(1);
27
28#[derive(Debug, Clone)]
30pub struct AgentRow {
31 pub agent_id: String,
33 pub cli: String,
35 pub status: String,
37 pub age: String,
39 pub summary: String,
41}
42
43pub fn status_symbol(status: &str) -> &'static str {
53 match status {
54 "working" => "🔵",
55 "done" | "verified" => "🟢",
56 "blocked" => "🟡",
57 _ => "⚪",
58 }
59}
60
61pub 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
80pub 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
101pub 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
108struct 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
126fn 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
136fn 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
147fn 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
197pub fn run_dashboard(
208 state: &Arc<BrokerState>,
209 _broker_handle: BrokerHandle,
210 shutdown: &std::sync::atomic::AtomicBool,
211) -> Result<(), PawError> {
212 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 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 restore_terminal(&mut guard.terminal)?;
260 Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[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 #[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 #[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 #[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}