1use std::collections::HashMap;
8use std::io::{self, Stdout};
9use std::sync::Arc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use crossterm::event::{self, Event, KeyCode, KeyEventKind};
14use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
15use ratatui::Frame;
16use ratatui::Terminal;
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Alignment, Constraint, Layout};
19use ratatui::style::{Modifier, Style};
20use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
21
22use crate::broker::delivery;
23use crate::broker::messages::BrokerMessage;
24use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
25use crate::error::PawError;
26
27const TICK_INTERVAL: Duration = Duration::from_millis(50);
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct AgentRow {
38 pub agent_id: String,
40 pub cli: String,
42 pub status: String,
44 pub age: String,
46 pub summary: String,
48}
49
50const MAX_VISIBLE_MESSAGES: usize = 20;
52
53pub fn status_symbol(status: &str) -> &'static str {
64 match status {
65 "working" => "🔵",
66 "done" | "verified" => "🟢",
67 "committed" => "🟣",
68 "blocked" => "🟡",
69 _ => "⚪",
70 }
71}
72
73pub fn format_age(elapsed: Duration) -> String {
79 let secs = elapsed.as_secs();
80 if secs < 60 {
81 format!("{secs}s ago")
82 } else if secs < 3600 {
83 let mins = secs / 60;
84 format!("{mins}m ago")
85 } else {
86 let hours = secs / 3600;
87 let mins = (secs % 3600) / 60;
88 format!("{hours}h {mins}m ago")
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct MessageEntry {
95 pub timestamp: String,
97 pub agent_id: String,
99 pub message_type: String,
101 pub content: String,
103}
104
105pub fn message_type_symbol(msg_type: &str) -> &'static str {
107 match msg_type {
108 "agent.status" => "📤",
109 "agent.artifact" => "📦",
110 "agent.blocked" => "🚧",
111 "agent.verified" => "✅",
112 "agent.feedback" => "💬",
113 "agent.question" => "❓",
114 _ => "📄",
115 }
116}
117
118pub fn format_message_entry(
120 _seq: u64,
121 timestamp: std::time::SystemTime,
122 msg: &BrokerMessage,
123) -> MessageEntry {
124 let time = timestamp.duration_since(std::time::UNIX_EPOCH).map_or_else(
126 |_| "00:00:00".to_string(),
127 |d| {
128 let secs = d.as_secs() % 86400; let hours = secs / 3600;
130 let mins = (secs % 3600) / 60;
131 let secs = secs % 60;
132 format!("{hours:02}:{mins:02}:{secs:02}")
133 },
134 );
135
136 let msg_type = match msg {
137 BrokerMessage::Status { .. } => "status",
138 BrokerMessage::Artifact { .. } => "artifact",
139 BrokerMessage::Blocked { .. } => "blocked",
140 BrokerMessage::Verified { .. } => "verified",
141 BrokerMessage::Feedback { .. } => "feedback",
142 BrokerMessage::Question { .. } => "question",
143 BrokerMessage::Intent { .. } => "intent",
144 };
145 let symbol = message_type_symbol(&format!("agent.{msg_type}"));
146 let _status_label = msg.status_label().to_string();
147
148 MessageEntry {
149 timestamp: time,
150 agent_id: msg.agent_id().to_string(),
151 message_type: format!("{symbol} {msg_type}"),
152 content: msg.to_string(),
153 }
154}
155
156pub fn format_message_entries(
158 messages: &[(u64, std::time::SystemTime, BrokerMessage)],
159) -> Vec<MessageEntry> {
160 messages
161 .iter()
162 .map(|(seq, ts, msg)| format_message_entry(*seq, *ts, msg))
163 .collect()
164}
165
166pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
177 agents
178 .iter()
179 .map(|agent| {
180 let elapsed = now.saturating_duration_since(agent.last_seen);
181 let label = agent.phase.as_deref().unwrap_or(&agent.status);
182 let symbol = status_symbol(label);
183 AgentRow {
184 agent_id: agent.agent_id.clone(),
185 cli: agent.cli.clone(),
186 status: format!("{symbol} {label}"),
187 age: format_age(elapsed),
188 summary: agent.summary.clone(),
189 }
190 })
191 .collect()
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum AgentTableRow {
199 Agent(AgentRow),
201 Divider,
203}
204
205pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
214 let mut supervisor: Option<AgentRow> = None;
215 let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
216 for row in rows {
217 if row.agent_id == "supervisor" {
218 supervisor = Some(row);
219 } else {
220 coding.push(row);
221 }
222 }
223
224 let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
225 if let Some(sup) = supervisor {
226 out.push(AgentTableRow::Agent(sup));
227 out.push(AgentTableRow::Divider);
228 }
229 out.extend(coding.into_iter().map(AgentTableRow::Agent));
230 out
231}
232
233pub fn format_status_line(
237 total: usize,
238 working: usize,
239 done: usize,
240 blocked: usize,
241 committed: usize,
242) -> String {
243 format!(
244 "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
245 )
246}
247
248struct TerminalGuard {
255 terminal: Terminal<CrosstermBackend<Stdout>>,
256}
257
258impl Drop for TerminalGuard {
259 fn drop(&mut self) {
260 let _ = terminal::disable_raw_mode();
261 let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
262 let _ = self.terminal.show_cursor();
263 }
264}
265
266fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
268 terminal::enable_raw_mode()
269 .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
270 crossterm::execute!(io::stdout(), EnterAlternateScreen)
271 .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
272 Terminal::new(CrosstermBackend::new(io::stdout()))
273 .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
274}
275
276fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
278 terminal::disable_raw_mode()
279 .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
280 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
281 .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
282 terminal
283 .show_cursor()
284 .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
285}
286
287pub fn render_dashboard(
297 frame: &mut Frame,
298 rows: &[AgentRow],
299 status_line: &str,
300 message_entries: &[MessageEntry],
301 show_message_log: bool,
302) {
303 draw_frame(frame, rows, status_line, message_entries, show_message_log);
304}
305
306pub(crate) fn build_layout_constraints(show_message_log: bool) -> Vec<Constraint> {
313 if show_message_log {
314 vec![
315 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(12), ]
320 } else {
321 vec![
322 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
326 }
327}
328
329pub(crate) fn should_quit(code: KeyCode) -> bool {
337 matches!(code, KeyCode::Char('q'))
338}
339
340fn draw_frame(
342 frame: &mut Frame,
343 rows: &[AgentRow],
344 status_line: &str,
345 message_entries: &[MessageEntry],
346 show_message_log: bool,
347) {
348 let layout_constraints = build_layout_constraints(show_message_log);
353
354 let chunks = Layout::vertical(layout_constraints).split(frame.area());
355
356 let title =
357 Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
358 frame.render_widget(title, chunks[0]);
359
360 if rows.is_empty() {
361 let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
362 frame.render_widget(empty, chunks[1]);
363 } else {
364 let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
365 .style(Style::default().add_modifier(Modifier::BOLD));
366 let arranged = arrange_with_supervisor_pinned(rows.to_vec());
372 let divider_segment = "─".repeat(20);
373 let table_rows: Vec<Row> = arranged
374 .iter()
375 .map(|entry| match entry {
376 AgentTableRow::Agent(r) => Row::new(vec![
377 r.agent_id.clone(),
378 r.cli.clone(),
379 r.status.clone(),
380 r.age.clone(),
381 r.summary.clone(),
382 ]),
383 AgentTableRow::Divider => Row::new(vec![
384 divider_segment.clone(),
385 divider_segment.clone(),
386 divider_segment.clone(),
387 divider_segment.clone(),
388 divider_segment.clone(),
389 ])
390 .style(Style::default().add_modifier(Modifier::DIM)),
391 })
392 .collect();
393 let widths = [
394 Constraint::Min(15),
395 Constraint::Length(10),
396 Constraint::Length(15),
397 Constraint::Length(10),
398 Constraint::Min(20),
399 ];
400 let table = Table::new(table_rows, widths).header(header);
401 frame.render_widget(table, chunks[1]);
402 }
403
404 let status = Paragraph::new(status_line.to_string());
405 frame.render_widget(status, chunks[2]);
406
407 if show_message_log {
409 let messages_title = format!("Messages ({} recent)", message_entries.len());
410 let messages_block = Block::default().borders(Borders::ALL).title(messages_title);
411 let messages_text = if message_entries.is_empty() {
412 "(no recent messages)".to_string()
413 } else {
414 message_entries
415 .iter()
416 .take(MAX_VISIBLE_MESSAGES)
417 .map(|entry| {
418 format!(
419 "{} [{}] {}: {}",
420 entry.timestamp, entry.agent_id, entry.message_type, entry.content
421 )
422 })
423 .collect::<Vec<_>>()
424 .join("\n")
425 };
426 let messages = Paragraph::new(messages_text).block(messages_block);
427 frame.render_widget(messages, chunks[3]);
428 }
429}
430
431pub fn run_dashboard(
447 state: &Arc<BrokerState>,
448 broker_handle: BrokerHandle,
449 shutdown: &std::sync::atomic::AtomicBool,
450) -> Result<(), PawError> {
451 run_dashboard_with_panes(state, broker_handle, shutdown, &HashMap::new(), None, false)
452}
453
454pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
461 state: &Arc<BrokerState>,
462 broker_handle: BrokerHandle,
463 shutdown: &std::sync::atomic::AtomicBool,
464 _pane_map: &HashMap<String, usize, S>,
465 _session_name: Option<&str>,
466 show_message_log: bool,
467) -> Result<(), PawError> {
468 let _broker_handle = broker_handle;
469 let original_hook = std::panic::take_hook();
471 std::panic::set_hook(Box::new(move |info| {
472 let _ = terminal::disable_raw_mode();
473 let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
474 original_hook(info);
475 }));
476
477 let terminal = setup_terminal()?;
478 let mut guard = TerminalGuard { terminal };
479
480 loop {
481 if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
483 break;
484 }
485
486 for _ in 0..32 {
489 if !event::poll(Duration::ZERO)
490 .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
491 {
492 break;
493 }
494 let ev = event::read()
495 .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
496 if let Event::Key(key) = ev
497 && key.kind == KeyEventKind::Press
498 && should_quit(key.code)
499 {
500 return restore_terminal(&mut guard.terminal);
501 }
502 }
503
504 let agents = delivery::agent_status_snapshot(state);
505 let now = Instant::now();
506 let rows = format_agent_rows(&agents, now);
507 let working = agents.iter().filter(|a| a.status == "working").count();
508 let done = agents
509 .iter()
510 .filter(|a| a.status == "done" || a.status == "verified")
511 .count();
512 let blocked = agents.iter().filter(|a| a.status == "blocked").count();
513 let committed = agents.iter().filter(|a| a.status == "committed").count();
514 let status_line = format_status_line(agents.len(), working, done, blocked, committed);
515
516 let recent_msgs = delivery::recent_messages(state, MAX_VISIBLE_MESSAGES);
518 let message_entries = format_message_entries(&recent_msgs);
519
520 guard
521 .terminal
522 .draw(|f| {
523 draw_frame(f, &rows, &status_line, &message_entries, show_message_log);
524 })
525 .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
526
527 thread::sleep(TICK_INTERVAL);
528 }
529
530 restore_terminal(&mut guard.terminal)?;
532 Ok(())
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::broker::messages::{
539 ArtifactPayload, BlockedPayload, FeedbackPayload, QuestionPayload, StatusPayload,
540 VerifiedPayload,
541 };
542
543 #[test]
548 fn status_symbol_working() {
549 assert_eq!(status_symbol("working"), "🔵");
550 }
551
552 #[test]
553 fn status_symbol_done() {
554 assert_eq!(status_symbol("done"), "🟢");
555 }
556
557 #[test]
558 fn status_symbol_verified() {
559 assert_eq!(status_symbol("verified"), "🟢");
560 }
561
562 #[test]
563 fn status_symbol_blocked() {
564 assert_eq!(status_symbol("blocked"), "🟡");
565 }
566
567 #[test]
568 fn status_symbol_committed() {
569 assert_eq!(status_symbol("committed"), "🟣");
570 }
571
572 #[test]
573 fn status_symbol_idle() {
574 assert_eq!(status_symbol("idle"), "⚪");
575 }
576
577 #[test]
578 fn status_symbol_unknown() {
579 assert_eq!(status_symbol("something-unexpected"), "⚪");
580 }
581
582 #[test]
587 fn message_type_symbol_status() {
588 assert_eq!(message_type_symbol("agent.status"), "📤");
589 }
590
591 #[test]
592 fn message_type_symbol_artifact() {
593 assert_eq!(message_type_symbol("agent.artifact"), "📦");
594 }
595
596 #[test]
597 fn message_type_symbol_blocked() {
598 assert_eq!(message_type_symbol("agent.blocked"), "🚧");
599 }
600
601 #[test]
602 fn message_type_symbol_verified() {
603 assert_eq!(message_type_symbol("agent.verified"), "✅");
604 }
605
606 #[test]
607 fn message_type_symbol_feedback() {
608 assert_eq!(message_type_symbol("agent.feedback"), "💬");
609 }
610
611 #[test]
612 fn message_type_symbol_question() {
613 assert_eq!(message_type_symbol("agent.question"), "❓");
614 }
615
616 #[test]
617 fn message_type_symbol_unknown() {
618 assert_eq!(message_type_symbol("agent.unknown"), "📄");
619 }
620
621 #[test]
626 fn format_message_entry_status() {
627 let msg = BrokerMessage::Status {
628 agent_id: "feat-errors".to_string(),
629 payload: StatusPayload {
630 status: "working".to_string(),
631 modified_files: vec!["src/main.rs".to_string()],
632 message: Some("refactoring".to_string()),
633 ..Default::default()
634 },
635 };
636 let entry = format_message_entry(1, std::time::SystemTime::now(), &msg);
637 assert_eq!(entry.agent_id, "feat-errors");
638 assert!(entry.message_type.contains("📤 status"));
639 assert!(entry.content.contains("[feat-errors] status: working"));
640 }
641
642 #[test]
643 fn format_message_entry_artifact() {
644 let msg = BrokerMessage::Artifact {
645 agent_id: "feat-errors".to_string(),
646 payload: ArtifactPayload {
647 status: "done".to_string(),
648 exports: vec!["PawError".to_string()],
649 modified_files: vec!["src/error.rs".to_string()],
650 },
651 };
652 let entry = format_message_entry(2, std::time::SystemTime::now(), &msg);
653 assert_eq!(entry.agent_id, "feat-errors");
654 assert!(entry.message_type.contains("📦 artifact"));
655 assert!(entry.content.contains("[feat-errors] artifact: done"));
656 }
657
658 #[test]
659 fn format_message_entries_empty() {
660 let entries = format_message_entries(&[]);
661 assert!(entries.is_empty());
662 }
663
664 #[test]
665 fn format_message_entries_multiple() {
666 let msg1 = BrokerMessage::Status {
667 agent_id: "feat-a".to_string(),
668 payload: StatusPayload {
669 status: "working".to_string(),
670 modified_files: vec![],
671 message: None,
672 ..Default::default()
673 },
674 };
675 let msg2 = BrokerMessage::Artifact {
676 agent_id: "feat-b".to_string(),
677 payload: ArtifactPayload {
678 status: "done".to_string(),
679 exports: vec![],
680 modified_files: vec![],
681 },
682 };
683 let messages = vec![
684 (1, std::time::SystemTime::now(), msg1),
685 (2, std::time::SystemTime::now(), msg2),
686 ];
687 let entries = format_message_entries(&messages);
688 assert_eq!(entries.len(), 2);
689 assert_eq!(entries[0].agent_id, "feat-a");
690 assert_eq!(entries[1].agent_id, "feat-b");
691 }
692
693 #[test]
694 fn format_message_entries_all_types() {
695 let messages = vec![
696 (
697 1,
698 std::time::SystemTime::now(),
699 BrokerMessage::Status {
700 agent_id: "feat-a".to_string(),
701 payload: StatusPayload {
702 status: "working".to_string(),
703 modified_files: vec![],
704 message: None,
705 ..Default::default()
706 },
707 },
708 ),
709 (
710 2,
711 std::time::SystemTime::now(),
712 BrokerMessage::Artifact {
713 agent_id: "feat-b".to_string(),
714 payload: ArtifactPayload {
715 status: "done".to_string(),
716 exports: vec![],
717 modified_files: vec![],
718 },
719 },
720 ),
721 (
722 3,
723 std::time::SystemTime::now(),
724 BrokerMessage::Blocked {
725 agent_id: "feat-c".to_string(),
726 payload: BlockedPayload {
727 needs: "types".to_string(),
728 from: "feat-b".to_string(),
729 },
730 },
731 ),
732 (
733 4,
734 std::time::SystemTime::now(),
735 BrokerMessage::Verified {
736 agent_id: "feat-d".to_string(),
737 payload: VerifiedPayload {
738 verified_by: "supervisor".to_string(),
739 message: None,
740 },
741 },
742 ),
743 (
744 5,
745 std::time::SystemTime::now(),
746 BrokerMessage::Feedback {
747 agent_id: "feat-e".to_string(),
748 payload: FeedbackPayload {
749 from: "supervisor".to_string(),
750 errors: vec!["error".to_string()],
751 },
752 },
753 ),
754 (
755 6,
756 std::time::SystemTime::now(),
757 BrokerMessage::Question {
758 agent_id: "feat-f".to_string(),
759 payload: QuestionPayload {
760 question: "question?".to_string(),
761 },
762 },
763 ),
764 ];
765
766 let entries = format_message_entries(&messages);
767 assert_eq!(entries.len(), 6);
768
769 let type_symbols: Vec<&str> = entries
771 .iter()
772 .map(|entry| entry.message_type.split(' ').next().unwrap())
773 .collect();
774 assert!(type_symbols.contains(&"📤")); assert!(type_symbols.contains(&"📦")); assert!(type_symbols.contains(&"🚧")); assert!(type_symbols.contains(&"✅")); assert!(type_symbols.contains(&"💬")); assert!(type_symbols.contains(&"❓")); }
781
782 #[test]
787 fn format_age_zero_seconds() {
788 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
789 }
790
791 #[test]
792 fn format_age_thirty_seconds() {
793 assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
794 }
795
796 #[test]
797 fn format_age_three_minutes() {
798 assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
799 }
800
801 #[test]
802 fn format_age_one_hour_exact() {
803 assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
804 }
805
806 #[test]
807 fn format_age_one_hour_fifteen_minutes() {
808 assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
809 }
810
811 #[test]
816 fn format_agent_rows_three_agents() {
817 let now = Instant::now();
818 let agents = vec![
819 AgentStatusEntry {
820 agent_id: "feat-a".to_string(),
821 cli: "claude".to_string(),
822 status: "working".to_string(),
823 last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
824 last_seen_seconds: 10,
825 summary: "msg a".to_string(),
826 phase: None,
827 },
828 AgentStatusEntry {
829 agent_id: "feat-b".to_string(),
830 cli: "cursor".to_string(),
831 status: "done".to_string(),
832 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
833 last_seen_seconds: 60,
834 summary: "msg b".to_string(),
835 phase: None,
836 },
837 AgentStatusEntry {
838 agent_id: "feat-c".to_string(),
839 cli: "claude".to_string(),
840 status: "blocked".to_string(),
841 last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
842 last_seen_seconds: 300,
843 summary: String::new(),
844 phase: None,
845 },
846 ];
847 let rows = format_agent_rows(&agents, now);
848 assert_eq!(rows.len(), 3);
849 assert_eq!(rows[0].agent_id, "feat-a");
850 assert_eq!(rows[1].agent_id, "feat-b");
851 assert_eq!(rows[2].agent_id, "feat-c");
852 }
853
854 #[test]
855 fn format_agent_rows_single_done_three_minutes() {
856 let now = Instant::now();
857 let agents = vec![AgentStatusEntry {
858 agent_id: "feat-errors".to_string(),
859 cli: "claude".to_string(),
860 status: "done".to_string(),
861 last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
862 last_seen_seconds: 180,
863 summary: "finished".to_string(),
864 phase: None,
865 }];
866 let rows = format_agent_rows(&agents, now);
867 assert_eq!(rows.len(), 1);
868 assert_eq!(rows[0].agent_id, "feat-errors");
869 assert_eq!(rows[0].age, "3m ago");
870 assert!(rows[0].status.contains("done"));
871 }
872
873 #[test]
874 fn format_agent_rows_with_committed_status() {
875 let now = Instant::now();
876 let agents = vec![
877 AgentStatusEntry {
878 agent_id: "feat-committed".to_string(),
879 cli: "claude".to_string(),
880 status: "committed".to_string(),
881 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
882 last_seen_seconds: 60,
883 summary: "changes committed".to_string(),
884 phase: None,
885 },
886 AgentStatusEntry {
887 agent_id: "feat-working".to_string(),
888 cli: "cursor".to_string(),
889 status: "working".to_string(),
890 last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
891 last_seen_seconds: 30,
892 summary: "in progress".to_string(),
893 phase: None,
894 },
895 ];
896 let rows = format_agent_rows(&agents, now);
897 assert_eq!(rows.len(), 2);
898
899 let committed_row = rows
901 .iter()
902 .find(|r| r.agent_id == "feat-committed")
903 .unwrap();
904 assert!(committed_row.status.contains("🟣"));
905 assert!(committed_row.status.contains("committed"));
906
907 let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
909 assert!(working_row.status.contains("🔵"));
910 assert!(working_row.status.contains("working"));
911 }
912
913 #[test]
914 fn format_agent_rows_empty_input() {
915 let rows = format_agent_rows(&[], Instant::now());
916 assert!(rows.is_empty());
917 }
918
919 #[test]
924 fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
925 let now = Instant::now();
926 let agents = vec![AgentStatusEntry {
927 agent_id: "supervisor".to_string(),
928 cli: "claude".to_string(),
929 status: "feedback".to_string(),
930 last_seen: now,
931 last_seen_seconds: 0,
932 summary: String::new(),
933 phase: Some("merging".to_string()),
934 }];
935 let rows = format_agent_rows(&agents, now);
936 assert_eq!(rows.len(), 1);
937 assert!(
938 rows[0].status.contains("merging"),
939 "expected phase 'merging' in status field; got {:?}",
940 rows[0].status,
941 );
942 assert!(
943 !rows[0].status.contains("feedback"),
944 "phase must replace status label, not append; got {:?}",
945 rows[0].status,
946 );
947 }
948
949 #[test]
950 fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
951 let now = Instant::now();
952 let agents = vec![AgentStatusEntry {
953 agent_id: "feat-broker".to_string(),
954 cli: "claude".to_string(),
955 status: "working".to_string(),
956 last_seen: now,
957 last_seen_seconds: 0,
958 summary: String::new(),
959 phase: None,
960 }];
961 let rows = format_agent_rows(&agents, now);
962 assert!(
963 rows[0].status.contains("working"),
964 "expected 'working' in status field; got {:?}",
965 rows[0].status,
966 );
967 }
968
969 fn agent_row(id: &str) -> AgentRow {
974 AgentRow {
975 agent_id: id.to_string(),
976 cli: "claude".to_string(),
977 status: "🔵 working".to_string(),
978 age: "0s ago".to_string(),
979 summary: String::new(),
980 }
981 }
982
983 #[test]
984 fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
985 let rows = vec![
986 agent_row("feat-broker"),
987 agent_row("feat-dashboard"),
988 agent_row("supervisor"),
989 ];
990 let arranged = arrange_with_supervisor_pinned(rows);
991 assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
992 assert!(
993 matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
994 "supervisor must be at row 0; got {:?}",
995 arranged[0]
996 );
997 assert_eq!(
998 arranged[1],
999 AgentTableRow::Divider,
1000 "divider must immediately follow supervisor"
1001 );
1002 assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
1003 assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
1004 }
1005
1006 #[test]
1007 fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
1008 let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
1009 let arranged = arrange_with_supervisor_pinned(rows);
1010 assert_eq!(arranged.len(), 2);
1011 for row in &arranged {
1012 assert!(
1013 !matches!(row, AgentTableRow::Divider),
1014 "no divider when supervisor is absent; got {row:?}"
1015 );
1016 }
1017 assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
1018 assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
1019 }
1020
1021 #[test]
1022 fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
1023 let arranged = arrange_with_supervisor_pinned(Vec::new());
1024 assert!(arranged.is_empty());
1025 }
1026
1027 #[test]
1028 fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
1029 use ratatui::Terminal;
1030 use ratatui::backend::TestBackend;
1031
1032 let rows = vec![
1036 agent_row("feat-broker"),
1037 agent_row("feat-dashboard"),
1038 agent_row("supervisor"),
1039 ];
1040
1041 let backend = TestBackend::new(140, 30);
1042 let mut terminal = Terminal::new(backend).unwrap();
1043 terminal
1044 .draw(|f| draw_frame(f, &rows, "3 agents", &[], false))
1045 .unwrap();
1046
1047 let buffer = terminal.backend().buffer().clone();
1050 let mut rendered = String::new();
1051 for y in 0..buffer.area.height {
1052 for x in 0..buffer.area.width {
1053 rendered.push_str(buffer[(x, y)].symbol());
1054 }
1055 rendered.push('\n');
1056 }
1057
1058 let pos_supervisor = rendered
1059 .find("supervisor")
1060 .expect("supervisor row should be in rendered frame");
1061 let pos_broker = rendered
1062 .find("feat-broker")
1063 .expect("feat-broker row should be in rendered frame");
1064 let pos_dashboard = rendered
1065 .find("feat-dashboard")
1066 .expect("feat-dashboard row should be in rendered frame");
1067 assert!(
1068 pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
1069 "supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
1070 );
1071
1072 let pos_divider = rendered[pos_supervisor..]
1075 .find('─')
1076 .map(|p| pos_supervisor + p)
1077 .expect("divider row should contain horizontal-line characters");
1078 assert!(
1079 pos_divider > pos_supervisor && pos_divider < pos_broker,
1080 "divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
1081 );
1082 }
1083
1084 #[test]
1089 fn format_status_line_mixed() {
1090 assert_eq!(
1091 format_status_line(4, 2, 1, 1, 0),
1092 "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1093 );
1094 }
1095
1096 #[test]
1097 fn format_status_line_all_done() {
1098 assert_eq!(
1099 format_status_line(3, 0, 3, 0, 0),
1100 "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1101 );
1102 }
1103
1104 #[test]
1105 fn format_status_line_zero_agents() {
1106 assert_eq!(
1107 format_status_line(0, 0, 0, 0, 0),
1108 "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1109 );
1110 }
1111
1112 #[test]
1113 fn format_status_line_with_committed() {
1114 assert_eq!(
1115 format_status_line(5, 2, 1, 1, 1),
1116 "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1117 );
1118 }
1119
1120 #[test]
1125 fn rendered_frame_contains_no_questions_or_reply_input() {
1126 use ratatui::Terminal;
1127 use ratatui::backend::TestBackend;
1128
1129 let backend = TestBackend::new(140, 30);
1130 let mut terminal = Terminal::new(backend).unwrap();
1131 terminal
1132 .draw(|f| draw_frame(f, &[], "0 agents", &[], false))
1133 .unwrap();
1134
1135 let buffer = terminal.backend().buffer().clone();
1136 let mut rendered = String::new();
1137 for y in 0..buffer.area.height {
1138 for x in 0..buffer.area.width {
1139 rendered.push_str(buffer[(x, y)].symbol());
1140 }
1141 rendered.push('\n');
1142 }
1143
1144 assert!(
1145 !rendered.contains("Questions ("),
1146 "dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
1147 );
1148 assert!(
1149 !rendered.contains("Reply to"),
1150 "dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
1151 );
1152 }
1153
1154 #[test]
1163 fn tab_key_ignored_no_buffer() {
1164 assert!(
1169 !should_quit(KeyCode::Tab),
1170 "Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
1171 );
1172 }
1173
1174 #[test]
1175 fn printable_char_ignored_no_buffer() {
1176 assert!(
1179 !should_quit(KeyCode::Char('a')),
1180 "printable char 'a' must not quit and must not accumulate into any buffer",
1181 );
1182 assert!(
1183 !should_quit(KeyCode::Char(' ')),
1184 "space must not quit and must not accumulate into any buffer",
1185 );
1186 assert!(
1189 should_quit(KeyCode::Char('q')),
1190 "lowercase 'q' must quit the dashboard",
1191 );
1192 }
1193
1194 #[test]
1195 fn layout_collapses_without_message_log() {
1196 let constraints = build_layout_constraints(false);
1201 assert_eq!(
1202 constraints.len(),
1203 3,
1204 "layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
1205 constraints.len(),
1206 );
1207
1208 let with_log = build_layout_constraints(true);
1213 assert_eq!(
1214 with_log.len(),
1215 4,
1216 "layout with message log must be exactly 4 segments, got {} constraints",
1217 with_log.len(),
1218 );
1219 }
1220}