1use std::collections::HashMap;
8use std::io::{self, Stdout};
9use std::process::Command;
10use std::sync::Arc;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crossterm::event::{self, Event, KeyCode, KeyEventKind};
15use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
16use ratatui::Frame;
17use ratatui::Terminal;
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Alignment, Constraint, Layout};
20use ratatui::style::{Modifier, Style};
21use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
22
23use crate::broker::delivery;
24use crate::broker::messages::BrokerMessage;
25use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
26use crate::error::PawError;
27
28const MAX_VISIBLE_QUESTIONS: usize = 5;
30
31const TICK_INTERVAL: Duration = Duration::from_millis(50);
38
39#[derive(Debug, Clone)]
45pub struct QuestionEntry {
46 pub agent_id: String,
48 pub pane_index: usize,
50 pub question: String,
52 pub seq: u64,
54}
55
56impl QuestionEntry {
57 pub fn from_broker_message(msg: &BrokerMessage, pane_index: usize) -> Self {
65 if let BrokerMessage::Question { agent_id, payload } = msg {
66 Self {
67 agent_id: agent_id.clone(),
68 pane_index,
69 question: payload.question.clone(),
70 seq: 0, }
72 } else {
73 panic!("Expected BrokerMessage::Question, got {msg:?}");
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct AgentRow {
81 pub agent_id: String,
83 pub cli: String,
85 pub status: String,
87 pub age: String,
89 pub summary: String,
91}
92
93const MAX_VISIBLE_MESSAGES: usize = 20;
95
96pub fn status_symbol(status: &str) -> &'static str {
107 match status {
108 "working" => "🔵",
109 "done" | "verified" => "🟢",
110 "committed" => "🟣",
111 "blocked" => "🟡",
112 _ => "⚪",
113 }
114}
115
116pub fn format_age(elapsed: Duration) -> String {
122 let secs = elapsed.as_secs();
123 if secs < 60 {
124 format!("{secs}s ago")
125 } else if secs < 3600 {
126 let mins = secs / 60;
127 format!("{mins}m ago")
128 } else {
129 let hours = secs / 3600;
130 let mins = (secs % 3600) / 60;
131 format!("{hours}h {mins}m ago")
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct MessageEntry {
138 pub timestamp: String,
140 pub agent_id: String,
142 pub message_type: String,
144 pub content: String,
146}
147
148pub fn message_type_symbol(msg_type: &str) -> &'static str {
150 match msg_type {
151 "agent.status" => "📤",
152 "agent.artifact" => "📦",
153 "agent.blocked" => "🚧",
154 "agent.verified" => "✅",
155 "agent.feedback" => "💬",
156 "agent.question" => "❓",
157 _ => "📄",
158 }
159}
160
161pub fn format_message_entry(
163 _seq: u64,
164 timestamp: std::time::SystemTime,
165 msg: &BrokerMessage,
166) -> MessageEntry {
167 let time = timestamp.duration_since(std::time::UNIX_EPOCH).map_or_else(
169 |_| "00:00:00".to_string(),
170 |d| {
171 let secs = d.as_secs() % 86400; let hours = secs / 3600;
173 let mins = (secs % 3600) / 60;
174 let secs = secs % 60;
175 format!("{hours:02}:{mins:02}:{secs:02}")
176 },
177 );
178
179 let msg_type = match msg {
180 BrokerMessage::Status { .. } => "status",
181 BrokerMessage::Artifact { .. } => "artifact",
182 BrokerMessage::Blocked { .. } => "blocked",
183 BrokerMessage::Verified { .. } => "verified",
184 BrokerMessage::Feedback { .. } => "feedback",
185 BrokerMessage::Question { .. } => "question",
186 };
187 let symbol = message_type_symbol(&format!("agent.{msg_type}"));
188 let _status_label = msg.status_label().to_string();
189
190 MessageEntry {
191 timestamp: time,
192 agent_id: msg.agent_id().to_string(),
193 message_type: format!("{symbol} {msg_type}"),
194 content: msg.to_string(),
195 }
196}
197
198pub fn format_message_entries(
200 messages: &[(u64, std::time::SystemTime, BrokerMessage)],
201) -> Vec<MessageEntry> {
202 messages
203 .iter()
204 .map(|(seq, ts, msg)| format_message_entry(*seq, *ts, msg))
205 .collect()
206}
207
208pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
213 agents
214 .iter()
215 .map(|agent| {
216 let elapsed = now.saturating_duration_since(agent.last_seen);
217 let symbol = status_symbol(&agent.status);
218 AgentRow {
219 agent_id: agent.agent_id.clone(),
220 cli: agent.cli.clone(),
221 status: format!("{symbol} {}", agent.status),
222 age: format_age(elapsed),
223 summary: agent.summary.clone(),
224 }
225 })
226 .collect()
227}
228
229pub fn format_status_line(
233 total: usize,
234 working: usize,
235 done: usize,
236 blocked: usize,
237 committed: usize,
238) -> String {
239 format!(
240 "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
241 )
242}
243
244struct TerminalGuard {
251 terminal: Terminal<CrosstermBackend<Stdout>>,
252}
253
254impl Drop for TerminalGuard {
255 fn drop(&mut self) {
256 let _ = terminal::disable_raw_mode();
257 let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
258 let _ = self.terminal.show_cursor();
259 }
260}
261
262fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
264 terminal::enable_raw_mode()
265 .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
266 crossterm::execute!(io::stdout(), EnterAlternateScreen)
267 .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
268 Terminal::new(CrosstermBackend::new(io::stdout()))
269 .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
270}
271
272fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
274 terminal::disable_raw_mode()
275 .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
276 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
277 .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
278 terminal
279 .show_cursor()
280 .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
281}
282
283#[allow(clippy::too_many_arguments)]
293pub fn render_dashboard(
294 frame: &mut Frame,
295 rows: &[AgentRow],
296 status_line: &str,
297 questions: &[QuestionEntry],
298 focused_question: Option<usize>,
299 input_buffer: &str,
300 message_entries: &[MessageEntry],
301 show_message_log: bool,
302) {
303 draw_frame(
304 frame,
305 rows,
306 status_line,
307 questions,
308 focused_question,
309 input_buffer,
310 message_entries,
311 show_message_log,
312 );
313}
314
315pub fn drive_question_tick<S: std::hash::BuildHasher>(
325 state: &Arc<BrokerState>,
326 pane_map: &HashMap<String, usize, S>,
327 questions: &mut Vec<QuestionEntry>,
328 last_seq: &mut u64,
329) {
330 let (new_msgs, observed_seq) = delivery::poll_messages(state, "supervisor", *last_seq);
331 if observed_seq > *last_seq {
332 *last_seq = observed_seq;
333 }
334 for msg in new_msgs {
335 if let BrokerMessage::Question { agent_id, payload } = msg {
336 let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
337 questions.push(QuestionEntry {
338 agent_id,
339 pane_index,
340 question: payload.question,
341 seq: observed_seq,
342 });
343 }
344 }
345}
346
347#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
349fn draw_frame(
350 frame: &mut Frame,
351 rows: &[AgentRow],
352 status_line: &str,
353 questions: &[QuestionEntry],
354 focused_question: Option<usize>,
355 input_buffer: &str,
356 message_entries: &[MessageEntry],
357 show_message_log: bool,
358) {
359 let layout_constraints = if show_message_log {
361 vec![
362 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(12), Constraint::Length(7), Constraint::Length(3), ]
369 } else {
370 vec![
371 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(7), Constraint::Length(3), ]
377 };
378
379 let chunks = Layout::vertical(layout_constraints).split(frame.area());
380
381 let title =
382 Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
383 frame.render_widget(title, chunks[0]);
384
385 if rows.is_empty() {
386 let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
387 frame.render_widget(empty, chunks[1]);
388 } else {
389 let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
390 .style(Style::default().add_modifier(Modifier::BOLD));
391 let table_rows: Vec<Row> = rows
392 .iter()
393 .map(|r| {
394 Row::new([
395 r.agent_id.as_str(),
396 r.cli.as_str(),
397 r.status.as_str(),
398 r.age.as_str(),
399 r.summary.as_str(),
400 ])
401 })
402 .collect();
403 let widths = [
404 Constraint::Min(15),
405 Constraint::Length(10),
406 Constraint::Length(15),
407 Constraint::Length(10),
408 Constraint::Min(20),
409 ];
410 let table = Table::new(table_rows, widths).header(header);
411 frame.render_widget(table, chunks[1]);
412 }
413
414 let status = Paragraph::new(status_line.to_string());
415 frame.render_widget(status, chunks[2]);
416
417 if show_message_log {
419 let messages_title = format!("Messages ({} recent)", message_entries.len());
420 let messages_block = Block::default().borders(Borders::ALL).title(messages_title);
421 let messages_text = if message_entries.is_empty() {
422 "(no recent messages)".to_string()
423 } else {
424 message_entries
425 .iter()
426 .take(MAX_VISIBLE_MESSAGES)
427 .map(|entry| {
428 format!(
429 "{} [{}] {}: {}",
430 entry.timestamp, entry.agent_id, entry.message_type, entry.content
431 )
432 })
433 .collect::<Vec<_>>()
434 .join("\n")
435 };
436 let messages = Paragraph::new(messages_text).block(messages_block);
437 frame.render_widget(messages, chunks[3]);
438
439 let prompts_chunk_idx = 4;
441 let input_chunk_idx = 5;
442
443 let prompts_title = format!("Questions ({} pending)", questions.len());
445 let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
446 let prompts_text = if questions.is_empty() {
447 "(no pending questions)".to_string()
448 } else {
449 questions
450 .iter()
451 .take(MAX_VISIBLE_QUESTIONS)
452 .enumerate()
453 .map(|(i, q)| {
454 let marker = if Some(i) == focused_question {
455 ">"
456 } else {
457 " "
458 };
459 format!("{marker} [{}] {}", q.agent_id, q.question)
460 })
461 .collect::<Vec<_>>()
462 .join("\n")
463 };
464 let prompts = Paragraph::new(prompts_text).block(prompts_block);
465 frame.render_widget(prompts, chunks[prompts_chunk_idx]);
466
467 let focused_agent = focused_question
469 .and_then(|i| questions.get(i))
470 .map_or("(none)", |q| q.agent_id.as_str());
471 let input_block = Block::default().borders(Borders::ALL);
472 let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
473 let input = Paragraph::new(input_text).block(input_block);
474 frame.render_widget(input, chunks[input_chunk_idx]);
475 } else {
476 let prompts_chunk_idx = 3;
478 let input_chunk_idx = 4;
479
480 let prompts_title = format!("Questions ({} pending)", questions.len());
482 let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
483 let prompts_text = if questions.is_empty() {
484 "(no pending questions)".to_string()
485 } else {
486 questions
487 .iter()
488 .take(MAX_VISIBLE_QUESTIONS)
489 .enumerate()
490 .map(|(i, q)| {
491 let marker = if Some(i) == focused_question {
492 ">"
493 } else {
494 " "
495 };
496 format!("{marker} [{}] {}", q.agent_id, q.question)
497 })
498 .collect::<Vec<_>>()
499 .join("\n")
500 };
501 let prompts = Paragraph::new(prompts_text).block(prompts_block);
502 frame.render_widget(prompts, chunks[prompts_chunk_idx]);
503
504 let focused_agent = focused_question
506 .and_then(|i| questions.get(i))
507 .map_or("(none)", |q| q.agent_id.as_str());
508 let input_block = Block::default().borders(Borders::ALL);
509 let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
510 let input = Paragraph::new(input_text).block(input_block);
511 frame.render_widget(input, chunks[input_chunk_idx]);
512 }
513}
514
515fn build_send_keys_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
527 vec![
528 "send-keys".to_string(),
529 "-t".to_string(),
530 format!("{session_name}:0.{pane_index}"),
531 text.to_string(),
532 "Enter".to_string(),
533 ]
534}
535
536pub fn send_reply_to_pane(session_name: &str, pane_index: usize, text: &str) -> io::Result<()> {
545 let args = build_send_keys_args(session_name, pane_index, text);
546 Command::new("tmux").args(&args).status().map(|_| ())
547}
548
549#[derive(Debug, PartialEq, Eq)]
564enum KeyAction {
565 Continue,
567 Quit,
569}
570
571fn handle_key_with_sender(
580 code: KeyCode,
581 questions: &mut Vec<QuestionEntry>,
582 focused_question: &mut Option<usize>,
583 input_buffer: &mut String,
584 session_name: Option<&str>,
585 send: &mut dyn FnMut(&str, usize, &str),
586) -> KeyAction {
587 match code {
588 KeyCode::Char('q') => return KeyAction::Quit,
589 KeyCode::Tab if !questions.is_empty() => {
590 *focused_question = Some(match *focused_question {
591 Some(i) => (i + 1) % questions.len(),
592 None => 0,
593 });
594 }
595 KeyCode::Backspace => {
596 input_buffer.pop();
597 }
598 KeyCode::Enter => {
599 if !input_buffer.is_empty()
600 && let Some(idx) = *focused_question
601 && idx < questions.len()
602 {
603 let entry = questions[idx].clone();
604 if let Some(session) = session_name {
605 send(session, entry.pane_index, input_buffer);
606 }
607 questions.remove(idx);
608 input_buffer.clear();
609 *focused_question = if questions.is_empty() {
610 None
611 } else if idx >= questions.len() {
612 Some(0)
613 } else {
614 Some(idx)
615 };
616 }
617 }
618 KeyCode::Char(c) if !c.is_control() => {
619 input_buffer.push(c);
620 }
621 _ => {}
622 }
623 KeyAction::Continue
624}
625
626fn handle_key(
629 code: KeyCode,
630 questions: &mut Vec<QuestionEntry>,
631 focused_question: &mut Option<usize>,
632 input_buffer: &mut String,
633 session_name: Option<&str>,
634) -> KeyAction {
635 handle_key_with_sender(
636 code,
637 questions,
638 focused_question,
639 input_buffer,
640 session_name,
641 &mut |session, pane, text| {
642 let _ = send_reply_to_pane(session, pane, text);
643 },
644 )
645}
646
647pub fn run_dashboard(
648 state: &Arc<BrokerState>,
649 broker_handle: BrokerHandle,
650 shutdown: &std::sync::atomic::AtomicBool,
651) -> Result<(), PawError> {
652 run_dashboard_with_panes(state, broker_handle, shutdown, &HashMap::new(), None, false)
653}
654
655pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
660 state: &Arc<BrokerState>,
661 broker_handle: BrokerHandle,
662 shutdown: &std::sync::atomic::AtomicBool,
663 pane_map: &HashMap<String, usize, S>,
664 session_name: Option<&str>,
665 show_message_log: bool,
666) -> Result<(), PawError> {
667 let _broker_handle = broker_handle;
668 let original_hook = std::panic::take_hook();
670 std::panic::set_hook(Box::new(move |info| {
671 let _ = terminal::disable_raw_mode();
672 let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
673 original_hook(info);
674 }));
675
676 let terminal = setup_terminal()?;
677 let mut guard = TerminalGuard { terminal };
678
679 let mut questions: Vec<QuestionEntry> = Vec::new();
680 let mut focused_question: Option<usize> = None;
681 let mut input_buffer = String::new();
682 let mut last_question_seq: u64 = 0;
683
684 loop {
685 if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
687 break;
688 }
689
690 for _ in 0..32 {
697 if !event::poll(Duration::ZERO)
698 .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
699 {
700 break;
701 }
702 let ev = event::read()
703 .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
704 if let Event::Key(key) = ev
705 && key.kind == KeyEventKind::Press
706 && handle_key(
707 key.code,
708 &mut questions,
709 &mut focused_question,
710 &mut input_buffer,
711 session_name,
712 ) == KeyAction::Quit
713 {
714 return restore_terminal(&mut guard.terminal);
715 }
716 }
717
718 let agents = delivery::agent_status_snapshot(state);
719 let now = Instant::now();
720 let rows = format_agent_rows(&agents, now);
721 let working = agents.iter().filter(|a| a.status == "working").count();
722 let done = agents
723 .iter()
724 .filter(|a| a.status == "done" || a.status == "verified")
725 .count();
726 let blocked = agents.iter().filter(|a| a.status == "blocked").count();
727 let committed = agents.iter().filter(|a| a.status == "committed").count();
728 let status_line = format_status_line(agents.len(), working, done, blocked, committed);
729
730 let recent_msgs = delivery::recent_messages(state, MAX_VISIBLE_MESSAGES);
732 let message_entries = format_message_entries(&recent_msgs);
733
734 let (new_msgs, last_seq) = delivery::poll_messages(state, "supervisor", last_question_seq);
736 if last_seq > last_question_seq {
737 last_question_seq = last_seq;
738 }
739 for msg in new_msgs {
740 if let BrokerMessage::Question { agent_id, payload } = msg {
741 let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
742 questions.push(QuestionEntry {
743 agent_id,
744 pane_index,
745 question: payload.question,
746 seq: last_seq,
747 });
748 if focused_question.is_none() {
749 focused_question = Some(0);
750 }
751 }
752 }
753
754 guard
755 .terminal
756 .draw(|f| {
757 draw_frame(
758 f,
759 &rows,
760 &status_line,
761 &questions,
762 focused_question,
763 &input_buffer,
764 &message_entries,
765 show_message_log,
766 );
767 })
768 .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
769
770 thread::sleep(TICK_INTERVAL);
771 }
772
773 restore_terminal(&mut guard.terminal)?;
775 Ok(())
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use crate::broker::messages::{
782 ArtifactPayload, BlockedPayload, FeedbackPayload, QuestionPayload, StatusPayload,
783 VerifiedPayload,
784 };
785
786 #[test]
791 fn status_symbol_working() {
792 assert_eq!(status_symbol("working"), "🔵");
793 }
794
795 #[test]
796 fn status_symbol_done() {
797 assert_eq!(status_symbol("done"), "🟢");
798 }
799
800 #[test]
801 fn status_symbol_verified() {
802 assert_eq!(status_symbol("verified"), "🟢");
803 }
804
805 #[test]
806 fn status_symbol_blocked() {
807 assert_eq!(status_symbol("blocked"), "🟡");
808 }
809
810 #[test]
811 fn status_symbol_committed() {
812 assert_eq!(status_symbol("committed"), "🟣");
813 }
814
815 #[test]
816 fn status_symbol_idle() {
817 assert_eq!(status_symbol("idle"), "⚪");
818 }
819
820 #[test]
821 fn status_symbol_unknown() {
822 assert_eq!(status_symbol("something-unexpected"), "⚪");
823 }
824
825 #[test]
830 fn message_type_symbol_status() {
831 assert_eq!(message_type_symbol("agent.status"), "📤");
832 }
833
834 #[test]
835 fn message_type_symbol_artifact() {
836 assert_eq!(message_type_symbol("agent.artifact"), "📦");
837 }
838
839 #[test]
840 fn message_type_symbol_blocked() {
841 assert_eq!(message_type_symbol("agent.blocked"), "🚧");
842 }
843
844 #[test]
845 fn message_type_symbol_verified() {
846 assert_eq!(message_type_symbol("agent.verified"), "✅");
847 }
848
849 #[test]
850 fn message_type_symbol_feedback() {
851 assert_eq!(message_type_symbol("agent.feedback"), "💬");
852 }
853
854 #[test]
855 fn message_type_symbol_question() {
856 assert_eq!(message_type_symbol("agent.question"), "❓");
857 }
858
859 #[test]
860 fn message_type_symbol_unknown() {
861 assert_eq!(message_type_symbol("agent.unknown"), "📄");
862 }
863
864 #[test]
869 fn format_message_entry_status() {
870 let msg = BrokerMessage::Status {
871 agent_id: "feat-errors".to_string(),
872 payload: StatusPayload {
873 status: "working".to_string(),
874 modified_files: vec!["src/main.rs".to_string()],
875 message: Some("refactoring".to_string()),
876 },
877 };
878 let entry = format_message_entry(1, std::time::SystemTime::now(), &msg);
879 assert_eq!(entry.agent_id, "feat-errors");
880 assert!(entry.message_type.contains("📤 status"));
881 assert!(entry.content.contains("[feat-errors] status: working"));
882 }
883
884 #[test]
885 fn format_message_entry_artifact() {
886 let msg = BrokerMessage::Artifact {
887 agent_id: "feat-errors".to_string(),
888 payload: ArtifactPayload {
889 status: "done".to_string(),
890 exports: vec!["PawError".to_string()],
891 modified_files: vec!["src/error.rs".to_string()],
892 },
893 };
894 let entry = format_message_entry(2, std::time::SystemTime::now(), &msg);
895 assert_eq!(entry.agent_id, "feat-errors");
896 assert!(entry.message_type.contains("📦 artifact"));
897 assert!(entry.content.contains("[feat-errors] artifact: done"));
898 }
899
900 #[test]
901 fn format_message_entries_empty() {
902 let entries = format_message_entries(&[]);
903 assert!(entries.is_empty());
904 }
905
906 #[test]
907 fn format_message_entries_multiple() {
908 let msg1 = BrokerMessage::Status {
909 agent_id: "feat-a".to_string(),
910 payload: StatusPayload {
911 status: "working".to_string(),
912 modified_files: vec![],
913 message: None,
914 },
915 };
916 let msg2 = BrokerMessage::Artifact {
917 agent_id: "feat-b".to_string(),
918 payload: ArtifactPayload {
919 status: "done".to_string(),
920 exports: vec![],
921 modified_files: vec![],
922 },
923 };
924 let messages = vec![
925 (1, std::time::SystemTime::now(), msg1),
926 (2, std::time::SystemTime::now(), msg2),
927 ];
928 let entries = format_message_entries(&messages);
929 assert_eq!(entries.len(), 2);
930 assert_eq!(entries[0].agent_id, "feat-a");
931 assert_eq!(entries[1].agent_id, "feat-b");
932 }
933
934 #[test]
935 fn format_message_entries_all_types() {
936 let messages = vec![
937 (
938 1,
939 std::time::SystemTime::now(),
940 BrokerMessage::Status {
941 agent_id: "feat-a".to_string(),
942 payload: StatusPayload {
943 status: "working".to_string(),
944 modified_files: vec![],
945 message: None,
946 },
947 },
948 ),
949 (
950 2,
951 std::time::SystemTime::now(),
952 BrokerMessage::Artifact {
953 agent_id: "feat-b".to_string(),
954 payload: ArtifactPayload {
955 status: "done".to_string(),
956 exports: vec![],
957 modified_files: vec![],
958 },
959 },
960 ),
961 (
962 3,
963 std::time::SystemTime::now(),
964 BrokerMessage::Blocked {
965 agent_id: "feat-c".to_string(),
966 payload: BlockedPayload {
967 needs: "types".to_string(),
968 from: "feat-b".to_string(),
969 },
970 },
971 ),
972 (
973 4,
974 std::time::SystemTime::now(),
975 BrokerMessage::Verified {
976 agent_id: "feat-d".to_string(),
977 payload: VerifiedPayload {
978 verified_by: "supervisor".to_string(),
979 message: None,
980 },
981 },
982 ),
983 (
984 5,
985 std::time::SystemTime::now(),
986 BrokerMessage::Feedback {
987 agent_id: "feat-e".to_string(),
988 payload: FeedbackPayload {
989 from: "supervisor".to_string(),
990 errors: vec!["error".to_string()],
991 },
992 },
993 ),
994 (
995 6,
996 std::time::SystemTime::now(),
997 BrokerMessage::Question {
998 agent_id: "feat-f".to_string(),
999 payload: QuestionPayload {
1000 question: "question?".to_string(),
1001 },
1002 },
1003 ),
1004 ];
1005
1006 let entries = format_message_entries(&messages);
1007 assert_eq!(entries.len(), 6);
1008
1009 let type_symbols: Vec<&str> = entries
1011 .iter()
1012 .map(|entry| entry.message_type.split(' ').next().unwrap())
1013 .collect();
1014 assert!(type_symbols.contains(&"📤")); assert!(type_symbols.contains(&"📦")); assert!(type_symbols.contains(&"🚧")); assert!(type_symbols.contains(&"✅")); assert!(type_symbols.contains(&"💬")); assert!(type_symbols.contains(&"❓")); }
1021
1022 #[test]
1027 fn format_age_zero_seconds() {
1028 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
1029 }
1030
1031 #[test]
1032 fn format_age_thirty_seconds() {
1033 assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
1034 }
1035
1036 #[test]
1037 fn format_age_three_minutes() {
1038 assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
1039 }
1040
1041 #[test]
1042 fn format_age_one_hour_exact() {
1043 assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
1044 }
1045
1046 #[test]
1047 fn format_age_one_hour_fifteen_minutes() {
1048 assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
1049 }
1050
1051 #[test]
1056 fn format_agent_rows_three_agents() {
1057 let now = Instant::now();
1058 let agents = vec![
1059 AgentStatusEntry {
1060 agent_id: "feat-a".to_string(),
1061 cli: "claude".to_string(),
1062 status: "working".to_string(),
1063 last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
1064 last_seen_seconds: 10,
1065 summary: "msg a".to_string(),
1066 },
1067 AgentStatusEntry {
1068 agent_id: "feat-b".to_string(),
1069 cli: "cursor".to_string(),
1070 status: "done".to_string(),
1071 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
1072 last_seen_seconds: 60,
1073 summary: "msg b".to_string(),
1074 },
1075 AgentStatusEntry {
1076 agent_id: "feat-c".to_string(),
1077 cli: "claude".to_string(),
1078 status: "blocked".to_string(),
1079 last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
1080 last_seen_seconds: 300,
1081 summary: String::new(),
1082 },
1083 ];
1084 let rows = format_agent_rows(&agents, now);
1085 assert_eq!(rows.len(), 3);
1086 assert_eq!(rows[0].agent_id, "feat-a");
1087 assert_eq!(rows[1].agent_id, "feat-b");
1088 assert_eq!(rows[2].agent_id, "feat-c");
1089 }
1090
1091 #[test]
1092 fn format_agent_rows_single_done_three_minutes() {
1093 let now = Instant::now();
1094 let agents = vec![AgentStatusEntry {
1095 agent_id: "feat-errors".to_string(),
1096 cli: "claude".to_string(),
1097 status: "done".to_string(),
1098 last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
1099 last_seen_seconds: 180,
1100 summary: "finished".to_string(),
1101 }];
1102 let rows = format_agent_rows(&agents, now);
1103 assert_eq!(rows.len(), 1);
1104 assert_eq!(rows[0].agent_id, "feat-errors");
1105 assert_eq!(rows[0].age, "3m ago");
1106 assert!(rows[0].status.contains("done"));
1107 }
1108
1109 #[test]
1110 fn format_agent_rows_with_committed_status() {
1111 let now = Instant::now();
1112 let agents = vec![
1113 AgentStatusEntry {
1114 agent_id: "feat-committed".to_string(),
1115 cli: "claude".to_string(),
1116 status: "committed".to_string(),
1117 last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
1118 last_seen_seconds: 60,
1119 summary: "changes committed".to_string(),
1120 },
1121 AgentStatusEntry {
1122 agent_id: "feat-working".to_string(),
1123 cli: "cursor".to_string(),
1124 status: "working".to_string(),
1125 last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
1126 last_seen_seconds: 30,
1127 summary: "in progress".to_string(),
1128 },
1129 ];
1130 let rows = format_agent_rows(&agents, now);
1131 assert_eq!(rows.len(), 2);
1132
1133 let committed_row = rows
1135 .iter()
1136 .find(|r| r.agent_id == "feat-committed")
1137 .unwrap();
1138 assert!(committed_row.status.contains("🟣"));
1139 assert!(committed_row.status.contains("committed"));
1140
1141 let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
1143 assert!(working_row.status.contains("🔵"));
1144 assert!(working_row.status.contains("working"));
1145 }
1146
1147 #[test]
1148 fn format_agent_rows_empty_input() {
1149 let rows = format_agent_rows(&[], Instant::now());
1150 assert!(rows.is_empty());
1151 }
1152
1153 #[test]
1158 fn format_status_line_mixed() {
1159 assert_eq!(
1160 format_status_line(4, 2, 1, 1, 0),
1161 "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1162 );
1163 }
1164
1165 #[test]
1166 fn format_status_line_all_done() {
1167 assert_eq!(
1168 format_status_line(3, 0, 3, 0, 0),
1169 "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1170 );
1171 }
1172
1173 #[test]
1174 fn format_status_line_zero_agents() {
1175 assert_eq!(
1176 format_status_line(0, 0, 0, 0, 0),
1177 "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1178 );
1179 }
1180
1181 #[test]
1182 fn format_status_line_with_committed() {
1183 assert_eq!(
1184 format_status_line(5, 2, 1, 1, 1),
1185 "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1186 );
1187 }
1188
1189 fn make_q(agent_id: &str, question: &str, pane_index: usize, seq: u64) -> QuestionEntry {
1194 QuestionEntry {
1195 agent_id: agent_id.to_string(),
1196 pane_index,
1197 question: question.to_string(),
1198 seq,
1199 }
1200 }
1201
1202 fn advance_focus(focused: Option<usize>, len: usize) -> Option<usize> {
1203 if len == 0 {
1204 return None;
1205 }
1206 Some(match focused {
1207 Some(i) => (i + 1) % len,
1208 None => 0,
1209 })
1210 }
1211
1212 #[test]
1213 fn tab_advances_focus_to_next() {
1214 let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1215 let next = advance_focus(Some(0), qs.len());
1216 assert_eq!(next, Some(1));
1217 }
1218
1219 #[test]
1220 fn tab_wraps_from_last_to_first() {
1221 let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1222 let next = advance_focus(Some(1), qs.len());
1223 assert_eq!(next, Some(0));
1224 }
1225
1226 #[test]
1227 fn tab_with_empty_questions_is_noop() {
1228 let next = advance_focus(None, 0);
1229 assert_eq!(next, None);
1230 }
1231
1232 #[test]
1233 fn build_send_keys_args_shape() {
1234 let args = build_send_keys_args("paw-myproj", 2, "Yes, do it");
1235 assert_eq!(
1236 args,
1237 vec![
1238 "send-keys".to_string(),
1239 "-t".to_string(),
1240 "paw-myproj:0.2".to_string(),
1241 "Yes, do it".to_string(),
1242 "Enter".to_string(),
1243 ]
1244 );
1245 }
1246
1247 fn handle_enter(
1250 questions: &mut Vec<QuestionEntry>,
1251 focused: &mut Option<usize>,
1252 buffer: &mut String,
1253 ) -> bool {
1254 if buffer.is_empty() {
1255 return false;
1256 }
1257 let Some(idx) = *focused else { return false };
1258 if idx >= questions.len() {
1259 return false;
1260 }
1261 questions.remove(idx);
1262 buffer.clear();
1263 *focused = if questions.is_empty() {
1264 None
1265 } else if idx >= questions.len() {
1266 Some(0)
1267 } else {
1268 Some(idx)
1269 };
1270 true
1271 }
1272
1273 #[test]
1274 fn enter_with_empty_input_is_noop() {
1275 let mut qs = vec![make_q("a", "q1", 1, 1)];
1276 let mut focused = Some(0);
1277 let mut buffer = String::new();
1278 let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
1279 assert!(!acted);
1280 assert_eq!(qs.len(), 1);
1281 assert_eq!(focused, Some(0));
1282 }
1283
1284 #[test]
1285 fn enter_with_input_removes_question_and_clears_buffer() {
1286 let mut qs = vec![make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1287 let mut focused = Some(0);
1288 let mut buffer = "Yes".to_string();
1289 let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
1290 assert!(acted);
1291 assert_eq!(qs.len(), 1);
1292 assert_eq!(qs[0].agent_id, "b");
1293 assert!(buffer.is_empty());
1294 assert_eq!(focused, Some(0));
1295 }
1296
1297 #[test]
1298 fn enter_clears_focus_when_last_question_answered() {
1299 let mut qs = vec![make_q("a", "q1", 1, 1)];
1300 let mut focused = Some(0);
1301 let mut buffer = "Yes".to_string();
1302 handle_enter(&mut qs, &mut focused, &mut buffer);
1303 assert!(qs.is_empty());
1304 assert_eq!(focused, None);
1305 }
1306
1307 #[test]
1308 fn prompts_section_caps_at_five_questions() {
1309 use ratatui::Terminal;
1310 use ratatui::backend::TestBackend;
1311
1312 let many_questions: Vec<_> = (0..7)
1315 .map(|i| {
1316 make_q(
1317 &format!("agent-{i:02}"),
1318 &format!("question-marker-{i:02}"),
1319 i,
1320 i as u64,
1321 )
1322 })
1323 .collect();
1324
1325 let backend = TestBackend::new(140, 30);
1326 let mut terminal = Terminal::new(backend).unwrap();
1327 terminal
1328 .draw(|f| {
1329 draw_frame(f, &[], "0 agents", &many_questions, Some(0), "", &[], false);
1330 })
1331 .unwrap();
1332
1333 let buffer = terminal.backend().buffer().clone();
1335 let mut rendered = String::new();
1336 for y in 0..buffer.area.height {
1337 for x in 0..buffer.area.width {
1338 rendered.push_str(buffer[(x, y)].symbol());
1339 }
1340 rendered.push('\n');
1341 }
1342
1343 let mut visible_count = 0;
1346 for i in 0..MAX_VISIBLE_QUESTIONS {
1347 let marker = format!("question-marker-{i:02}");
1348 assert!(
1349 rendered.contains(&marker),
1350 "expected first {MAX_VISIBLE_QUESTIONS} questions to render; missing {marker} in:\n{rendered}"
1351 );
1352 visible_count += 1;
1353 }
1354 for i in MAX_VISIBLE_QUESTIONS..many_questions.len() {
1355 let marker = format!("question-marker-{i:02}");
1356 assert!(
1357 !rendered.contains(&marker),
1358 "questions beyond cap should not render; found {marker} in:\n{rendered}"
1359 );
1360 }
1361 assert_eq!(visible_count, MAX_VISIBLE_QUESTIONS);
1362 assert!(
1364 rendered.contains("7 pending"),
1365 "header should still show full pending count; got:\n{rendered}"
1366 );
1367 }
1368
1369 #[test]
1374 fn printable_char_appends_to_input_buffer() {
1375 let mut buffer = String::new();
1376 let mut focused = None;
1377 let mut questions = vec![];
1378 let action = handle_key(
1379 KeyCode::Char('x'),
1380 &mut questions,
1381 &mut focused,
1382 &mut buffer,
1383 None,
1384 );
1385 assert_eq!(action, KeyAction::Continue);
1386 assert_eq!(buffer, "x");
1387 }
1388
1389 #[test]
1390 fn backspace_removes_last_char_from_input_buffer() {
1391 let mut buffer = "hello".to_string();
1392 let mut focused = None;
1393 let mut questions = vec![];
1394 let action = handle_key(
1395 KeyCode::Backspace,
1396 &mut questions,
1397 &mut focused,
1398 &mut buffer,
1399 None,
1400 );
1401 assert_eq!(action, KeyAction::Continue);
1402 assert_eq!(buffer, "hell");
1403 }
1404
1405 #[test]
1406 fn backspace_on_empty_buffer_is_noop() {
1407 let mut buffer = String::new();
1408 let mut focused = None;
1409 let mut questions = vec![];
1410 let action = handle_key(
1411 KeyCode::Backspace,
1412 &mut questions,
1413 &mut focused,
1414 &mut buffer,
1415 None,
1416 );
1417 assert_eq!(action, KeyAction::Continue);
1418 assert_eq!(buffer, "");
1419 }
1420
1421 #[test]
1426 fn question_entry_from_broker_message() {
1427 let msg = BrokerMessage::Question {
1428 agent_id: "feat-errors".to_string(),
1429 payload: crate::broker::messages::QuestionPayload {
1430 question: "Should I use anyhow or thiserror?".to_string(),
1431 },
1432 };
1433 let entry = QuestionEntry::from_broker_message(&msg, 2);
1434 assert_eq!(entry.agent_id, "feat-errors");
1435 assert_eq!(entry.pane_index, 2);
1436 assert_eq!(entry.question, "Should I use anyhow or thiserror?");
1437 }
1438
1439 #[test]
1444 fn advance_focus_wraps_around_when_at_end() {
1445 let focused = Some(2); let questions = [
1447 make_q("a", "q1", 1, 1),
1448 make_q("b", "q2", 2, 2),
1449 make_q("c", "q3", 3, 3),
1450 ];
1451 let new_focused = advance_focus(focused, questions.len());
1452 assert_eq!(new_focused, Some(0));
1453 }
1454
1455 #[test]
1456 fn advance_focus_on_empty_list_is_noop() {
1457 let focused = None;
1458 let questions: Vec<QuestionEntry> = vec![];
1459 let new_focused = advance_focus(focused, questions.len());
1460 assert_eq!(new_focused, None);
1461 }
1462
1463 #[test]
1472 fn enter_invokes_send_reply_with_focused_pane() {
1473 let mut questions = vec![
1474 make_q("feat-auth", "q1", 1, 1),
1475 make_q("feat-db", "q2", 7, 2),
1476 make_q("feat-api", "q3", 3, 3),
1477 ];
1478 let mut focused = Some(1); let mut buffer = "Yes please".to_string();
1480
1481 let mut captured: Vec<Vec<String>> = Vec::new();
1483 {
1484 let mut record = |session: &str, pane: usize, text: &str| {
1485 captured.push(build_send_keys_args(session, pane, text));
1486 };
1487
1488 let action = handle_key_with_sender(
1489 KeyCode::Enter,
1490 &mut questions,
1491 &mut focused,
1492 &mut buffer,
1493 Some("paw-myproj"),
1494 &mut record,
1495 );
1496 assert_eq!(action, KeyAction::Continue);
1497 }
1498
1499 assert_eq!(
1502 captured.len(),
1503 1,
1504 "send should fire exactly once for one Enter press"
1505 );
1506 assert_eq!(
1507 captured[0],
1508 vec![
1509 "send-keys".to_string(),
1510 "-t".to_string(),
1511 "paw-myproj:0.7".to_string(),
1512 "Yes please".to_string(),
1513 "Enter".to_string(),
1514 ],
1515 "tmux send-keys argv must target the focused pane"
1516 );
1517
1518 assert_eq!(questions.len(), 2);
1525 assert_eq!(questions[0].agent_id, "feat-auth");
1526 assert_eq!(questions[1].agent_id, "feat-api");
1527 assert!(buffer.is_empty(), "input buffer should be cleared");
1528 assert_eq!(
1529 focused,
1530 Some(1),
1531 "focus should remain on the same index when one remains after it"
1532 );
1533 }
1534
1535 #[test]
1538 fn enter_without_session_name_does_not_invoke_sender() {
1539 let mut questions = vec![make_q("feat-auth", "q1", 1, 1)];
1540 let mut focused = Some(0);
1541 let mut buffer = "noop".to_string();
1542
1543 let mut sender_calls = 0;
1544 let mut record = |_: &str, _: usize, _: &str| {
1545 sender_calls += 1;
1546 };
1547 let action = handle_key_with_sender(
1548 KeyCode::Enter,
1549 &mut questions,
1550 &mut focused,
1551 &mut buffer,
1552 None,
1553 &mut record,
1554 );
1555 assert_eq!(action, KeyAction::Continue);
1556 assert_eq!(sender_calls, 0, "sender must not fire without a session");
1557 assert!(questions.is_empty());
1560 assert!(buffer.is_empty());
1561 }
1562}