pub mod broker_log;
use std::collections::HashMap;
use std::io::{self, Stdout};
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::{Paragraph, Row, Table};
use crate::broker::delivery;
use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
use crate::dashboard::broker_log::{BrokerLog, LogKeyAction};
use crate::error::PawError;
const TICK_INTERVAL: Duration = Duration::from_millis(50);
const UNKNOWN_CLI: &str = "?";
const SUPERVISOR_AGENT_ID: &str = "supervisor";
const STUCK_ON_PROMPT_PHASE: &str = "stuck-on-prompt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRow {
pub agent_id: String,
pub cli: String,
pub status: String,
pub age: String,
pub summary: String,
}
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")
}
}
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 honour_phase = agent.agent_id == SUPERVISOR_AGENT_ID
|| agent.phase.as_deref() == Some(STUCK_ON_PROMPT_PHASE);
let label = match agent.phase.as_deref() {
Some(phase) if honour_phase => phase,
_ => &agent.status,
};
let symbol = status_symbol(label);
let cli = if agent.cli.trim().is_empty() {
UNKNOWN_CLI.to_string()
} else {
agent.cli.clone()
};
AgentRow {
agent_id: agent.agent_id.clone(),
cli,
status: format!("{symbol} {label}"),
age: format_age(elapsed),
summary: agent.summary.clone(),
}
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentTableRow {
Agent(AgentRow),
Divider,
}
pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
let mut supervisor: Option<AgentRow> = None;
let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
for row in rows {
if row.agent_id == "supervisor" {
supervisor = Some(row);
} else {
coding.push(row);
}
}
let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
if let Some(sup) = supervisor {
out.push(AgentTableRow::Agent(sup));
out.push(AgentTableRow::Divider);
}
out.extend(coding.into_iter().map(AgentTableRow::Agent));
out
}
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}")))
}
pub fn render_dashboard(
frame: &mut Frame,
rows: &[AgentRow],
status_line: &str,
broker_log: &BrokerLog,
) {
draw_frame(frame, rows, status_line, broker_log);
}
pub(crate) fn build_layout_constraints(show_panel: bool) -> Vec<Constraint> {
if show_panel {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(12), ]
} else {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
}
}
pub(crate) fn should_quit(code: KeyCode) -> bool {
matches!(code, KeyCode::Char('q'))
}
fn draw_frame(frame: &mut Frame, rows: &[AgentRow], status_line: &str, broker_log: &BrokerLog) {
let layout_constraints = build_layout_constraints(broker_log.visible);
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 arranged = arrange_with_supervisor_pinned(rows.to_vec());
let divider_segment = "─".repeat(20);
let table_rows: Vec<Row> = arranged
.iter()
.map(|entry| match entry {
AgentTableRow::Agent(r) => Row::new(vec![
r.agent_id.clone(),
r.cli.clone(),
r.status.clone(),
r.age.clone(),
r.summary.clone(),
]),
AgentTableRow::Divider => Row::new(vec![
divider_segment.clone(),
divider_segment.clone(),
divider_segment.clone(),
divider_segment.clone(),
divider_segment.clone(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
})
.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_text = if broker_log.visible {
status_line.to_string()
} else {
format!("{status_line} · broker log hidden — press l to show")
};
let status = Paragraph::new(status_text);
frame.render_widget(status, chunks[2]);
if broker_log.visible {
broker_log::render(frame, chunks[3], broker_log);
}
}
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,
500,
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>,
max_messages: usize,
default_visible: 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 broker_log = BrokerLog::new(max_messages, default_visible);
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
{
if broker_log::handle_key(&mut broker_log, key.code) == LogKeyAction::Ignored
&& should_quit(key.code)
{
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);
broker_log.ingest(delivery::full_log(state, broker_log.last_seq()));
guard
.terminal
.draw(|f| {
draw_frame(f, &rows, &status_line, &broker_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::*;
fn hidden_log() -> BrokerLog {
BrokerLog::new(500, false)
}
#[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 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(),
phase: None,
},
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(),
phase: None,
},
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(),
phase: None,
},
];
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(),
phase: None,
}];
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(),
phase: None,
},
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(),
phase: None,
},
];
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_agent_rows_populates_cli_for_every_agent() {
let now = Instant::now();
let agents = vec![
AgentStatusEntry {
agent_id: "supervisor".to_string(),
cli: "claude-oss".to_string(),
status: "working".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: Some("watching".to_string()),
},
AgentStatusEntry {
agent_id: "feat-a".to_string(),
cli: "claude-oss".to_string(),
status: "working".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: None,
},
AgentStatusEntry {
agent_id: "feat-b".to_string(),
cli: "claude-oss".to_string(),
status: "working".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: None,
},
];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 3);
for row in &rows {
assert_eq!(
row.cli, "claude-oss",
"every agent row must render its CLI, not just the supervisor: {row:?}",
);
}
}
#[test]
fn format_agent_rows_shows_placeholder_for_unresolved_cli() {
let now = Instant::now();
let agents = vec![AgentStatusEntry {
agent_id: "feat-mystery".to_string(),
cli: String::new(),
status: "working".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: None,
}];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].cli, UNKNOWN_CLI,
"blank CLI must render the documented placeholder, not an empty string",
);
assert!(!rows[0].cli.is_empty());
}
#[test]
fn dashboard_row_transitions_committed_to_working_within_ttl() {
use crate::broker::BrokerState;
use crate::broker::delivery::{agent_status_snapshot, publish_message};
use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
use std::sync::Arc;
let state = Arc::new(BrokerState::new(None)); publish_message(
&state,
&BrokerMessage::Artifact {
agent_id: "feat-x".to_string(),
payload: ArtifactPayload {
status: "committed".to_string(),
exports: vec![],
modified_files: vec![],
},
},
);
let snap = agent_status_snapshot(&state);
let rows = format_agent_rows(&snap, Instant::now());
let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
assert!(row.status.contains("committed"), "should start committed");
publish_message(
&state,
&BrokerMessage::Status {
agent_id: "feat-x".to_string(),
payload: StatusPayload {
status: "working".to_string(),
modified_files: vec!["src/lib.rs".to_string()],
message: None,
..Default::default()
},
},
);
let snap = agent_status_snapshot(&state);
let rows = format_agent_rows(&snap, Instant::now());
let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
assert!(
row.status.contains("working") && row.status.contains("🔵"),
"dashboard row must transition committed -> working, got {:?}",
row.status
);
}
#[test]
fn dashboard_row_stays_committed_when_ttl_zero() {
use crate::broker::BrokerState;
use crate::broker::delivery::{agent_status_snapshot, publish_message};
use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
use std::sync::Arc;
let state = Arc::new(BrokerState::new(None));
state.set_republish_working_ttl(Duration::ZERO);
publish_message(
&state,
&BrokerMessage::Artifact {
agent_id: "feat-y".to_string(),
payload: ArtifactPayload {
status: "committed".to_string(),
exports: vec![],
modified_files: vec![],
},
},
);
publish_message(
&state,
&BrokerMessage::Status {
agent_id: "feat-y".to_string(),
payload: StatusPayload {
status: "working".to_string(),
modified_files: vec!["src/lib.rs".to_string()],
message: None,
..Default::default()
},
},
);
let snap = agent_status_snapshot(&state);
let rows = format_agent_rows(&snap, Instant::now());
let row = rows.iter().find(|r| r.agent_id == "feat-y").unwrap();
assert!(
row.status.contains("committed"),
"with TTL=0 the dashboard row must stay committed, got {:?}",
row.status
);
}
#[test]
fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
let now = Instant::now();
let agents = vec![AgentStatusEntry {
agent_id: "supervisor".to_string(),
cli: "claude".to_string(),
status: "feedback".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: Some("merging".to_string()),
}];
let rows = format_agent_rows(&agents, now);
assert_eq!(rows.len(), 1);
assert!(
rows[0].status.contains("merging"),
"expected phase 'merging' in status field; got {:?}",
rows[0].status,
);
assert!(
!rows[0].status.contains("feedback"),
"phase must replace status label, not append; got {:?}",
rows[0].status,
);
}
#[test]
fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
let now = Instant::now();
let agents = vec![AgentStatusEntry {
agent_id: "feat-broker".to_string(),
cli: "claude".to_string(),
status: "working".to_string(),
last_seen: now,
last_seen_seconds: 0,
summary: String::new(),
phase: None,
}];
let rows = format_agent_rows(&agents, now);
assert!(
rows[0].status.contains("working"),
"expected 'working' in status field; got {:?}",
rows[0].status,
);
}
fn entry_with_phase(agent_id: &str, status: &str, phase: Option<&str>) -> AgentStatusEntry {
AgentStatusEntry {
agent_id: agent_id.to_string(),
cli: "claude".to_string(),
status: status.to_string(),
last_seen: Instant::now(),
last_seen_seconds: 0,
summary: "summary text".to_string(),
phase: phase.map(str::to_string),
}
}
#[test]
fn format_agent_rows_supervisor_shows_introspection_phase() {
let now = Instant::now();
let agents = vec![entry_with_phase("supervisor", "working", Some("audit"))];
let rows = format_agent_rows(&agents, now);
assert!(
rows[0].status.contains("audit"),
"supervisor row must surface the introspection phase; got {:?}",
rows[0].status,
);
assert_eq!(
rows[0].summary, "summary text",
"the summary is preserved alongside the phase",
);
}
#[test]
fn format_agent_rows_supervisor_falls_back_when_phase_absent() {
let now = Instant::now();
let agents = vec![entry_with_phase("supervisor", "working", None)];
let rows = format_agent_rows(&agents, now);
assert!(
rows[0].status.contains("working"),
"without a phase the supervisor row renders the status label; got {:?}",
rows[0].status,
);
}
#[test]
fn format_agent_rows_non_supervisor_ignores_phase() {
let now = Instant::now();
let agents = vec![entry_with_phase("feat-auth", "working", Some("audit"))];
let rows = format_agent_rows(&agents, now);
assert!(
rows[0].status.contains("working"),
"a coding agent's phase must be ignored; got {:?}",
rows[0].status,
);
assert!(
!rows[0].status.contains("audit"),
"the introspection phase must not leak onto a coding-agent row; got {:?}",
rows[0].status,
);
}
#[test]
fn format_agent_rows_non_supervisor_still_shows_stuck_on_prompt() {
let now = Instant::now();
let agents = vec![entry_with_phase(
"feat-auth",
"working",
Some(STUCK_ON_PROMPT_PHASE),
)];
let rows = format_agent_rows(&agents, now);
assert!(
rows[0].status.contains(STUCK_ON_PROMPT_PHASE),
"the supervisor-authored stuck-on-prompt alert must surface on the \
coding-agent row; got {:?}",
rows[0].status,
);
}
#[test]
fn format_agent_rows_supervisor_phase_snapshot_layout() {
let now = Instant::now();
let with_phase = format_agent_rows(
&[entry_with_phase("supervisor", "feedback", Some("merge"))],
now,
);
assert_eq!(with_phase[0].status, "⚪ merge");
let without_phase =
format_agent_rows(&[entry_with_phase("supervisor", "working", None)], now);
assert_eq!(without_phase[0].status, "🔵 working");
}
fn agent_row(id: &str) -> AgentRow {
AgentRow {
agent_id: id.to_string(),
cli: "claude".to_string(),
status: "🔵 working".to_string(),
age: "0s ago".to_string(),
summary: String::new(),
}
}
#[test]
fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
let rows = vec![
agent_row("feat-broker"),
agent_row("feat-dashboard"),
agent_row("supervisor"),
];
let arranged = arrange_with_supervisor_pinned(rows);
assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
assert!(
matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
"supervisor must be at row 0; got {:?}",
arranged[0]
);
assert_eq!(
arranged[1],
AgentTableRow::Divider,
"divider must immediately follow supervisor"
);
assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
}
#[test]
fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
let arranged = arrange_with_supervisor_pinned(rows);
assert_eq!(arranged.len(), 2);
for row in &arranged {
assert!(
!matches!(row, AgentTableRow::Divider),
"no divider when supervisor is absent; got {row:?}"
);
}
assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
}
#[test]
fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
let arranged = arrange_with_supervisor_pinned(Vec::new());
assert!(arranged.is_empty());
}
#[test]
fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let rows = vec![
agent_row("feat-broker"),
agent_row("feat-dashboard"),
agent_row("supervisor"),
];
let backend = TestBackend::new(140, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| draw_frame(f, &rows, "3 agents", &hidden_log()))
.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 pos_supervisor = rendered
.find("supervisor")
.expect("supervisor row should be in rendered frame");
let pos_broker = rendered
.find("feat-broker")
.expect("feat-broker row should be in rendered frame");
let pos_dashboard = rendered
.find("feat-dashboard")
.expect("feat-dashboard row should be in rendered frame");
assert!(
pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
"supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
);
let pos_divider = rendered[pos_supervisor..]
.find('─')
.map(|p| pos_supervisor + p)
.expect("divider row should contain horizontal-line characters");
assert!(
pos_divider > pos_supervisor && pos_divider < pos_broker,
"divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
);
}
#[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"
);
}
#[test]
fn rendered_frame_contains_no_questions_or_reply_input() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(140, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| draw_frame(f, &[], "0 agents", &hidden_log()))
.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');
}
assert!(
!rendered.contains("Questions ("),
"dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
);
assert!(
!rendered.contains("Reply to"),
"dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
);
}
#[test]
fn tab_key_ignored_no_buffer() {
assert!(
!should_quit(KeyCode::Tab),
"Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
);
}
#[test]
fn printable_char_ignored_no_buffer() {
assert!(
!should_quit(KeyCode::Char('a')),
"printable char 'a' must not quit and must not accumulate into any buffer",
);
assert!(
!should_quit(KeyCode::Char(' ')),
"space must not quit and must not accumulate into any buffer",
);
assert!(
should_quit(KeyCode::Char('q')),
"lowercase 'q' must quit the dashboard",
);
}
#[test]
fn layout_collapses_without_message_log() {
let constraints = build_layout_constraints(false);
assert_eq!(
constraints.len(),
3,
"layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
constraints.len(),
);
let with_log = build_layout_constraints(true);
assert_eq!(
with_log.len(),
4,
"layout with message log must be exactly 4 segments, got {} constraints",
with_log.len(),
);
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn draw_to_buffer(rows: &[AgentRow], status: &str, log: &broker_log::BrokerLog) -> Buffer {
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw_frame(f, rows, status, log)).unwrap();
terminal.backend().buffer().clone()
}
fn sample_log_entry(seq: u64) -> broker_log::LogEntry {
(
seq,
std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
crate::broker::messages::BrokerMessage::Status {
agent_id: "feat-auth".to_string(),
payload: crate::broker::messages::StatusPayload {
status: "working".to_string(),
modified_files: vec![],
message: Some("rebasing onto main".to_string()),
..Default::default()
},
},
)
}
fn log_entry_with_message(seq: u64, msg: &str) -> broker_log::LogEntry {
(
seq,
std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
crate::broker::messages::BrokerMessage::Status {
agent_id: "feat-auth".to_string(),
payload: crate::broker::messages::StatusPayload {
status: "working".to_string(),
modified_files: vec![],
message: Some(msg.to_string()),
..Default::default()
},
},
)
}
fn buffer_text(buffer: &Buffer) -> String {
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');
}
rendered
}
#[test]
fn scrolling_reaches_messages_beyond_the_first_screen() {
let rows = vec![agent_row("feat-auth")];
let mut log = BrokerLog::new(500, true);
for i in 0..40 {
log.push(log_entry_with_message(i, &format!("scroll-msg-{i:02}")));
}
let at_top = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
assert!(
!at_top.contains("scroll-msg-00"),
"precondition: the oldest message should be off-screen before scrolling; got:\n{at_top}"
);
for _ in 0..39 {
log.select_down();
}
let scrolled = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
assert!(
scrolled.contains("scroll-msg-00"),
"scrolling to the bottom must reveal the oldest message; got:\n{scrolled}"
);
}
#[test]
fn hidden_panel_status_line_shows_restore_hint() {
let rows = vec![agent_row("feat-auth")];
let log = BrokerLog::new(500, false); let rendered = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
assert!(
rendered.contains("press l to show"),
"hidden panel must hint the `l` toggle in the status line; got:\n{rendered}"
);
assert!(
!rendered.contains("Broker log ("),
"hidden panel must not render the panel title region; got:\n{rendered}"
);
}
#[test]
fn hidden_panel_layout_is_byte_equivalent_regardless_of_buffer_contents() {
let rows = vec![agent_row("feat-auth"), agent_row("feat-db")];
let empty = BrokerLog::new(500, false);
let mut full = BrokerLog::new(500, false);
for i in 1..=50 {
full.push(sample_log_entry(i));
}
let buf_empty = draw_to_buffer(&rows, "2 agents", &empty);
let buf_full = draw_to_buffer(&rows, "2 agents", &full);
assert_eq!(
buf_empty, buf_full,
"a hidden Broker log must not alter the rendered frame regardless of buffered messages",
);
}
#[test]
fn visible_panel_renders_broker_log_region() {
let rows = vec![agent_row("feat-auth")];
let mut log = BrokerLog::new(500, true);
log.push(sample_log_entry(1));
let buffer = draw_to_buffer(&rows, "1 agents", &log);
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');
}
assert!(
rendered.contains("Broker log"),
"visible panel must render its titled region; got:\n{rendered}",
);
assert!(
rendered.contains("rebasing onto main"),
"visible panel must render the buffered message summary; got:\n{rendered}",
);
}
#[test]
fn toggling_visibility_returns_to_hidden_layout() {
let rows = vec![agent_row("feat-auth")];
let mut log = BrokerLog::new(500, false);
log.push(sample_log_entry(1));
let hidden_before = draw_to_buffer(&rows, "1 agents", &log);
broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(log.visible);
broker_log::handle_key(&mut log, KeyCode::Char('l')); assert!(!log.visible);
let hidden_after = draw_to_buffer(&rows, "1 agents", &log);
assert_eq!(
hidden_before, hidden_after,
"hiding the panel again must reproduce the hidden layout exactly",
);
}
}