1pub mod broker_log;
8
9use std::collections::HashMap;
10use std::io::{self, Stdout};
11use std::sync::Arc;
12use std::thread;
13use std::time::{Duration, Instant};
14
15use crossterm::event::{self, Event, KeyCode, KeyEventKind};
16use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
17use ratatui::Frame;
18use ratatui::Terminal;
19use ratatui::backend::CrosstermBackend;
20use ratatui::layout::{Alignment, Constraint, Layout};
21use ratatui::style::{Modifier, Style};
22use ratatui::widgets::{Paragraph, Row, Table};
23
24use crate::broker::delivery;
25use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
26use crate::dashboard::broker_log::{BrokerLog, LogKeyAction};
27use crate::error::PawError;
28
29const TICK_INTERVAL: Duration = Duration::from_millis(50);
36
37const UNKNOWN_CLI: &str = "?";
42
43const SUPERVISOR_AGENT_ID: &str = "supervisor";
47
48const STUCK_ON_PROMPT_PHASE: &str = "stuck-on-prompt";
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AgentRow {
62 pub agent_id: String,
64 pub cli: String,
66 pub status: String,
68 pub age: String,
70}
71
72pub fn status_symbol(status: &str) -> &'static str {
83 match status {
84 "working" => "๐ต",
85 "done" | "verified" => "๐ข",
86 "committed" => "๐ฃ",
87 "blocked" => "๐ก",
88 _ => "โช",
89 }
90}
91
92pub fn format_age(elapsed: Duration) -> String {
98 let secs = elapsed.as_secs();
99 if secs < 60 {
100 format!("{secs}s ago")
101 } else if secs < 3600 {
102 let mins = secs / 60;
103 format!("{mins}m ago")
104 } else {
105 let hours = secs / 3600;
106 let mins = (secs % 3600) / 60;
107 format!("{hours}h {mins}m ago")
108 }
109}
110
111pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
129 agents
130 .iter()
131 .map(|agent| {
132 let elapsed = now.saturating_duration_since(agent.last_seen);
133 let honour_phase = agent.agent_id == SUPERVISOR_AGENT_ID
137 || agent.phase.as_deref() == Some(STUCK_ON_PROMPT_PHASE);
138 let label = match agent.phase.as_deref() {
139 Some(phase) if honour_phase => phase,
140 _ => &agent.status,
141 };
142 let symbol = status_symbol(label);
143 let cli = if agent.cli.trim().is_empty() {
144 UNKNOWN_CLI.to_string()
145 } else {
146 agent.cli.clone()
147 };
148 AgentRow {
149 agent_id: agent.agent_id.clone(),
150 cli,
151 status: format!("{symbol} {label}"),
152 age: format_age(elapsed),
153 }
154 })
155 .collect()
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum AgentTableRow {
163 Agent(AgentRow),
165 Divider,
167}
168
169pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
178 let mut supervisor: Option<AgentRow> = None;
179 let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
180 for row in rows {
181 if row.agent_id == "supervisor" {
182 supervisor = Some(row);
183 } else {
184 coding.push(row);
185 }
186 }
187
188 let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
189 if let Some(sup) = supervisor {
190 out.push(AgentTableRow::Agent(sup));
191 out.push(AgentTableRow::Divider);
192 }
193 out.extend(coding.into_iter().map(AgentTableRow::Agent));
194 out
195}
196
197pub fn format_status_line(
201 total: usize,
202 working: usize,
203 done: usize,
204 blocked: usize,
205 committed: usize,
206) -> String {
207 format!(
208 "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
209 )
210}
211
212struct TerminalGuard {
219 terminal: Terminal<CrosstermBackend<Stdout>>,
220}
221
222impl Drop for TerminalGuard {
223 fn drop(&mut self) {
224 let _ = terminal::disable_raw_mode();
225 let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
226 let _ = self.terminal.show_cursor();
227 }
228}
229
230fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
232 terminal::enable_raw_mode()
233 .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
234 crossterm::execute!(io::stdout(), EnterAlternateScreen)
235 .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
236 Terminal::new(CrosstermBackend::new(io::stdout()))
237 .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
238}
239
240fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
242 terminal::disable_raw_mode()
243 .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
244 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
245 .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
246 terminal
247 .show_cursor()
248 .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
249}
250
251pub fn render_dashboard(
262 frame: &mut Frame,
263 rows: &[AgentRow],
264 status_line: &str,
265 broker_log: &BrokerLog,
266 panel_height: u16,
267) {
268 draw_frame(frame, rows, status_line, broker_log, panel_height);
269}
270
271pub(crate) const MIN_AGENT_TABLE_HEIGHT: u16 = 6;
278
279pub(crate) fn build_layout_constraints(show_panel: bool, panel_height: u16) -> Vec<Constraint> {
289 if show_panel {
290 vec![
291 Constraint::Length(1), Constraint::Min(MIN_AGENT_TABLE_HEIGHT), Constraint::Length(1), Constraint::Length(panel_height), ]
296 } else {
297 vec![
298 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
302 }
303}
304
305pub(crate) fn should_quit(code: KeyCode) -> bool {
313 matches!(code, KeyCode::Char('q'))
314}
315
316fn draw_frame(
319 frame: &mut Frame,
320 rows: &[AgentRow],
321 status_line: &str,
322 broker_log: &BrokerLog,
323 panel_height: u16,
324) {
325 let layout_constraints = build_layout_constraints(broker_log.visible, panel_height);
332
333 let chunks = Layout::vertical(layout_constraints).split(frame.area());
334
335 let title =
336 Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
337 frame.render_widget(title, chunks[0]);
338
339 if rows.is_empty() {
340 let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
341 frame.render_widget(empty, chunks[1]);
342 } else {
343 let header = Row::new(["Agent", "CLI", "Status", "Last Update"])
344 .style(Style::default().add_modifier(Modifier::BOLD));
345 let arranged = arrange_with_supervisor_pinned(rows.to_vec());
351 let divider_segment = "โ".repeat(20);
352 let table_rows: Vec<Row> = arranged
353 .iter()
354 .map(|entry| match entry {
355 AgentTableRow::Agent(r) => Row::new(vec![
356 r.agent_id.clone(),
357 r.cli.clone(),
358 r.status.clone(),
359 r.age.clone(),
360 ]),
361 AgentTableRow::Divider => Row::new(vec![
362 divider_segment.clone(),
363 divider_segment.clone(),
364 divider_segment.clone(),
365 divider_segment.clone(),
366 ])
367 .style(Style::default().add_modifier(Modifier::DIM)),
368 })
369 .collect();
370 let widths = [
371 Constraint::Min(15),
372 Constraint::Length(10),
373 Constraint::Length(15),
374 Constraint::Length(12),
378 ];
379 let table = Table::new(table_rows, widths).header(header);
380 frame.render_widget(table, chunks[1]);
381 }
382
383 let status_text = if broker_log.visible {
388 status_line.to_string()
389 } else {
390 format!("{status_line} ยท broker log hidden โ press l to show")
391 };
392 let status = Paragraph::new(status_text);
393 frame.render_widget(status, chunks[2]);
394
395 if broker_log.visible {
399 broker_log::render(frame, chunks[3], broker_log);
400 }
401}
402
403pub fn run_dashboard(
419 state: &Arc<BrokerState>,
420 broker_handle: BrokerHandle,
421 shutdown: &std::sync::atomic::AtomicBool,
422) -> Result<(), PawError> {
423 run_dashboard_with_panes(
424 state,
425 broker_handle,
426 shutdown,
427 &HashMap::new(),
428 None,
429 500,
430 false,
431 crate::config::BrokerLogConfig::default().height_lines,
432 )
433}
434
435#[allow(clippy::too_many_arguments)]
447pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
448 state: &Arc<BrokerState>,
449 broker_handle: BrokerHandle,
450 shutdown: &std::sync::atomic::AtomicBool,
451 _pane_map: &HashMap<String, usize, S>,
452 _session_name: Option<&str>,
453 max_messages: usize,
454 default_visible: bool,
455 height_lines: u16,
456) -> Result<(), PawError> {
457 let _broker_handle = broker_handle;
458 let original_hook = std::panic::take_hook();
460 std::panic::set_hook(Box::new(move |info| {
461 let _ = terminal::disable_raw_mode();
462 let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
463 original_hook(info);
464 }));
465
466 let terminal = setup_terminal()?;
467 let mut guard = TerminalGuard { terminal };
468
469 let mut broker_log = BrokerLog::new(max_messages, default_visible);
474
475 loop {
476 if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
478 break;
479 }
480
481 for _ in 0..32 {
485 if !event::poll(Duration::ZERO)
486 .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
487 {
488 break;
489 }
490 let ev = event::read()
491 .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
492 if let Event::Key(key) = ev
493 && key.kind == KeyEventKind::Press
494 {
495 if broker_log::handle_key(&mut broker_log, key.code) == LogKeyAction::Ignored
499 && should_quit(key.code)
500 {
501 return restore_terminal(&mut guard.terminal);
502 }
503 }
504 }
505
506 let agents = delivery::agent_status_snapshot(state);
507 let now = Instant::now();
508 let rows = format_agent_rows(&agents, now);
509 let working = agents.iter().filter(|a| a.status == "working").count();
510 let done = agents
511 .iter()
512 .filter(|a| a.status == "done" || a.status == "verified")
513 .count();
514 let blocked = agents.iter().filter(|a| a.status == "blocked").count();
515 let committed = agents.iter().filter(|a| a.status == "committed").count();
516 let status_line = format_status_line(agents.len(), working, done, blocked, committed);
517
518 broker_log.ingest(delivery::full_log(state, broker_log.last_seq()));
522
523 guard
524 .terminal
525 .draw(|f| {
526 draw_frame(f, &rows, &status_line, &broker_log, height_lines);
527 })
528 .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
529
530 thread::sleep(TICK_INTERVAL);
531 }
532
533 restore_terminal(&mut guard.terminal)?;
535 Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 fn hidden_log() -> BrokerLog {
546 BrokerLog::new(500, false)
547 }
548
549 fn default_panel_height() -> u16 {
552 crate::config::BrokerLogConfig::default().height_lines
553 }
554
555 #[test]
560 fn status_symbol_working() {
561 assert_eq!(status_symbol("working"), "๐ต");
562 }
563
564 #[test]
565 fn status_symbol_done() {
566 assert_eq!(status_symbol("done"), "๐ข");
567 }
568
569 #[test]
570 fn status_symbol_verified() {
571 assert_eq!(status_symbol("verified"), "๐ข");
572 }
573
574 #[test]
575 fn status_symbol_blocked() {
576 assert_eq!(status_symbol("blocked"), "๐ก");
577 }
578
579 #[test]
580 fn status_symbol_committed() {
581 assert_eq!(status_symbol("committed"), "๐ฃ");
582 }
583
584 #[test]
585 fn status_symbol_idle() {
586 assert_eq!(status_symbol("idle"), "โช");
587 }
588
589 #[test]
590 fn status_symbol_unknown() {
591 assert_eq!(status_symbol("something-unexpected"), "โช");
592 }
593
594 #[test]
599 fn format_age_zero_seconds() {
600 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
601 }
602
603 #[test]
604 fn format_age_thirty_seconds() {
605 assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
606 }
607
608 #[test]
609 fn format_age_three_minutes() {
610 assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
611 }
612
613 #[test]
614 fn format_age_one_hour_exact() {
615 assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
616 }
617
618 #[test]
619 fn format_age_one_hour_fifteen_minutes() {
620 assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
621 }
622
623 #[test]
628 fn format_agent_rows_three_agents() {
629 let now = Instant::now();
630 let agents = vec![
631 AgentStatusEntry {
632 agent_id: "feat-a".to_string(),
633 cli: "claude".to_string(),
634 status: "working".to_string(),
635 last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
636 last_seen_seconds: 10,
637 phase: None,
638 },
639 AgentStatusEntry {
640 agent_id: "feat-b".to_string(),
641 cli: "cursor".to_string(),
642 status: "done".to_string(),
643 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
644 last_seen_seconds: 60,
645 phase: None,
646 },
647 AgentStatusEntry {
648 agent_id: "feat-c".to_string(),
649 cli: "claude".to_string(),
650 status: "blocked".to_string(),
651 last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
652 last_seen_seconds: 300,
653 phase: None,
654 },
655 ];
656 let rows = format_agent_rows(&agents, now);
657 assert_eq!(rows.len(), 3);
658 assert_eq!(rows[0].agent_id, "feat-a");
659 assert_eq!(rows[1].agent_id, "feat-b");
660 assert_eq!(rows[2].agent_id, "feat-c");
661 }
662
663 #[test]
664 fn format_agent_rows_single_done_three_minutes() {
665 let now = Instant::now();
666 let agents = vec![AgentStatusEntry {
667 agent_id: "feat-errors".to_string(),
668 cli: "claude".to_string(),
669 status: "done".to_string(),
670 last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
671 last_seen_seconds: 180,
672 phase: None,
673 }];
674 let rows = format_agent_rows(&agents, now);
675 assert_eq!(rows.len(), 1);
676 assert_eq!(rows[0].agent_id, "feat-errors");
677 assert_eq!(rows[0].age, "3m ago");
678 assert!(rows[0].status.contains("done"));
679 }
680
681 #[test]
682 fn format_agent_rows_with_committed_status() {
683 let now = Instant::now();
684 let agents = vec![
685 AgentStatusEntry {
686 agent_id: "feat-committed".to_string(),
687 cli: "claude".to_string(),
688 status: "committed".to_string(),
689 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
690 last_seen_seconds: 60,
691 phase: None,
692 },
693 AgentStatusEntry {
694 agent_id: "feat-working".to_string(),
695 cli: "cursor".to_string(),
696 status: "working".to_string(),
697 last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
698 last_seen_seconds: 30,
699 phase: None,
700 },
701 ];
702 let rows = format_agent_rows(&agents, now);
703 assert_eq!(rows.len(), 2);
704
705 let committed_row = rows
707 .iter()
708 .find(|r| r.agent_id == "feat-committed")
709 .unwrap();
710 assert!(committed_row.status.contains("๐ฃ"));
711 assert!(committed_row.status.contains("committed"));
712
713 let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
715 assert!(working_row.status.contains("๐ต"));
716 assert!(working_row.status.contains("working"));
717 }
718
719 #[test]
720 fn format_agent_rows_empty_input() {
721 let rows = format_agent_rows(&[], Instant::now());
722 assert!(rows.is_empty());
723 }
724
725 #[test]
726 fn agent_row_exposes_only_four_fields_no_summary() {
727 let now = Instant::now();
733 let agents = vec![AgentStatusEntry {
734 agent_id: "feat-errors".to_string(),
735 cli: "claude".to_string(),
736 status: "done".to_string(),
737 last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
738 last_seen_seconds: 180,
739 phase: None,
740 }];
741 let rows = format_agent_rows(&agents, now);
742 assert_eq!(rows.len(), 1);
743 let AgentRow {
744 agent_id,
745 cli,
746 status,
747 age,
748 } = &rows[0];
749 assert_eq!(agent_id, "feat-errors");
750 assert_eq!(cli, "claude");
751 assert!(status.contains("done"));
752 assert_eq!(age, "3m ago");
753 }
754
755 #[test]
760 fn format_agent_rows_populates_cli_for_every_agent() {
761 let now = Instant::now();
764 let agents = vec![
765 AgentStatusEntry {
766 agent_id: "supervisor".to_string(),
767 cli: "claude-oss".to_string(),
768 status: "working".to_string(),
769 last_seen: now,
770 last_seen_seconds: 0,
771 phase: Some("watching".to_string()),
772 },
773 AgentStatusEntry {
774 agent_id: "feat-a".to_string(),
775 cli: "claude-oss".to_string(),
776 status: "working".to_string(),
777 last_seen: now,
778 last_seen_seconds: 0,
779 phase: None,
780 },
781 AgentStatusEntry {
782 agent_id: "feat-b".to_string(),
783 cli: "claude-oss".to_string(),
784 status: "working".to_string(),
785 last_seen: now,
786 last_seen_seconds: 0,
787 phase: None,
788 },
789 ];
790 let rows = format_agent_rows(&agents, now);
791 assert_eq!(rows.len(), 3);
792 for row in &rows {
793 assert_eq!(
794 row.cli, "claude-oss",
795 "every agent row must render its CLI, not just the supervisor: {row:?}",
796 );
797 }
798 }
799
800 #[test]
801 fn format_agent_rows_shows_placeholder_for_unresolved_cli() {
802 let now = Instant::now();
805 let agents = vec![AgentStatusEntry {
806 agent_id: "feat-mystery".to_string(),
807 cli: String::new(),
808 status: "working".to_string(),
809 last_seen: now,
810 last_seen_seconds: 0,
811 phase: None,
812 }];
813 let rows = format_agent_rows(&agents, now);
814 assert_eq!(rows.len(), 1);
815 assert_eq!(
816 rows[0].cli, UNKNOWN_CLI,
817 "blank CLI must render the documented placeholder, not an empty string",
818 );
819 assert!(!rows[0].cli.is_empty());
820 }
821
822 #[test]
827 fn dashboard_row_transitions_committed_to_working_within_ttl() {
828 use crate::broker::BrokerState;
829 use crate::broker::delivery::{agent_status_snapshot, publish_message};
830 use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
831 use std::sync::Arc;
832
833 let state = Arc::new(BrokerState::new(None)); publish_message(
835 &state,
836 &BrokerMessage::Artifact {
837 agent_id: "feat-x".to_string(),
838 payload: ArtifactPayload {
839 status: "committed".to_string(),
840 exports: vec![],
841 modified_files: vec![],
842 },
843 },
844 );
845 let snap = agent_status_snapshot(&state);
847 let rows = format_agent_rows(&snap, Instant::now());
848 let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
849 assert!(row.status.contains("committed"), "should start committed");
850
851 publish_message(
853 &state,
854 &BrokerMessage::Status {
855 agent_id: "feat-x".to_string(),
856 payload: StatusPayload {
857 status: "working".to_string(),
858 modified_files: vec!["src/lib.rs".to_string()],
859 message: None,
860 ..Default::default()
861 },
862 },
863 );
864 let snap = agent_status_snapshot(&state);
865 let rows = format_agent_rows(&snap, Instant::now());
866 let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
867 assert!(
868 row.status.contains("working") && row.status.contains("๐ต"),
869 "dashboard row must transition committed -> working, got {:?}",
870 row.status
871 );
872 }
873
874 #[test]
875 fn dashboard_row_stays_committed_when_ttl_zero() {
876 use crate::broker::BrokerState;
878 use crate::broker::delivery::{agent_status_snapshot, publish_message};
879 use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
880 use std::sync::Arc;
881
882 let state = Arc::new(BrokerState::new(None));
883 state.set_republish_working_ttl(Duration::ZERO);
884 publish_message(
885 &state,
886 &BrokerMessage::Artifact {
887 agent_id: "feat-y".to_string(),
888 payload: ArtifactPayload {
889 status: "committed".to_string(),
890 exports: vec![],
891 modified_files: vec![],
892 },
893 },
894 );
895 publish_message(
896 &state,
897 &BrokerMessage::Status {
898 agent_id: "feat-y".to_string(),
899 payload: StatusPayload {
900 status: "working".to_string(),
901 modified_files: vec!["src/lib.rs".to_string()],
902 message: None,
903 ..Default::default()
904 },
905 },
906 );
907 let snap = agent_status_snapshot(&state);
908 let rows = format_agent_rows(&snap, Instant::now());
909 let row = rows.iter().find(|r| r.agent_id == "feat-y").unwrap();
910 assert!(
911 row.status.contains("committed"),
912 "with TTL=0 the dashboard row must stay committed, got {:?}",
913 row.status
914 );
915 }
916
917 #[test]
922 fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
923 let now = Instant::now();
924 let agents = vec![AgentStatusEntry {
925 agent_id: "supervisor".to_string(),
926 cli: "claude".to_string(),
927 status: "feedback".to_string(),
928 last_seen: now,
929 last_seen_seconds: 0,
930 phase: Some("merging".to_string()),
931 }];
932 let rows = format_agent_rows(&agents, now);
933 assert_eq!(rows.len(), 1);
934 assert!(
935 rows[0].status.contains("merging"),
936 "expected phase 'merging' in status field; got {:?}",
937 rows[0].status,
938 );
939 assert!(
940 !rows[0].status.contains("feedback"),
941 "phase must replace status label, not append; got {:?}",
942 rows[0].status,
943 );
944 }
945
946 #[test]
947 fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
948 let now = Instant::now();
949 let agents = vec![AgentStatusEntry {
950 agent_id: "feat-broker".to_string(),
951 cli: "claude".to_string(),
952 status: "working".to_string(),
953 last_seen: now,
954 last_seen_seconds: 0,
955 phase: None,
956 }];
957 let rows = format_agent_rows(&agents, now);
958 assert!(
959 rows[0].status.contains("working"),
960 "expected 'working' in status field; got {:?}",
961 rows[0].status,
962 );
963 }
964
965 fn entry_with_phase(agent_id: &str, status: &str, phase: Option<&str>) -> AgentStatusEntry {
972 AgentStatusEntry {
973 agent_id: agent_id.to_string(),
974 cli: "claude".to_string(),
975 status: status.to_string(),
976 last_seen: Instant::now(),
977 last_seen_seconds: 0,
978 phase: phase.map(str::to_string),
979 }
980 }
981
982 #[test]
983 fn format_agent_rows_supervisor_shows_introspection_phase() {
984 let now = Instant::now();
986 let agents = vec![entry_with_phase("supervisor", "working", Some("audit"))];
987 let rows = format_agent_rows(&agents, now);
988 assert!(
989 rows[0].status.contains("audit"),
990 "supervisor row must surface the introspection phase; got {:?}",
991 rows[0].status,
992 );
993 }
994
995 #[test]
996 fn format_agent_rows_supervisor_falls_back_when_phase_absent() {
997 let now = Instant::now();
1000 let agents = vec![entry_with_phase("supervisor", "working", None)];
1001 let rows = format_agent_rows(&agents, now);
1002 assert!(
1003 rows[0].status.contains("working"),
1004 "without a phase the supervisor row renders the status label; got {:?}",
1005 rows[0].status,
1006 );
1007 }
1008
1009 #[test]
1010 fn format_agent_rows_non_supervisor_ignores_phase() {
1011 let now = Instant::now();
1014 let agents = vec![entry_with_phase("feat-auth", "working", Some("audit"))];
1015 let rows = format_agent_rows(&agents, now);
1016 assert!(
1017 rows[0].status.contains("working"),
1018 "a coding agent's phase must be ignored; got {:?}",
1019 rows[0].status,
1020 );
1021 assert!(
1022 !rows[0].status.contains("audit"),
1023 "the introspection phase must not leak onto a coding-agent row; got {:?}",
1024 rows[0].status,
1025 );
1026 }
1027
1028 #[test]
1029 fn format_agent_rows_non_supervisor_still_shows_stuck_on_prompt() {
1030 let now = Instant::now();
1034 let agents = vec![entry_with_phase(
1035 "feat-auth",
1036 "working",
1037 Some(STUCK_ON_PROMPT_PHASE),
1038 )];
1039 let rows = format_agent_rows(&agents, now);
1040 assert!(
1041 rows[0].status.contains(STUCK_ON_PROMPT_PHASE),
1042 "the supervisor-authored stuck-on-prompt alert must surface on the \
1043 coding-agent row; got {:?}",
1044 rows[0].status,
1045 );
1046 }
1047
1048 #[test]
1049 fn format_agent_rows_supervisor_phase_snapshot_layout() {
1050 let now = Instant::now();
1054 let with_phase = format_agent_rows(
1055 &[entry_with_phase("supervisor", "feedback", Some("merge"))],
1056 now,
1057 );
1058 assert_eq!(with_phase[0].status, "โช merge");
1059
1060 let without_phase =
1061 format_agent_rows(&[entry_with_phase("supervisor", "working", None)], now);
1062 assert_eq!(without_phase[0].status, "๐ต working");
1063 }
1064
1065 fn agent_row(id: &str) -> AgentRow {
1070 AgentRow {
1071 agent_id: id.to_string(),
1072 cli: "claude".to_string(),
1073 status: "๐ต working".to_string(),
1074 age: "0s ago".to_string(),
1075 }
1076 }
1077
1078 #[test]
1079 fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
1080 let rows = vec![
1081 agent_row("feat-broker"),
1082 agent_row("feat-dashboard"),
1083 agent_row("supervisor"),
1084 ];
1085 let arranged = arrange_with_supervisor_pinned(rows);
1086 assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
1087 assert!(
1088 matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
1089 "supervisor must be at row 0; got {:?}",
1090 arranged[0]
1091 );
1092 assert_eq!(
1093 arranged[1],
1094 AgentTableRow::Divider,
1095 "divider must immediately follow supervisor"
1096 );
1097 assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
1098 assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
1099 }
1100
1101 #[test]
1102 fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
1103 let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
1104 let arranged = arrange_with_supervisor_pinned(rows);
1105 assert_eq!(arranged.len(), 2);
1106 for row in &arranged {
1107 assert!(
1108 !matches!(row, AgentTableRow::Divider),
1109 "no divider when supervisor is absent; got {row:?}"
1110 );
1111 }
1112 assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
1113 assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
1114 }
1115
1116 #[test]
1117 fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
1118 let arranged = arrange_with_supervisor_pinned(Vec::new());
1119 assert!(arranged.is_empty());
1120 }
1121
1122 #[test]
1123 fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
1124 use ratatui::Terminal;
1125 use ratatui::backend::TestBackend;
1126
1127 let rows = vec![
1131 agent_row("feat-broker"),
1132 agent_row("feat-dashboard"),
1133 agent_row("supervisor"),
1134 ];
1135
1136 let backend = TestBackend::new(140, 30);
1137 let mut terminal = Terminal::new(backend).unwrap();
1138 terminal
1139 .draw(|f| draw_frame(f, &rows, "3 agents", &hidden_log(), default_panel_height()))
1140 .unwrap();
1141
1142 let buffer = terminal.backend().buffer().clone();
1145 let mut rendered = String::new();
1146 for y in 0..buffer.area.height {
1147 for x in 0..buffer.area.width {
1148 rendered.push_str(buffer[(x, y)].symbol());
1149 }
1150 rendered.push('\n');
1151 }
1152
1153 let pos_supervisor = rendered
1154 .find("supervisor")
1155 .expect("supervisor row should be in rendered frame");
1156 let pos_broker = rendered
1157 .find("feat-broker")
1158 .expect("feat-broker row should be in rendered frame");
1159 let pos_dashboard = rendered
1160 .find("feat-dashboard")
1161 .expect("feat-dashboard row should be in rendered frame");
1162 assert!(
1163 pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
1164 "supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
1165 );
1166
1167 let pos_divider = rendered[pos_supervisor..]
1170 .find('โ')
1171 .map(|p| pos_supervisor + p)
1172 .expect("divider row should contain horizontal-line characters");
1173 assert!(
1174 pos_divider > pos_supervisor && pos_divider < pos_broker,
1175 "divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
1176 );
1177 }
1178
1179 #[test]
1180 fn header_row_has_four_columns_and_no_summary() {
1181 use ratatui::Terminal;
1182 use ratatui::backend::TestBackend;
1183
1184 let rows = vec![agent_row("feat-broker")];
1188
1189 let backend = TestBackend::new(140, 30);
1190 let mut terminal = Terminal::new(backend).unwrap();
1191 terminal
1192 .draw(|f| draw_frame(f, &rows, "1 agent", &hidden_log(), default_panel_height()))
1193 .unwrap();
1194
1195 let buffer = terminal.backend().buffer().clone();
1196 let mut rendered = String::new();
1197 for y in 0..buffer.area.height {
1198 for x in 0..buffer.area.width {
1199 rendered.push_str(buffer[(x, y)].symbol());
1200 }
1201 rendered.push('\n');
1202 }
1203
1204 for label in ["Agent", "CLI", "Status", "Last Update"] {
1205 assert!(
1206 rendered.contains(label),
1207 "header must contain the {label:?} column label; got:\n{rendered}",
1208 );
1209 }
1210 assert!(
1211 !rendered.contains("Summary"),
1212 "header must NOT contain a 'Summary' column label; got:\n{rendered}",
1213 );
1214 }
1215
1216 #[test]
1221 fn format_status_line_mixed() {
1222 assert_eq!(
1223 format_status_line(4, 2, 1, 1, 0),
1224 "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1225 );
1226 }
1227
1228 #[test]
1229 fn format_status_line_all_done() {
1230 assert_eq!(
1231 format_status_line(3, 0, 3, 0, 0),
1232 "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1233 );
1234 }
1235
1236 #[test]
1237 fn format_status_line_zero_agents() {
1238 assert_eq!(
1239 format_status_line(0, 0, 0, 0, 0),
1240 "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1241 );
1242 }
1243
1244 #[test]
1245 fn format_status_line_with_committed() {
1246 assert_eq!(
1247 format_status_line(5, 2, 1, 1, 1),
1248 "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1249 );
1250 }
1251
1252 #[test]
1257 fn rendered_frame_contains_no_questions_or_reply_input() {
1258 use ratatui::Terminal;
1259 use ratatui::backend::TestBackend;
1260
1261 let backend = TestBackend::new(140, 30);
1262 let mut terminal = Terminal::new(backend).unwrap();
1263 terminal
1264 .draw(|f| draw_frame(f, &[], "0 agents", &hidden_log(), default_panel_height()))
1265 .unwrap();
1266
1267 let buffer = terminal.backend().buffer().clone();
1268 let mut rendered = String::new();
1269 for y in 0..buffer.area.height {
1270 for x in 0..buffer.area.width {
1271 rendered.push_str(buffer[(x, y)].symbol());
1272 }
1273 rendered.push('\n');
1274 }
1275
1276 assert!(
1277 !rendered.contains("Questions ("),
1278 "dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
1279 );
1280 assert!(
1281 !rendered.contains("Reply to"),
1282 "dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
1283 );
1284 }
1285
1286 #[test]
1295 fn tab_key_ignored_no_buffer() {
1296 assert!(
1301 !should_quit(KeyCode::Tab),
1302 "Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
1303 );
1304 }
1305
1306 #[test]
1307 fn printable_char_ignored_no_buffer() {
1308 assert!(
1311 !should_quit(KeyCode::Char('a')),
1312 "printable char 'a' must not quit and must not accumulate into any buffer",
1313 );
1314 assert!(
1315 !should_quit(KeyCode::Char(' ')),
1316 "space must not quit and must not accumulate into any buffer",
1317 );
1318 assert!(
1321 should_quit(KeyCode::Char('q')),
1322 "lowercase 'q' must quit the dashboard",
1323 );
1324 }
1325
1326 #[test]
1327 fn layout_collapses_without_message_log() {
1328 let constraints = build_layout_constraints(false, default_panel_height());
1333 assert_eq!(
1334 constraints.len(),
1335 3,
1336 "layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
1337 constraints.len(),
1338 );
1339
1340 let with_log = build_layout_constraints(true, default_panel_height());
1345 assert_eq!(
1346 with_log.len(),
1347 4,
1348 "layout with message log must be exactly 4 segments, got {} constraints",
1349 with_log.len(),
1350 );
1351
1352 assert_eq!(
1355 with_log[3],
1356 Constraint::Length(default_panel_height()),
1357 "the broker-log panel segment must be the configured height, not the old fixed 12",
1358 );
1359 }
1360
1361 #[test]
1362 fn visible_panel_default_height_exceeds_twelve() {
1363 let constraints = build_layout_constraints(true, default_panel_height());
1369 let panel = constraints[3];
1370 match panel {
1371 Constraint::Length(n) => assert!(
1372 n > 12,
1373 "default panel height must be strictly greater than 12, got {n}",
1374 ),
1375 other => panic!("panel segment must be a Length constraint, got {other:?}"),
1376 }
1377 }
1378
1379 #[test]
1380 fn configured_height_sets_panel_segment_length() {
1381 let constraints = build_layout_constraints(true, 24);
1384 assert_eq!(
1385 constraints[3],
1386 Constraint::Length(24),
1387 "configured height_lines must size the panel segment exactly",
1388 );
1389 }
1390
1391 #[test]
1392 fn agent_table_keeps_positive_minimum() {
1393 let constraints = build_layout_constraints(true, default_panel_height());
1398 match constraints[1] {
1399 Constraint::Min(m) => assert!(
1400 m > 0,
1401 "agent-table segment must keep a positive minimum height, got Min({m})",
1402 ),
1403 other => panic!("agent-table segment must be a Min constraint, got {other:?}"),
1404 }
1405 }
1406
1407 use ratatui::Terminal;
1412 use ratatui::backend::TestBackend;
1413 use ratatui::buffer::Buffer;
1414
1415 fn draw_to_buffer(rows: &[AgentRow], status: &str, log: &broker_log::BrokerLog) -> Buffer {
1416 let backend = TestBackend::new(120, 30);
1417 let mut terminal = Terminal::new(backend).unwrap();
1418 terminal
1419 .draw(|f| draw_frame(f, rows, status, log, default_panel_height()))
1420 .unwrap();
1421 terminal.backend().buffer().clone()
1422 }
1423
1424 fn sample_log_entry(seq: u64) -> broker_log::LogEntry {
1425 (
1426 seq,
1427 std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1428 crate::broker::messages::BrokerMessage::Status {
1429 agent_id: "feat-auth".to_string(),
1430 payload: crate::broker::messages::StatusPayload {
1431 status: "working".to_string(),
1432 modified_files: vec![],
1433 message: Some("rebasing onto main".to_string()),
1434 ..Default::default()
1435 },
1436 },
1437 )
1438 }
1439
1440 fn log_entry_with_message(seq: u64, msg: &str) -> broker_log::LogEntry {
1441 (
1442 seq,
1443 std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1444 crate::broker::messages::BrokerMessage::Status {
1445 agent_id: "feat-auth".to_string(),
1446 payload: crate::broker::messages::StatusPayload {
1447 status: "working".to_string(),
1448 modified_files: vec![],
1449 message: Some(msg.to_string()),
1450 ..Default::default()
1451 },
1452 },
1453 )
1454 }
1455
1456 fn buffer_text(buffer: &Buffer) -> String {
1457 let mut rendered = String::new();
1458 for y in 0..buffer.area.height {
1459 for x in 0..buffer.area.width {
1460 rendered.push_str(buffer[(x, y)].symbol());
1461 }
1462 rendered.push('\n');
1463 }
1464 rendered
1465 }
1466
1467 #[test]
1468 fn scrolling_reaches_messages_beyond_the_first_screen() {
1469 let rows = vec![agent_row("feat-auth")];
1473 let mut log = BrokerLog::new(500, true);
1474 for i in 0..40 {
1476 log.push(log_entry_with_message(i, &format!("scroll-msg-{i:02}")));
1477 }
1478 let at_top = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1480 assert!(
1481 !at_top.contains("scroll-msg-00"),
1482 "precondition: the oldest message should be off-screen before scrolling; got:\n{at_top}"
1483 );
1484 for _ in 0..39 {
1486 log.select_down();
1487 }
1488 let scrolled = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1489 assert!(
1490 scrolled.contains("scroll-msg-00"),
1491 "scrolling to the bottom must reveal the oldest message; got:\n{scrolled}"
1492 );
1493 }
1494
1495 #[test]
1496 fn hidden_panel_status_line_shows_restore_hint() {
1497 let rows = vec![agent_row("feat-auth")];
1498 let log = BrokerLog::new(500, false); let rendered = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1500 assert!(
1501 rendered.contains("press l to show"),
1502 "hidden panel must hint the `l` toggle in the status line; got:\n{rendered}"
1503 );
1504 assert!(
1505 !rendered.contains("Broker log ("),
1506 "hidden panel must not render the panel title region; got:\n{rendered}"
1507 );
1508 }
1509
1510 #[test]
1511 fn hidden_panel_layout_is_byte_equivalent_regardless_of_buffer_contents() {
1512 let rows = vec![agent_row("feat-auth"), agent_row("feat-db")];
1518
1519 let empty = BrokerLog::new(500, false);
1520 let mut full = BrokerLog::new(500, false);
1521 for i in 1..=50 {
1522 full.push(sample_log_entry(i));
1523 }
1524
1525 let buf_empty = draw_to_buffer(&rows, "2 agents", &empty);
1526 let buf_full = draw_to_buffer(&rows, "2 agents", &full);
1527 assert_eq!(
1528 buf_empty, buf_full,
1529 "a hidden Broker log must not alter the rendered frame regardless of buffered messages",
1530 );
1531 }
1532
1533 #[test]
1534 fn visible_panel_renders_broker_log_region() {
1535 let rows = vec![agent_row("feat-auth")];
1538 let mut log = BrokerLog::new(500, true);
1539 log.push(sample_log_entry(1));
1540
1541 let buffer = draw_to_buffer(&rows, "1 agents", &log);
1542 let mut rendered = String::new();
1543 for y in 0..buffer.area.height {
1544 for x in 0..buffer.area.width {
1545 rendered.push_str(buffer[(x, y)].symbol());
1546 }
1547 rendered.push('\n');
1548 }
1549 assert!(
1550 rendered.contains("Broker log"),
1551 "visible panel must render its titled region; got:\n{rendered}",
1552 );
1553 assert!(
1554 rendered.contains("rebasing onto main"),
1555 "visible panel must render the buffered message summary; got:\n{rendered}",
1556 );
1557 }
1558
1559 #[test]
1560 fn toggling_visibility_returns_to_hidden_layout() {
1561 let rows = vec![agent_row("feat-auth")];
1564 let mut log = BrokerLog::new(500, false);
1565 log.push(sample_log_entry(1));
1566 let hidden_before = draw_to_buffer(&rows, "1 agents", &log);
1567
1568 broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(log.visible);
1570 broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(!log.visible);
1572 let hidden_after = draw_to_buffer(&rows, "1 agents", &log);
1573
1574 assert_eq!(
1575 hidden_before, hidden_after,
1576 "hiding the panel again must reproduce the hidden layout exactly",
1577 );
1578 }
1579}