use std::collections::HashMap;
use std::io::{self, Stdout};
use std::process::Command;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Frame;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
use crate::broker::delivery;
use crate::broker::messages::BrokerMessage;
use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
use crate::error::PawError;
const MAX_VISIBLE_QUESTIONS: usize = 5;
const TICK_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone)]
pub struct QuestionEntry {
pub agent_id: String,
pub pane_index: usize,
pub question: String,
pub seq: u64,
}
impl QuestionEntry {
pub fn from_broker_message(msg: &BrokerMessage, pane_index: usize) -> Self {
if let BrokerMessage::Question { agent_id, payload } = msg {
Self {
agent_id: agent_id.clone(),
pane_index,
question: payload.question.clone(),
seq: 0, }
} else {
panic!("Expected BrokerMessage::Question, got {msg:?}");
}
}
}
#[derive(Debug, Clone)]
pub struct AgentRow {
pub agent_id: String,
pub cli: String,
pub status: String,
pub age: String,
pub summary: String,
}
const MAX_VISIBLE_MESSAGES: usize = 20;
pub fn status_symbol(status: &str) -> &'static str {
match status {
"working" => "🔵",
"done" | "verified" => "🟢",
"committed" => "🟣",
"blocked" => "🟡",
_ => "⚪",
}
}
pub fn format_age(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
let mins = secs / 60;
format!("{mins}m ago")
} else {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
format!("{hours}h {mins}m ago")
}
}
#[derive(Debug, Clone)]
pub struct MessageEntry {
pub timestamp: String,
pub agent_id: String,
pub message_type: String,
pub content: String,
}
pub fn message_type_symbol(msg_type: &str) -> &'static str {
match msg_type {
"agent.status" => "📤",
"agent.artifact" => "📦",
"agent.blocked" => "🚧",
"agent.verified" => "✅",
"agent.feedback" => "💬",
"agent.question" => "❓",
_ => "📄",
}
}
pub fn format_message_entry(
_seq: u64,
timestamp: std::time::SystemTime,
msg: &BrokerMessage,
) -> MessageEntry {
let time = timestamp.duration_since(std::time::UNIX_EPOCH).map_or_else(
|_| "00:00:00".to_string(),
|d| {
let secs = d.as_secs() % 86400; let hours = secs / 3600;
let mins = (secs % 3600) / 60;
let secs = secs % 60;
format!("{hours:02}:{mins:02}:{secs:02}")
},
);
let msg_type = match msg {
BrokerMessage::Status { .. } => "status",
BrokerMessage::Artifact { .. } => "artifact",
BrokerMessage::Blocked { .. } => "blocked",
BrokerMessage::Verified { .. } => "verified",
BrokerMessage::Feedback { .. } => "feedback",
BrokerMessage::Question { .. } => "question",
};
let symbol = message_type_symbol(&format!("agent.{msg_type}"));
let _status_label = msg.status_label().to_string();
MessageEntry {
timestamp: time,
agent_id: msg.agent_id().to_string(),
message_type: format!("{symbol} {msg_type}"),
content: msg.to_string(),
}
}
pub fn format_message_entries(
messages: &[(u64, std::time::SystemTime, BrokerMessage)],
) -> Vec<MessageEntry> {
messages
.iter()
.map(|(seq, ts, msg)| format_message_entry(*seq, *ts, msg))
.collect()
}
pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
agents
.iter()
.map(|agent| {
let elapsed = now.saturating_duration_since(agent.last_seen);
let symbol = status_symbol(&agent.status);
AgentRow {
agent_id: agent.agent_id.clone(),
cli: agent.cli.clone(),
status: format!("{symbol} {}", agent.status),
age: format_age(elapsed),
summary: agent.summary.clone(),
}
})
.collect()
}
pub fn format_status_line(
total: usize,
working: usize,
done: usize,
blocked: usize,
committed: usize,
) -> String {
format!(
"{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
)
}
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
terminal::enable_raw_mode()
.map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
crossterm::execute!(io::stdout(), EnterAlternateScreen)
.map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
Terminal::new(CrosstermBackend::new(io::stdout()))
.map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
terminal::disable_raw_mode()
.map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
.map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
terminal
.show_cursor()
.map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
}
#[allow(clippy::too_many_arguments)]
pub fn render_dashboard(
frame: &mut Frame,
rows: &[AgentRow],
status_line: &str,
questions: &[QuestionEntry],
focused_question: Option<usize>,
input_buffer: &str,
message_entries: &[MessageEntry],
show_message_log: bool,
) {
draw_frame(
frame,
rows,
status_line,
questions,
focused_question,
input_buffer,
message_entries,
show_message_log,
);
}
pub fn drive_question_tick<S: std::hash::BuildHasher>(
state: &Arc<BrokerState>,
pane_map: &HashMap<String, usize, S>,
questions: &mut Vec<QuestionEntry>,
last_seq: &mut u64,
) {
let (new_msgs, observed_seq) = delivery::poll_messages(state, "supervisor", *last_seq);
if observed_seq > *last_seq {
*last_seq = observed_seq;
}
for msg in new_msgs {
if let BrokerMessage::Question { agent_id, payload } = msg {
let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
questions.push(QuestionEntry {
agent_id,
pane_index,
question: payload.question,
seq: observed_seq,
});
}
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn draw_frame(
frame: &mut Frame,
rows: &[AgentRow],
status_line: &str,
questions: &[QuestionEntry],
focused_question: Option<usize>,
input_buffer: &str,
message_entries: &[MessageEntry],
show_message_log: bool,
) {
let layout_constraints = if show_message_log {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(12), Constraint::Length(7), Constraint::Length(3), ]
} else {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(7), Constraint::Length(3), ]
};
let chunks = Layout::vertical(layout_constraints).split(frame.area());
let title =
Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
frame.render_widget(title, chunks[0]);
if rows.is_empty() {
let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
frame.render_widget(empty, chunks[1]);
} else {
let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
.style(Style::default().add_modifier(Modifier::BOLD));
let table_rows: Vec<Row> = rows
.iter()
.map(|r| {
Row::new([
r.agent_id.as_str(),
r.cli.as_str(),
r.status.as_str(),
r.age.as_str(),
r.summary.as_str(),
])
})
.collect();
let widths = [
Constraint::Min(15),
Constraint::Length(10),
Constraint::Length(15),
Constraint::Length(10),
Constraint::Min(20),
];
let table = Table::new(table_rows, widths).header(header);
frame.render_widget(table, chunks[1]);
}
let status = Paragraph::new(status_line.to_string());
frame.render_widget(status, chunks[2]);
if show_message_log {
let messages_title = format!("Messages ({} recent)", message_entries.len());
let messages_block = Block::default().borders(Borders::ALL).title(messages_title);
let messages_text = if message_entries.is_empty() {
"(no recent messages)".to_string()
} else {
message_entries
.iter()
.take(MAX_VISIBLE_MESSAGES)
.map(|entry| {
format!(
"{} [{}] {}: {}",
entry.timestamp, entry.agent_id, entry.message_type, entry.content
)
})
.collect::<Vec<_>>()
.join("\n")
};
let messages = Paragraph::new(messages_text).block(messages_block);
frame.render_widget(messages, chunks[3]);
let prompts_chunk_idx = 4;
let input_chunk_idx = 5;
let prompts_title = format!("Questions ({} pending)", questions.len());
let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
let prompts_text = if questions.is_empty() {
"(no pending questions)".to_string()
} else {
questions
.iter()
.take(MAX_VISIBLE_QUESTIONS)
.enumerate()
.map(|(i, q)| {
let marker = if Some(i) == focused_question {
">"
} else {
" "
};
format!("{marker} [{}] {}", q.agent_id, q.question)
})
.collect::<Vec<_>>()
.join("\n")
};
let prompts = Paragraph::new(prompts_text).block(prompts_block);
frame.render_widget(prompts, chunks[prompts_chunk_idx]);
let focused_agent = focused_question
.and_then(|i| questions.get(i))
.map_or("(none)", |q| q.agent_id.as_str());
let input_block = Block::default().borders(Borders::ALL);
let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
let input = Paragraph::new(input_text).block(input_block);
frame.render_widget(input, chunks[input_chunk_idx]);
} else {
let prompts_chunk_idx = 3;
let input_chunk_idx = 4;
let prompts_title = format!("Questions ({} pending)", questions.len());
let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
let prompts_text = if questions.is_empty() {
"(no pending questions)".to_string()
} else {
questions
.iter()
.take(MAX_VISIBLE_QUESTIONS)
.enumerate()
.map(|(i, q)| {
let marker = if Some(i) == focused_question {
">"
} else {
" "
};
format!("{marker} [{}] {}", q.agent_id, q.question)
})
.collect::<Vec<_>>()
.join("\n")
};
let prompts = Paragraph::new(prompts_text).block(prompts_block);
frame.render_widget(prompts, chunks[prompts_chunk_idx]);
let focused_agent = focused_question
.and_then(|i| questions.get(i))
.map_or("(none)", |q| q.agent_id.as_str());
let input_block = Block::default().borders(Borders::ALL);
let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
let input = Paragraph::new(input_text).block(input_block);
frame.render_widget(input, chunks[input_chunk_idx]);
}
}
fn build_send_keys_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
vec![
"send-keys".to_string(),
"-t".to_string(),
format!("{session_name}:0.{pane_index}"),
text.to_string(),
"Enter".to_string(),
]
}
pub fn send_reply_to_pane(session_name: &str, pane_index: usize, text: &str) -> io::Result<()> {
let args = build_send_keys_args(session_name, pane_index, text);
Command::new("tmux").args(&args).status().map(|_| ())
}
#[derive(Debug, PartialEq, Eq)]
enum KeyAction {
Continue,
Quit,
}
fn handle_key_with_sender(
code: KeyCode,
questions: &mut Vec<QuestionEntry>,
focused_question: &mut Option<usize>,
input_buffer: &mut String,
session_name: Option<&str>,
send: &mut dyn FnMut(&str, usize, &str),
) -> KeyAction {
match code {
KeyCode::Char('q') => return KeyAction::Quit,
KeyCode::Tab if !questions.is_empty() => {
*focused_question = Some(match *focused_question {
Some(i) => (i + 1) % questions.len(),
None => 0,
});
}
KeyCode::Backspace => {
input_buffer.pop();
}
KeyCode::Enter => {
if !input_buffer.is_empty()
&& let Some(idx) = *focused_question
&& idx < questions.len()
{
let entry = questions[idx].clone();
if let Some(session) = session_name {
send(session, entry.pane_index, input_buffer);
}
questions.remove(idx);
input_buffer.clear();
*focused_question = if questions.is_empty() {
None
} else if idx >= questions.len() {
Some(0)
} else {
Some(idx)
};
}
}
KeyCode::Char(c) if !c.is_control() => {
input_buffer.push(c);
}
_ => {}
}
KeyAction::Continue
}
fn handle_key(
code: KeyCode,
questions: &mut Vec<QuestionEntry>,
focused_question: &mut Option<usize>,
input_buffer: &mut String,
session_name: Option<&str>,
) -> KeyAction {
handle_key_with_sender(
code,
questions,
focused_question,
input_buffer,
session_name,
&mut |session, pane, text| {
let _ = send_reply_to_pane(session, pane, text);
},
)
}
pub fn run_dashboard(
state: &Arc<BrokerState>,
broker_handle: BrokerHandle,
shutdown: &std::sync::atomic::AtomicBool,
) -> Result<(), PawError> {
run_dashboard_with_panes(state, broker_handle, shutdown, &HashMap::new(), None, false)
}
pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
state: &Arc<BrokerState>,
broker_handle: BrokerHandle,
shutdown: &std::sync::atomic::AtomicBool,
pane_map: &HashMap<String, usize, S>,
session_name: Option<&str>,
show_message_log: bool,
) -> Result<(), PawError> {
let _broker_handle = broker_handle;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = terminal::disable_raw_mode();
let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
let terminal = setup_terminal()?;
let mut guard = TerminalGuard { terminal };
let mut questions: Vec<QuestionEntry> = Vec::new();
let mut focused_question: Option<usize> = None;
let mut input_buffer = String::new();
let mut last_question_seq: u64 = 0;
loop {
if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
for _ in 0..32 {
if !event::poll(Duration::ZERO)
.map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
{
break;
}
let ev = event::read()
.map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
if let Event::Key(key) = ev
&& key.kind == KeyEventKind::Press
&& handle_key(
key.code,
&mut questions,
&mut focused_question,
&mut input_buffer,
session_name,
) == KeyAction::Quit
{
return restore_terminal(&mut guard.terminal);
}
}
let agents = delivery::agent_status_snapshot(state);
let now = Instant::now();
let rows = format_agent_rows(&agents, now);
let working = agents.iter().filter(|a| a.status == "working").count();
let done = agents
.iter()
.filter(|a| a.status == "done" || a.status == "verified")
.count();
let blocked = agents.iter().filter(|a| a.status == "blocked").count();
let committed = agents.iter().filter(|a| a.status == "committed").count();
let status_line = format_status_line(agents.len(), working, done, blocked, committed);
let recent_msgs = delivery::recent_messages(state, MAX_VISIBLE_MESSAGES);
let message_entries = format_message_entries(&recent_msgs);
let (new_msgs, last_seq) = delivery::poll_messages(state, "supervisor", last_question_seq);
if last_seq > last_question_seq {
last_question_seq = last_seq;
}
for msg in new_msgs {
if let BrokerMessage::Question { agent_id, payload } = msg {
let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
questions.push(QuestionEntry {
agent_id,
pane_index,
question: payload.question,
seq: last_seq,
});
if focused_question.is_none() {
focused_question = Some(0);
}
}
}
guard
.terminal
.draw(|f| {
draw_frame(
f,
&rows,
&status_line,
&questions,
focused_question,
&input_buffer,
&message_entries,
show_message_log,
);
})
.map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
thread::sleep(TICK_INTERVAL);
}
restore_terminal(&mut guard.terminal)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broker::messages::{
ArtifactPayload, BlockedPayload, FeedbackPayload, QuestionPayload, StatusPayload,
VerifiedPayload,
};
#[test]
fn status_symbol_working() {
assert_eq!(status_symbol("working"), "🔵");
}
#[test]
fn status_symbol_done() {
assert_eq!(status_symbol("done"), "🟢");
}
#[test]
fn status_symbol_verified() {
assert_eq!(status_symbol("verified"), "🟢");
}
#[test]
fn status_symbol_blocked() {
assert_eq!(status_symbol("blocked"), "🟡");
}
#[test]
fn status_symbol_committed() {
assert_eq!(status_symbol("committed"), "🟣");
}
#[test]
fn status_symbol_idle() {
assert_eq!(status_symbol("idle"), "⚪");
}
#[test]
fn status_symbol_unknown() {
assert_eq!(status_symbol("something-unexpected"), "⚪");
}
#[test]
fn message_type_symbol_status() {
assert_eq!(message_type_symbol("agent.status"), "📤");
}
#[test]
fn message_type_symbol_artifact() {
assert_eq!(message_type_symbol("agent.artifact"), "📦");
}
#[test]
fn message_type_symbol_blocked() {
assert_eq!(message_type_symbol("agent.blocked"), "🚧");
}
#[test]
fn message_type_symbol_verified() {
assert_eq!(message_type_symbol("agent.verified"), "✅");
}
#[test]
fn message_type_symbol_feedback() {
assert_eq!(message_type_symbol("agent.feedback"), "💬");
}
#[test]
fn message_type_symbol_question() {
assert_eq!(message_type_symbol("agent.question"), "❓");
}
#[test]
fn message_type_symbol_unknown() {
assert_eq!(message_type_symbol("agent.unknown"), "📄");
}
#[test]
fn format_message_entry_status() {
let msg = BrokerMessage::Status {
agent_id: "feat-errors".to_string(),
payload: StatusPayload {
status: "working".to_string(),
modified_files: vec!["src/main.rs".to_string()],
message: Some("refactoring".to_string()),
},
};
let entry = format_message_entry(1, std::time::SystemTime::now(), &msg);
assert_eq!(entry.agent_id, "feat-errors");
assert!(entry.message_type.contains("📤 status"));
assert!(entry.content.contains("[feat-errors] status: working"));
}
#[test]
fn format_message_entry_artifact() {
let msg = BrokerMessage::Artifact {
agent_id: "feat-errors".to_string(),
payload: ArtifactPayload {
status: "done".to_string(),
exports: vec!["PawError".to_string()],
modified_files: vec!["src/error.rs".to_string()],
},
};
let entry = format_message_entry(2, std::time::SystemTime::now(), &msg);
assert_eq!(entry.agent_id, "feat-errors");
assert!(entry.message_type.contains("📦 artifact"));
assert!(entry.content.contains("[feat-errors] artifact: done"));
}
#[test]
fn format_message_entries_empty() {
let entries = format_message_entries(&[]);
assert!(entries.is_empty());
}
#[test]
fn format_message_entries_multiple() {
let msg1 = BrokerMessage::Status {
agent_id: "feat-a".to_string(),
payload: StatusPayload {
status: "working".to_string(),
modified_files: vec![],
message: None,
},
};
let msg2 = BrokerMessage::Artifact {
agent_id: "feat-b".to_string(),
payload: ArtifactPayload {
status: "done".to_string(),
exports: vec![],
modified_files: vec![],
},
};
let messages = vec![
(1, std::time::SystemTime::now(), msg1),
(2, std::time::SystemTime::now(), msg2),
];
let entries = format_message_entries(&messages);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].agent_id, "feat-a");
assert_eq!(entries[1].agent_id, "feat-b");
}
#[test]
fn format_message_entries_all_types() {
let messages = vec![
(
1,
std::time::SystemTime::now(),
BrokerMessage::Status {
agent_id: "feat-a".to_string(),
payload: StatusPayload {
status: "working".to_string(),
modified_files: vec![],
message: None,
},
},
),
(
2,
std::time::SystemTime::now(),
BrokerMessage::Artifact {
agent_id: "feat-b".to_string(),
payload: ArtifactPayload {
status: "done".to_string(),
exports: vec![],
modified_files: vec![],
},
},
),
(
3,
std::time::SystemTime::now(),
BrokerMessage::Blocked {
agent_id: "feat-c".to_string(),
payload: BlockedPayload {
needs: "types".to_string(),
from: "feat-b".to_string(),
},
},
),
(
4,
std::time::SystemTime::now(),
BrokerMessage::Verified {
agent_id: "feat-d".to_string(),
payload: VerifiedPayload {
verified_by: "supervisor".to_string(),
message: None,
},
},
),
(
5,
std::time::SystemTime::now(),
BrokerMessage::Feedback {
agent_id: "feat-e".to_string(),
payload: FeedbackPayload {
from: "supervisor".to_string(),
errors: vec!["error".to_string()],
},
},
),
(
6,
std::time::SystemTime::now(),
BrokerMessage::Question {
agent_id: "feat-f".to_string(),
payload: QuestionPayload {
question: "question?".to_string(),
},
},
),
];
let entries = format_message_entries(&messages);
assert_eq!(entries.len(), 6);
let type_symbols: Vec<&str> = entries
.iter()
.map(|entry| entry.message_type.split(' ').next().unwrap())
.collect();
assert!(type_symbols.contains(&"📤")); assert!(type_symbols.contains(&"📦")); assert!(type_symbols.contains(&"🚧")); assert!(type_symbols.contains(&"✅")); assert!(type_symbols.contains(&"💬")); assert!(type_symbols.contains(&"❓")); }
#[test]
fn format_age_zero_seconds() {
assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
}
#[test]
fn format_age_thirty_seconds() {
assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
}
#[test]
fn format_age_three_minutes() {
assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
}
#[test]
fn format_age_one_hour_exact() {
assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
}
#[test]
fn format_age_one_hour_fifteen_minutes() {
assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
}
#[test]
fn format_agent_rows_three_agents() {
let now = Instant::now();
let agents = vec![
AgentStatusEntry {
agent_id: "feat-a".to_string(),
cli: "claude".to_string(),
status: "working".to_string(),
last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
last_seen_seconds: 10,
summary: "msg a".to_string(),
},
AgentStatusEntry {
agent_id: "feat-b".to_string(),
cli: "cursor".to_string(),
status: "done".to_string(),
last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
last_seen_seconds: 60,
summary: "msg b".to_string(),
},
AgentStatusEntry {
agent_id: "feat-c".to_string(),
cli: "claude".to_string(),
status: "blocked".to_string(),
last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
last_seen_seconds: 300,
summary: String::new(),
},
];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].agent_id, "feat-a");
assert_eq!(rows[1].agent_id, "feat-b");
assert_eq!(rows[2].agent_id, "feat-c");
}
#[test]
fn format_agent_rows_single_done_three_minutes() {
let now = Instant::now();
let agents = vec![AgentStatusEntry {
agent_id: "feat-errors".to_string(),
cli: "claude".to_string(),
status: "done".to_string(),
last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
last_seen_seconds: 180,
summary: "finished".to_string(),
}];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].agent_id, "feat-errors");
assert_eq!(rows[0].age, "3m ago");
assert!(rows[0].status.contains("done"));
}
#[test]
fn format_agent_rows_with_committed_status() {
let now = Instant::now();
let agents = vec![
AgentStatusEntry {
agent_id: "feat-committed".to_string(),
cli: "claude".to_string(),
status: "committed".to_string(),
last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
last_seen_seconds: 60,
summary: "changes committed".to_string(),
},
AgentStatusEntry {
agent_id: "feat-working".to_string(),
cli: "cursor".to_string(),
status: "working".to_string(),
last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
last_seen_seconds: 30,
summary: "in progress".to_string(),
},
];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 2);
let committed_row = rows
.iter()
.find(|r| r.agent_id == "feat-committed")
.unwrap();
assert!(committed_row.status.contains("🟣"));
assert!(committed_row.status.contains("committed"));
let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
assert!(working_row.status.contains("🔵"));
assert!(working_row.status.contains("working"));
}
#[test]
fn format_agent_rows_empty_input() {
let rows = format_agent_rows(&[], Instant::now());
assert!(rows.is_empty());
}
#[test]
fn format_status_line_mixed() {
assert_eq!(
format_status_line(4, 2, 1, 1, 0),
"4 agents: 2 working, 1 done, 1 blocked, 0 committed"
);
}
#[test]
fn format_status_line_all_done() {
assert_eq!(
format_status_line(3, 0, 3, 0, 0),
"3 agents: 0 working, 3 done, 0 blocked, 0 committed"
);
}
#[test]
fn format_status_line_zero_agents() {
assert_eq!(
format_status_line(0, 0, 0, 0, 0),
"0 agents: 0 working, 0 done, 0 blocked, 0 committed"
);
}
#[test]
fn format_status_line_with_committed() {
assert_eq!(
format_status_line(5, 2, 1, 1, 1),
"5 agents: 2 working, 1 done, 1 blocked, 1 committed"
);
}
fn make_q(agent_id: &str, question: &str, pane_index: usize, seq: u64) -> QuestionEntry {
QuestionEntry {
agent_id: agent_id.to_string(),
pane_index,
question: question.to_string(),
seq,
}
}
fn advance_focus(focused: Option<usize>, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
Some(match focused {
Some(i) => (i + 1) % len,
None => 0,
})
}
#[test]
fn tab_advances_focus_to_next() {
let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
let next = advance_focus(Some(0), qs.len());
assert_eq!(next, Some(1));
}
#[test]
fn tab_wraps_from_last_to_first() {
let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
let next = advance_focus(Some(1), qs.len());
assert_eq!(next, Some(0));
}
#[test]
fn tab_with_empty_questions_is_noop() {
let next = advance_focus(None, 0);
assert_eq!(next, None);
}
#[test]
fn build_send_keys_args_shape() {
let args = build_send_keys_args("paw-myproj", 2, "Yes, do it");
assert_eq!(
args,
vec![
"send-keys".to_string(),
"-t".to_string(),
"paw-myproj:0.2".to_string(),
"Yes, do it".to_string(),
"Enter".to_string(),
]
);
}
fn handle_enter(
questions: &mut Vec<QuestionEntry>,
focused: &mut Option<usize>,
buffer: &mut String,
) -> bool {
if buffer.is_empty() {
return false;
}
let Some(idx) = *focused else { return false };
if idx >= questions.len() {
return false;
}
questions.remove(idx);
buffer.clear();
*focused = if questions.is_empty() {
None
} else if idx >= questions.len() {
Some(0)
} else {
Some(idx)
};
true
}
#[test]
fn enter_with_empty_input_is_noop() {
let mut qs = vec![make_q("a", "q1", 1, 1)];
let mut focused = Some(0);
let mut buffer = String::new();
let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
assert!(!acted);
assert_eq!(qs.len(), 1);
assert_eq!(focused, Some(0));
}
#[test]
fn enter_with_input_removes_question_and_clears_buffer() {
let mut qs = vec![make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
let mut focused = Some(0);
let mut buffer = "Yes".to_string();
let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
assert!(acted);
assert_eq!(qs.len(), 1);
assert_eq!(qs[0].agent_id, "b");
assert!(buffer.is_empty());
assert_eq!(focused, Some(0));
}
#[test]
fn enter_clears_focus_when_last_question_answered() {
let mut qs = vec![make_q("a", "q1", 1, 1)];
let mut focused = Some(0);
let mut buffer = "Yes".to_string();
handle_enter(&mut qs, &mut focused, &mut buffer);
assert!(qs.is_empty());
assert_eq!(focused, None);
}
#[test]
fn prompts_section_caps_at_five_questions() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let many_questions: Vec<_> = (0..7)
.map(|i| {
make_q(
&format!("agent-{i:02}"),
&format!("question-marker-{i:02}"),
i,
i as u64,
)
})
.collect();
let backend = TestBackend::new(140, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_frame(f, &[], "0 agents", &many_questions, Some(0), "", &[], false);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let mut rendered = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
rendered.push_str(buffer[(x, y)].symbol());
}
rendered.push('\n');
}
let mut visible_count = 0;
for i in 0..MAX_VISIBLE_QUESTIONS {
let marker = format!("question-marker-{i:02}");
assert!(
rendered.contains(&marker),
"expected first {MAX_VISIBLE_QUESTIONS} questions to render; missing {marker} in:\n{rendered}"
);
visible_count += 1;
}
for i in MAX_VISIBLE_QUESTIONS..many_questions.len() {
let marker = format!("question-marker-{i:02}");
assert!(
!rendered.contains(&marker),
"questions beyond cap should not render; found {marker} in:\n{rendered}"
);
}
assert_eq!(visible_count, MAX_VISIBLE_QUESTIONS);
assert!(
rendered.contains("7 pending"),
"header should still show full pending count; got:\n{rendered}"
);
}
#[test]
fn printable_char_appends_to_input_buffer() {
let mut buffer = String::new();
let mut focused = None;
let mut questions = vec![];
let action = handle_key(
KeyCode::Char('x'),
&mut questions,
&mut focused,
&mut buffer,
None,
);
assert_eq!(action, KeyAction::Continue);
assert_eq!(buffer, "x");
}
#[test]
fn backspace_removes_last_char_from_input_buffer() {
let mut buffer = "hello".to_string();
let mut focused = None;
let mut questions = vec![];
let action = handle_key(
KeyCode::Backspace,
&mut questions,
&mut focused,
&mut buffer,
None,
);
assert_eq!(action, KeyAction::Continue);
assert_eq!(buffer, "hell");
}
#[test]
fn backspace_on_empty_buffer_is_noop() {
let mut buffer = String::new();
let mut focused = None;
let mut questions = vec![];
let action = handle_key(
KeyCode::Backspace,
&mut questions,
&mut focused,
&mut buffer,
None,
);
assert_eq!(action, KeyAction::Continue);
assert_eq!(buffer, "");
}
#[test]
fn question_entry_from_broker_message() {
let msg = BrokerMessage::Question {
agent_id: "feat-errors".to_string(),
payload: crate::broker::messages::QuestionPayload {
question: "Should I use anyhow or thiserror?".to_string(),
},
};
let entry = QuestionEntry::from_broker_message(&msg, 2);
assert_eq!(entry.agent_id, "feat-errors");
assert_eq!(entry.pane_index, 2);
assert_eq!(entry.question, "Should I use anyhow or thiserror?");
}
#[test]
fn advance_focus_wraps_around_when_at_end() {
let focused = Some(2); let questions = [
make_q("a", "q1", 1, 1),
make_q("b", "q2", 2, 2),
make_q("c", "q3", 3, 3),
];
let new_focused = advance_focus(focused, questions.len());
assert_eq!(new_focused, Some(0));
}
#[test]
fn advance_focus_on_empty_list_is_noop() {
let focused = None;
let questions: Vec<QuestionEntry> = vec![];
let new_focused = advance_focus(focused, questions.len());
assert_eq!(new_focused, None);
}
#[test]
fn enter_invokes_send_reply_with_focused_pane() {
let mut questions = vec![
make_q("feat-auth", "q1", 1, 1),
make_q("feat-db", "q2", 7, 2),
make_q("feat-api", "q3", 3, 3),
];
let mut focused = Some(1); let mut buffer = "Yes please".to_string();
let mut captured: Vec<Vec<String>> = Vec::new();
{
let mut record = |session: &str, pane: usize, text: &str| {
captured.push(build_send_keys_args(session, pane, text));
};
let action = handle_key_with_sender(
KeyCode::Enter,
&mut questions,
&mut focused,
&mut buffer,
Some("paw-myproj"),
&mut record,
);
assert_eq!(action, KeyAction::Continue);
}
assert_eq!(
captured.len(),
1,
"send should fire exactly once for one Enter press"
);
assert_eq!(
captured[0],
vec![
"send-keys".to_string(),
"-t".to_string(),
"paw-myproj:0.7".to_string(),
"Yes please".to_string(),
"Enter".to_string(),
],
"tmux send-keys argv must target the focused pane"
);
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].agent_id, "feat-auth");
assert_eq!(questions[1].agent_id, "feat-api");
assert!(buffer.is_empty(), "input buffer should be cleared");
assert_eq!(
focused,
Some(1),
"focus should remain on the same index when one remains after it"
);
}
#[test]
fn enter_without_session_name_does_not_invoke_sender() {
let mut questions = vec![make_q("feat-auth", "q1", 1, 1)];
let mut focused = Some(0);
let mut buffer = "noop".to_string();
let mut sender_calls = 0;
let mut record = |_: &str, _: usize, _: &str| {
sender_calls += 1;
};
let action = handle_key_with_sender(
KeyCode::Enter,
&mut questions,
&mut focused,
&mut buffer,
None,
&mut record,
);
assert_eq!(action, KeyAction::Continue);
assert_eq!(sender_calls, 0, "sender must not fire without a session");
assert!(questions.is_empty());
assert!(buffer.is_empty());
}
}