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 pub summary: String,
72}
73
74pub fn status_symbol(status: &str) -> &'static str {
85 match status {
86 "working" => "๐ต",
87 "done" | "verified" => "๐ข",
88 "committed" => "๐ฃ",
89 "blocked" => "๐ก",
90 _ => "โช",
91 }
92}
93
94pub fn format_age(elapsed: Duration) -> String {
100 let secs = elapsed.as_secs();
101 if secs < 60 {
102 format!("{secs}s ago")
103 } else if secs < 3600 {
104 let mins = secs / 60;
105 format!("{mins}m ago")
106 } else {
107 let hours = secs / 3600;
108 let mins = (secs % 3600) / 60;
109 format!("{hours}h {mins}m ago")
110 }
111}
112
113pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
131 agents
132 .iter()
133 .map(|agent| {
134 let elapsed = now.saturating_duration_since(agent.last_seen);
135 let honour_phase = agent.agent_id == SUPERVISOR_AGENT_ID
139 || agent.phase.as_deref() == Some(STUCK_ON_PROMPT_PHASE);
140 let label = match agent.phase.as_deref() {
141 Some(phase) if honour_phase => phase,
142 _ => &agent.status,
143 };
144 let symbol = status_symbol(label);
145 let cli = if agent.cli.trim().is_empty() {
146 UNKNOWN_CLI.to_string()
147 } else {
148 agent.cli.clone()
149 };
150 AgentRow {
151 agent_id: agent.agent_id.clone(),
152 cli,
153 status: format!("{symbol} {label}"),
154 age: format_age(elapsed),
155 summary: agent.summary.clone(),
156 }
157 })
158 .collect()
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum AgentTableRow {
166 Agent(AgentRow),
168 Divider,
170}
171
172pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
181 let mut supervisor: Option<AgentRow> = None;
182 let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
183 for row in rows {
184 if row.agent_id == "supervisor" {
185 supervisor = Some(row);
186 } else {
187 coding.push(row);
188 }
189 }
190
191 let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
192 if let Some(sup) = supervisor {
193 out.push(AgentTableRow::Agent(sup));
194 out.push(AgentTableRow::Divider);
195 }
196 out.extend(coding.into_iter().map(AgentTableRow::Agent));
197 out
198}
199
200pub fn format_status_line(
204 total: usize,
205 working: usize,
206 done: usize,
207 blocked: usize,
208 committed: usize,
209) -> String {
210 format!(
211 "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
212 )
213}
214
215struct TerminalGuard {
222 terminal: Terminal<CrosstermBackend<Stdout>>,
223}
224
225impl Drop for TerminalGuard {
226 fn drop(&mut self) {
227 let _ = terminal::disable_raw_mode();
228 let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
229 let _ = self.terminal.show_cursor();
230 }
231}
232
233fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
235 terminal::enable_raw_mode()
236 .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
237 crossterm::execute!(io::stdout(), EnterAlternateScreen)
238 .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
239 Terminal::new(CrosstermBackend::new(io::stdout()))
240 .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
241}
242
243fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
245 terminal::disable_raw_mode()
246 .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
247 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
248 .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
249 terminal
250 .show_cursor()
251 .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
252}
253
254pub fn render_dashboard(
264 frame: &mut Frame,
265 rows: &[AgentRow],
266 status_line: &str,
267 broker_log: &BrokerLog,
268) {
269 draw_frame(frame, rows, status_line, broker_log);
270}
271
272pub(crate) fn build_layout_constraints(show_panel: bool) -> Vec<Constraint> {
280 if show_panel {
281 vec![
282 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(12), ]
287 } else {
288 vec![
289 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
293 }
294}
295
296pub(crate) fn should_quit(code: KeyCode) -> bool {
304 matches!(code, KeyCode::Char('q'))
305}
306
307fn draw_frame(frame: &mut Frame, rows: &[AgentRow], status_line: &str, broker_log: &BrokerLog) {
309 let layout_constraints = build_layout_constraints(broker_log.visible);
316
317 let chunks = Layout::vertical(layout_constraints).split(frame.area());
318
319 let title =
320 Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
321 frame.render_widget(title, chunks[0]);
322
323 if rows.is_empty() {
324 let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
325 frame.render_widget(empty, chunks[1]);
326 } else {
327 let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
328 .style(Style::default().add_modifier(Modifier::BOLD));
329 let arranged = arrange_with_supervisor_pinned(rows.to_vec());
335 let divider_segment = "โ".repeat(20);
336 let table_rows: Vec<Row> = arranged
337 .iter()
338 .map(|entry| match entry {
339 AgentTableRow::Agent(r) => Row::new(vec![
340 r.agent_id.clone(),
341 r.cli.clone(),
342 r.status.clone(),
343 r.age.clone(),
344 r.summary.clone(),
345 ]),
346 AgentTableRow::Divider => Row::new(vec![
347 divider_segment.clone(),
348 divider_segment.clone(),
349 divider_segment.clone(),
350 divider_segment.clone(),
351 divider_segment.clone(),
352 ])
353 .style(Style::default().add_modifier(Modifier::DIM)),
354 })
355 .collect();
356 let widths = [
357 Constraint::Min(15),
358 Constraint::Length(10),
359 Constraint::Length(15),
360 Constraint::Length(10),
361 Constraint::Min(20),
362 ];
363 let table = Table::new(table_rows, widths).header(header);
364 frame.render_widget(table, chunks[1]);
365 }
366
367 let status_text = if broker_log.visible {
372 status_line.to_string()
373 } else {
374 format!("{status_line} ยท broker log hidden โ press l to show")
375 };
376 let status = Paragraph::new(status_text);
377 frame.render_widget(status, chunks[2]);
378
379 if broker_log.visible {
383 broker_log::render(frame, chunks[3], broker_log);
384 }
385}
386
387pub fn run_dashboard(
403 state: &Arc<BrokerState>,
404 broker_handle: BrokerHandle,
405 shutdown: &std::sync::atomic::AtomicBool,
406) -> Result<(), PawError> {
407 run_dashboard_with_panes(
408 state,
409 broker_handle,
410 shutdown,
411 &HashMap::new(),
412 None,
413 500,
414 false,
415 )
416}
417
418pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
427 state: &Arc<BrokerState>,
428 broker_handle: BrokerHandle,
429 shutdown: &std::sync::atomic::AtomicBool,
430 _pane_map: &HashMap<String, usize, S>,
431 _session_name: Option<&str>,
432 max_messages: usize,
433 default_visible: bool,
434) -> Result<(), PawError> {
435 let _broker_handle = broker_handle;
436 let original_hook = std::panic::take_hook();
438 std::panic::set_hook(Box::new(move |info| {
439 let _ = terminal::disable_raw_mode();
440 let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
441 original_hook(info);
442 }));
443
444 let terminal = setup_terminal()?;
445 let mut guard = TerminalGuard { terminal };
446
447 let mut broker_log = BrokerLog::new(max_messages, default_visible);
452
453 loop {
454 if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
456 break;
457 }
458
459 for _ in 0..32 {
463 if !event::poll(Duration::ZERO)
464 .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
465 {
466 break;
467 }
468 let ev = event::read()
469 .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
470 if let Event::Key(key) = ev
471 && key.kind == KeyEventKind::Press
472 {
473 if broker_log::handle_key(&mut broker_log, key.code) == LogKeyAction::Ignored
477 && should_quit(key.code)
478 {
479 return restore_terminal(&mut guard.terminal);
480 }
481 }
482 }
483
484 let agents = delivery::agent_status_snapshot(state);
485 let now = Instant::now();
486 let rows = format_agent_rows(&agents, now);
487 let working = agents.iter().filter(|a| a.status == "working").count();
488 let done = agents
489 .iter()
490 .filter(|a| a.status == "done" || a.status == "verified")
491 .count();
492 let blocked = agents.iter().filter(|a| a.status == "blocked").count();
493 let committed = agents.iter().filter(|a| a.status == "committed").count();
494 let status_line = format_status_line(agents.len(), working, done, blocked, committed);
495
496 broker_log.ingest(delivery::full_log(state, broker_log.last_seq()));
500
501 guard
502 .terminal
503 .draw(|f| {
504 draw_frame(f, &rows, &status_line, &broker_log);
505 })
506 .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
507
508 thread::sleep(TICK_INTERVAL);
509 }
510
511 restore_terminal(&mut guard.terminal)?;
513 Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn hidden_log() -> BrokerLog {
524 BrokerLog::new(500, false)
525 }
526
527 #[test]
532 fn status_symbol_working() {
533 assert_eq!(status_symbol("working"), "๐ต");
534 }
535
536 #[test]
537 fn status_symbol_done() {
538 assert_eq!(status_symbol("done"), "๐ข");
539 }
540
541 #[test]
542 fn status_symbol_verified() {
543 assert_eq!(status_symbol("verified"), "๐ข");
544 }
545
546 #[test]
547 fn status_symbol_blocked() {
548 assert_eq!(status_symbol("blocked"), "๐ก");
549 }
550
551 #[test]
552 fn status_symbol_committed() {
553 assert_eq!(status_symbol("committed"), "๐ฃ");
554 }
555
556 #[test]
557 fn status_symbol_idle() {
558 assert_eq!(status_symbol("idle"), "โช");
559 }
560
561 #[test]
562 fn status_symbol_unknown() {
563 assert_eq!(status_symbol("something-unexpected"), "โช");
564 }
565
566 #[test]
571 fn format_age_zero_seconds() {
572 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
573 }
574
575 #[test]
576 fn format_age_thirty_seconds() {
577 assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
578 }
579
580 #[test]
581 fn format_age_three_minutes() {
582 assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
583 }
584
585 #[test]
586 fn format_age_one_hour_exact() {
587 assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
588 }
589
590 #[test]
591 fn format_age_one_hour_fifteen_minutes() {
592 assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
593 }
594
595 #[test]
600 fn format_agent_rows_three_agents() {
601 let now = Instant::now();
602 let agents = vec![
603 AgentStatusEntry {
604 agent_id: "feat-a".to_string(),
605 cli: "claude".to_string(),
606 status: "working".to_string(),
607 last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
608 last_seen_seconds: 10,
609 summary: "msg a".to_string(),
610 phase: None,
611 },
612 AgentStatusEntry {
613 agent_id: "feat-b".to_string(),
614 cli: "cursor".to_string(),
615 status: "done".to_string(),
616 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
617 last_seen_seconds: 60,
618 summary: "msg b".to_string(),
619 phase: None,
620 },
621 AgentStatusEntry {
622 agent_id: "feat-c".to_string(),
623 cli: "claude".to_string(),
624 status: "blocked".to_string(),
625 last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
626 last_seen_seconds: 300,
627 summary: String::new(),
628 phase: None,
629 },
630 ];
631 let rows = format_agent_rows(&agents, now);
632 assert_eq!(rows.len(), 3);
633 assert_eq!(rows[0].agent_id, "feat-a");
634 assert_eq!(rows[1].agent_id, "feat-b");
635 assert_eq!(rows[2].agent_id, "feat-c");
636 }
637
638 #[test]
639 fn format_agent_rows_single_done_three_minutes() {
640 let now = Instant::now();
641 let agents = vec![AgentStatusEntry {
642 agent_id: "feat-errors".to_string(),
643 cli: "claude".to_string(),
644 status: "done".to_string(),
645 last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
646 last_seen_seconds: 180,
647 summary: "finished".to_string(),
648 phase: None,
649 }];
650 let rows = format_agent_rows(&agents, now);
651 assert_eq!(rows.len(), 1);
652 assert_eq!(rows[0].agent_id, "feat-errors");
653 assert_eq!(rows[0].age, "3m ago");
654 assert!(rows[0].status.contains("done"));
655 }
656
657 #[test]
658 fn format_agent_rows_with_committed_status() {
659 let now = Instant::now();
660 let agents = vec![
661 AgentStatusEntry {
662 agent_id: "feat-committed".to_string(),
663 cli: "claude".to_string(),
664 status: "committed".to_string(),
665 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
666 last_seen_seconds: 60,
667 summary: "changes committed".to_string(),
668 phase: None,
669 },
670 AgentStatusEntry {
671 agent_id: "feat-working".to_string(),
672 cli: "cursor".to_string(),
673 status: "working".to_string(),
674 last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
675 last_seen_seconds: 30,
676 summary: "in progress".to_string(),
677 phase: None,
678 },
679 ];
680 let rows = format_agent_rows(&agents, now);
681 assert_eq!(rows.len(), 2);
682
683 let committed_row = rows
685 .iter()
686 .find(|r| r.agent_id == "feat-committed")
687 .unwrap();
688 assert!(committed_row.status.contains("๐ฃ"));
689 assert!(committed_row.status.contains("committed"));
690
691 let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
693 assert!(working_row.status.contains("๐ต"));
694 assert!(working_row.status.contains("working"));
695 }
696
697 #[test]
698 fn format_agent_rows_empty_input() {
699 let rows = format_agent_rows(&[], Instant::now());
700 assert!(rows.is_empty());
701 }
702
703 #[test]
708 fn format_agent_rows_populates_cli_for_every_agent() {
709 let now = Instant::now();
712 let agents = vec![
713 AgentStatusEntry {
714 agent_id: "supervisor".to_string(),
715 cli: "claude-oss".to_string(),
716 status: "working".to_string(),
717 last_seen: now,
718 last_seen_seconds: 0,
719 summary: String::new(),
720 phase: Some("watching".to_string()),
721 },
722 AgentStatusEntry {
723 agent_id: "feat-a".to_string(),
724 cli: "claude-oss".to_string(),
725 status: "working".to_string(),
726 last_seen: now,
727 last_seen_seconds: 0,
728 summary: String::new(),
729 phase: None,
730 },
731 AgentStatusEntry {
732 agent_id: "feat-b".to_string(),
733 cli: "claude-oss".to_string(),
734 status: "working".to_string(),
735 last_seen: now,
736 last_seen_seconds: 0,
737 summary: String::new(),
738 phase: None,
739 },
740 ];
741 let rows = format_agent_rows(&agents, now);
742 assert_eq!(rows.len(), 3);
743 for row in &rows {
744 assert_eq!(
745 row.cli, "claude-oss",
746 "every agent row must render its CLI, not just the supervisor: {row:?}",
747 );
748 }
749 }
750
751 #[test]
752 fn format_agent_rows_shows_placeholder_for_unresolved_cli() {
753 let now = Instant::now();
756 let agents = vec![AgentStatusEntry {
757 agent_id: "feat-mystery".to_string(),
758 cli: String::new(),
759 status: "working".to_string(),
760 last_seen: now,
761 last_seen_seconds: 0,
762 summary: String::new(),
763 phase: None,
764 }];
765 let rows = format_agent_rows(&agents, now);
766 assert_eq!(rows.len(), 1);
767 assert_eq!(
768 rows[0].cli, UNKNOWN_CLI,
769 "blank CLI must render the documented placeholder, not an empty string",
770 );
771 assert!(!rows[0].cli.is_empty());
772 }
773
774 #[test]
779 fn dashboard_row_transitions_committed_to_working_within_ttl() {
780 use crate::broker::BrokerState;
781 use crate::broker::delivery::{agent_status_snapshot, publish_message};
782 use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
783 use std::sync::Arc;
784
785 let state = Arc::new(BrokerState::new(None)); publish_message(
787 &state,
788 &BrokerMessage::Artifact {
789 agent_id: "feat-x".to_string(),
790 payload: ArtifactPayload {
791 status: "committed".to_string(),
792 exports: vec![],
793 modified_files: vec![],
794 },
795 },
796 );
797 let snap = agent_status_snapshot(&state);
799 let rows = format_agent_rows(&snap, Instant::now());
800 let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
801 assert!(row.status.contains("committed"), "should start committed");
802
803 publish_message(
805 &state,
806 &BrokerMessage::Status {
807 agent_id: "feat-x".to_string(),
808 payload: StatusPayload {
809 status: "working".to_string(),
810 modified_files: vec!["src/lib.rs".to_string()],
811 message: None,
812 ..Default::default()
813 },
814 },
815 );
816 let snap = agent_status_snapshot(&state);
817 let rows = format_agent_rows(&snap, Instant::now());
818 let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
819 assert!(
820 row.status.contains("working") && row.status.contains("๐ต"),
821 "dashboard row must transition committed -> working, got {:?}",
822 row.status
823 );
824 }
825
826 #[test]
827 fn dashboard_row_stays_committed_when_ttl_zero() {
828 use crate::broker::BrokerState;
830 use crate::broker::delivery::{agent_status_snapshot, publish_message};
831 use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
832 use std::sync::Arc;
833
834 let state = Arc::new(BrokerState::new(None));
835 state.set_republish_working_ttl(Duration::ZERO);
836 publish_message(
837 &state,
838 &BrokerMessage::Artifact {
839 agent_id: "feat-y".to_string(),
840 payload: ArtifactPayload {
841 status: "committed".to_string(),
842 exports: vec![],
843 modified_files: vec![],
844 },
845 },
846 );
847 publish_message(
848 &state,
849 &BrokerMessage::Status {
850 agent_id: "feat-y".to_string(),
851 payload: StatusPayload {
852 status: "working".to_string(),
853 modified_files: vec!["src/lib.rs".to_string()],
854 message: None,
855 ..Default::default()
856 },
857 },
858 );
859 let snap = agent_status_snapshot(&state);
860 let rows = format_agent_rows(&snap, Instant::now());
861 let row = rows.iter().find(|r| r.agent_id == "feat-y").unwrap();
862 assert!(
863 row.status.contains("committed"),
864 "with TTL=0 the dashboard row must stay committed, got {:?}",
865 row.status
866 );
867 }
868
869 #[test]
874 fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
875 let now = Instant::now();
876 let agents = vec![AgentStatusEntry {
877 agent_id: "supervisor".to_string(),
878 cli: "claude".to_string(),
879 status: "feedback".to_string(),
880 last_seen: now,
881 last_seen_seconds: 0,
882 summary: String::new(),
883 phase: Some("merging".to_string()),
884 }];
885 let rows = format_agent_rows(&agents, now);
886 assert_eq!(rows.len(), 1);
887 assert!(
888 rows[0].status.contains("merging"),
889 "expected phase 'merging' in status field; got {:?}",
890 rows[0].status,
891 );
892 assert!(
893 !rows[0].status.contains("feedback"),
894 "phase must replace status label, not append; got {:?}",
895 rows[0].status,
896 );
897 }
898
899 #[test]
900 fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
901 let now = Instant::now();
902 let agents = vec![AgentStatusEntry {
903 agent_id: "feat-broker".to_string(),
904 cli: "claude".to_string(),
905 status: "working".to_string(),
906 last_seen: now,
907 last_seen_seconds: 0,
908 summary: String::new(),
909 phase: None,
910 }];
911 let rows = format_agent_rows(&agents, now);
912 assert!(
913 rows[0].status.contains("working"),
914 "expected 'working' in status field; got {:?}",
915 rows[0].status,
916 );
917 }
918
919 fn entry_with_phase(agent_id: &str, status: &str, phase: Option<&str>) -> AgentStatusEntry {
926 AgentStatusEntry {
927 agent_id: agent_id.to_string(),
928 cli: "claude".to_string(),
929 status: status.to_string(),
930 last_seen: Instant::now(),
931 last_seen_seconds: 0,
932 summary: "summary text".to_string(),
933 phase: phase.map(str::to_string),
934 }
935 }
936
937 #[test]
938 fn format_agent_rows_supervisor_shows_introspection_phase() {
939 let now = Instant::now();
941 let agents = vec![entry_with_phase("supervisor", "working", Some("audit"))];
942 let rows = format_agent_rows(&agents, now);
943 assert!(
944 rows[0].status.contains("audit"),
945 "supervisor row must surface the introspection phase; got {:?}",
946 rows[0].status,
947 );
948 assert_eq!(
949 rows[0].summary, "summary text",
950 "the summary is preserved alongside the phase",
951 );
952 }
953
954 #[test]
955 fn format_agent_rows_supervisor_falls_back_when_phase_absent() {
956 let now = Instant::now();
959 let agents = vec![entry_with_phase("supervisor", "working", None)];
960 let rows = format_agent_rows(&agents, now);
961 assert!(
962 rows[0].status.contains("working"),
963 "without a phase the supervisor row renders the status label; got {:?}",
964 rows[0].status,
965 );
966 }
967
968 #[test]
969 fn format_agent_rows_non_supervisor_ignores_phase() {
970 let now = Instant::now();
973 let agents = vec![entry_with_phase("feat-auth", "working", Some("audit"))];
974 let rows = format_agent_rows(&agents, now);
975 assert!(
976 rows[0].status.contains("working"),
977 "a coding agent's phase must be ignored; got {:?}",
978 rows[0].status,
979 );
980 assert!(
981 !rows[0].status.contains("audit"),
982 "the introspection phase must not leak onto a coding-agent row; got {:?}",
983 rows[0].status,
984 );
985 }
986
987 #[test]
988 fn format_agent_rows_non_supervisor_still_shows_stuck_on_prompt() {
989 let now = Instant::now();
993 let agents = vec![entry_with_phase(
994 "feat-auth",
995 "working",
996 Some(STUCK_ON_PROMPT_PHASE),
997 )];
998 let rows = format_agent_rows(&agents, now);
999 assert!(
1000 rows[0].status.contains(STUCK_ON_PROMPT_PHASE),
1001 "the supervisor-authored stuck-on-prompt alert must surface on the \
1002 coding-agent row; got {:?}",
1003 rows[0].status,
1004 );
1005 }
1006
1007 #[test]
1008 fn format_agent_rows_supervisor_phase_snapshot_layout() {
1009 let now = Instant::now();
1013 let with_phase = format_agent_rows(
1014 &[entry_with_phase("supervisor", "feedback", Some("merge"))],
1015 now,
1016 );
1017 assert_eq!(with_phase[0].status, "โช merge");
1018
1019 let without_phase =
1020 format_agent_rows(&[entry_with_phase("supervisor", "working", None)], now);
1021 assert_eq!(without_phase[0].status, "๐ต working");
1022 }
1023
1024 fn agent_row(id: &str) -> AgentRow {
1029 AgentRow {
1030 agent_id: id.to_string(),
1031 cli: "claude".to_string(),
1032 status: "๐ต working".to_string(),
1033 age: "0s ago".to_string(),
1034 summary: String::new(),
1035 }
1036 }
1037
1038 #[test]
1039 fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
1040 let rows = vec![
1041 agent_row("feat-broker"),
1042 agent_row("feat-dashboard"),
1043 agent_row("supervisor"),
1044 ];
1045 let arranged = arrange_with_supervisor_pinned(rows);
1046 assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
1047 assert!(
1048 matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
1049 "supervisor must be at row 0; got {:?}",
1050 arranged[0]
1051 );
1052 assert_eq!(
1053 arranged[1],
1054 AgentTableRow::Divider,
1055 "divider must immediately follow supervisor"
1056 );
1057 assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
1058 assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
1059 }
1060
1061 #[test]
1062 fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
1063 let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
1064 let arranged = arrange_with_supervisor_pinned(rows);
1065 assert_eq!(arranged.len(), 2);
1066 for row in &arranged {
1067 assert!(
1068 !matches!(row, AgentTableRow::Divider),
1069 "no divider when supervisor is absent; got {row:?}"
1070 );
1071 }
1072 assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
1073 assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
1074 }
1075
1076 #[test]
1077 fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
1078 let arranged = arrange_with_supervisor_pinned(Vec::new());
1079 assert!(arranged.is_empty());
1080 }
1081
1082 #[test]
1083 fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
1084 use ratatui::Terminal;
1085 use ratatui::backend::TestBackend;
1086
1087 let rows = vec![
1091 agent_row("feat-broker"),
1092 agent_row("feat-dashboard"),
1093 agent_row("supervisor"),
1094 ];
1095
1096 let backend = TestBackend::new(140, 30);
1097 let mut terminal = Terminal::new(backend).unwrap();
1098 terminal
1099 .draw(|f| draw_frame(f, &rows, "3 agents", &hidden_log()))
1100 .unwrap();
1101
1102 let buffer = terminal.backend().buffer().clone();
1105 let mut rendered = String::new();
1106 for y in 0..buffer.area.height {
1107 for x in 0..buffer.area.width {
1108 rendered.push_str(buffer[(x, y)].symbol());
1109 }
1110 rendered.push('\n');
1111 }
1112
1113 let pos_supervisor = rendered
1114 .find("supervisor")
1115 .expect("supervisor row should be in rendered frame");
1116 let pos_broker = rendered
1117 .find("feat-broker")
1118 .expect("feat-broker row should be in rendered frame");
1119 let pos_dashboard = rendered
1120 .find("feat-dashboard")
1121 .expect("feat-dashboard row should be in rendered frame");
1122 assert!(
1123 pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
1124 "supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
1125 );
1126
1127 let pos_divider = rendered[pos_supervisor..]
1130 .find('โ')
1131 .map(|p| pos_supervisor + p)
1132 .expect("divider row should contain horizontal-line characters");
1133 assert!(
1134 pos_divider > pos_supervisor && pos_divider < pos_broker,
1135 "divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
1136 );
1137 }
1138
1139 #[test]
1144 fn format_status_line_mixed() {
1145 assert_eq!(
1146 format_status_line(4, 2, 1, 1, 0),
1147 "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1148 );
1149 }
1150
1151 #[test]
1152 fn format_status_line_all_done() {
1153 assert_eq!(
1154 format_status_line(3, 0, 3, 0, 0),
1155 "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1156 );
1157 }
1158
1159 #[test]
1160 fn format_status_line_zero_agents() {
1161 assert_eq!(
1162 format_status_line(0, 0, 0, 0, 0),
1163 "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1164 );
1165 }
1166
1167 #[test]
1168 fn format_status_line_with_committed() {
1169 assert_eq!(
1170 format_status_line(5, 2, 1, 1, 1),
1171 "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1172 );
1173 }
1174
1175 #[test]
1180 fn rendered_frame_contains_no_questions_or_reply_input() {
1181 use ratatui::Terminal;
1182 use ratatui::backend::TestBackend;
1183
1184 let backend = TestBackend::new(140, 30);
1185 let mut terminal = Terminal::new(backend).unwrap();
1186 terminal
1187 .draw(|f| draw_frame(f, &[], "0 agents", &hidden_log()))
1188 .unwrap();
1189
1190 let buffer = terminal.backend().buffer().clone();
1191 let mut rendered = String::new();
1192 for y in 0..buffer.area.height {
1193 for x in 0..buffer.area.width {
1194 rendered.push_str(buffer[(x, y)].symbol());
1195 }
1196 rendered.push('\n');
1197 }
1198
1199 assert!(
1200 !rendered.contains("Questions ("),
1201 "dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
1202 );
1203 assert!(
1204 !rendered.contains("Reply to"),
1205 "dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
1206 );
1207 }
1208
1209 #[test]
1218 fn tab_key_ignored_no_buffer() {
1219 assert!(
1224 !should_quit(KeyCode::Tab),
1225 "Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
1226 );
1227 }
1228
1229 #[test]
1230 fn printable_char_ignored_no_buffer() {
1231 assert!(
1234 !should_quit(KeyCode::Char('a')),
1235 "printable char 'a' must not quit and must not accumulate into any buffer",
1236 );
1237 assert!(
1238 !should_quit(KeyCode::Char(' ')),
1239 "space must not quit and must not accumulate into any buffer",
1240 );
1241 assert!(
1244 should_quit(KeyCode::Char('q')),
1245 "lowercase 'q' must quit the dashboard",
1246 );
1247 }
1248
1249 #[test]
1250 fn layout_collapses_without_message_log() {
1251 let constraints = build_layout_constraints(false);
1256 assert_eq!(
1257 constraints.len(),
1258 3,
1259 "layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
1260 constraints.len(),
1261 );
1262
1263 let with_log = build_layout_constraints(true);
1268 assert_eq!(
1269 with_log.len(),
1270 4,
1271 "layout with message log must be exactly 4 segments, got {} constraints",
1272 with_log.len(),
1273 );
1274 }
1275
1276 use ratatui::Terminal;
1281 use ratatui::backend::TestBackend;
1282 use ratatui::buffer::Buffer;
1283
1284 fn draw_to_buffer(rows: &[AgentRow], status: &str, log: &broker_log::BrokerLog) -> Buffer {
1285 let backend = TestBackend::new(120, 30);
1286 let mut terminal = Terminal::new(backend).unwrap();
1287 terminal.draw(|f| draw_frame(f, rows, status, log)).unwrap();
1288 terminal.backend().buffer().clone()
1289 }
1290
1291 fn sample_log_entry(seq: u64) -> broker_log::LogEntry {
1292 (
1293 seq,
1294 std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1295 crate::broker::messages::BrokerMessage::Status {
1296 agent_id: "feat-auth".to_string(),
1297 payload: crate::broker::messages::StatusPayload {
1298 status: "working".to_string(),
1299 modified_files: vec![],
1300 message: Some("rebasing onto main".to_string()),
1301 ..Default::default()
1302 },
1303 },
1304 )
1305 }
1306
1307 fn log_entry_with_message(seq: u64, msg: &str) -> broker_log::LogEntry {
1308 (
1309 seq,
1310 std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1311 crate::broker::messages::BrokerMessage::Status {
1312 agent_id: "feat-auth".to_string(),
1313 payload: crate::broker::messages::StatusPayload {
1314 status: "working".to_string(),
1315 modified_files: vec![],
1316 message: Some(msg.to_string()),
1317 ..Default::default()
1318 },
1319 },
1320 )
1321 }
1322
1323 fn buffer_text(buffer: &Buffer) -> String {
1324 let mut rendered = String::new();
1325 for y in 0..buffer.area.height {
1326 for x in 0..buffer.area.width {
1327 rendered.push_str(buffer[(x, y)].symbol());
1328 }
1329 rendered.push('\n');
1330 }
1331 rendered
1332 }
1333
1334 #[test]
1335 fn scrolling_reaches_messages_beyond_the_first_screen() {
1336 let rows = vec![agent_row("feat-auth")];
1340 let mut log = BrokerLog::new(500, true);
1341 for i in 0..40 {
1343 log.push(log_entry_with_message(i, &format!("scroll-msg-{i:02}")));
1344 }
1345 let at_top = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1347 assert!(
1348 !at_top.contains("scroll-msg-00"),
1349 "precondition: the oldest message should be off-screen before scrolling; got:\n{at_top}"
1350 );
1351 for _ in 0..39 {
1353 log.select_down();
1354 }
1355 let scrolled = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1356 assert!(
1357 scrolled.contains("scroll-msg-00"),
1358 "scrolling to the bottom must reveal the oldest message; got:\n{scrolled}"
1359 );
1360 }
1361
1362 #[test]
1363 fn hidden_panel_status_line_shows_restore_hint() {
1364 let rows = vec![agent_row("feat-auth")];
1365 let log = BrokerLog::new(500, false); let rendered = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1367 assert!(
1368 rendered.contains("press l to show"),
1369 "hidden panel must hint the `l` toggle in the status line; got:\n{rendered}"
1370 );
1371 assert!(
1372 !rendered.contains("Broker log ("),
1373 "hidden panel must not render the panel title region; got:\n{rendered}"
1374 );
1375 }
1376
1377 #[test]
1378 fn hidden_panel_layout_is_byte_equivalent_regardless_of_buffer_contents() {
1379 let rows = vec![agent_row("feat-auth"), agent_row("feat-db")];
1385
1386 let empty = BrokerLog::new(500, false);
1387 let mut full = BrokerLog::new(500, false);
1388 for i in 1..=50 {
1389 full.push(sample_log_entry(i));
1390 }
1391
1392 let buf_empty = draw_to_buffer(&rows, "2 agents", &empty);
1393 let buf_full = draw_to_buffer(&rows, "2 agents", &full);
1394 assert_eq!(
1395 buf_empty, buf_full,
1396 "a hidden Broker log must not alter the rendered frame regardless of buffered messages",
1397 );
1398 }
1399
1400 #[test]
1401 fn visible_panel_renders_broker_log_region() {
1402 let rows = vec![agent_row("feat-auth")];
1405 let mut log = BrokerLog::new(500, true);
1406 log.push(sample_log_entry(1));
1407
1408 let buffer = draw_to_buffer(&rows, "1 agents", &log);
1409 let mut rendered = String::new();
1410 for y in 0..buffer.area.height {
1411 for x in 0..buffer.area.width {
1412 rendered.push_str(buffer[(x, y)].symbol());
1413 }
1414 rendered.push('\n');
1415 }
1416 assert!(
1417 rendered.contains("Broker log"),
1418 "visible panel must render its titled region; got:\n{rendered}",
1419 );
1420 assert!(
1421 rendered.contains("rebasing onto main"),
1422 "visible panel must render the buffered message summary; got:\n{rendered}",
1423 );
1424 }
1425
1426 #[test]
1427 fn toggling_visibility_returns_to_hidden_layout() {
1428 let rows = vec![agent_row("feat-auth")];
1431 let mut log = BrokerLog::new(500, false);
1432 log.push(sample_log_entry(1));
1433 let hidden_before = draw_to_buffer(&rows, "1 agents", &log);
1434
1435 broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(log.visible);
1437 broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(!log.visible);
1439 let hidden_after = draw_to_buffer(&rows, "1 agents", &log);
1440
1441 assert_eq!(
1442 hidden_before, hidden_after,
1443 "hiding the panel again must reproduce the hidden layout exactly",
1444 );
1445 }
1446}