Skip to main content

git_paw/
dashboard.rs

1//! Ratatui TUI status table for pane 0.
2//!
3//! Reads from [`BrokerState`] on a 1-second tick
4//! and renders a read-only agent status table. The v0.3.0 dashboard is
5//! display-only — the only interaction is quitting with `q`.
6
7use std::collections::HashMap;
8use std::io::{self, Stdout};
9use std::sync::Arc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use crossterm::event::{self, Event, KeyCode, KeyEventKind};
14use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
15use ratatui::Frame;
16use ratatui::Terminal;
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Alignment, Constraint, Layout};
19use ratatui::style::{Modifier, Style};
20use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
21
22use crate::broker::delivery;
23use crate::broker::messages::BrokerMessage;
24use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
25use crate::error::PawError;
26
27/// Tick interval for the dashboard draw loop.
28///
29/// Also bounds the worst-case typing latency: any keystroke that arrives
30/// mid-sleep is picked up on the next tick. 50ms is comfortably below the
31/// ~100ms perceptual threshold for interactive UIs while keeping the
32/// broker-state snapshot rate modest (~20 Hz against an in-process lock).
33const TICK_INTERVAL: Duration = Duration::from_millis(50);
34
35/// A formatted row for display in the agent status table.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct AgentRow {
38    /// The agent identifier (slugified branch name).
39    pub agent_id: String,
40    /// The CLI name (e.g. `"claude"`).
41    pub cli: String,
42    /// Status symbol and label (e.g. `"🔵 working"`).
43    pub status: String,
44    /// Relative time since last message (e.g. `"3m ago"`).
45    pub age: String,
46    /// One-line summary from the last message.
47    pub summary: String,
48}
49
50/// Maximum number of messages displayed in the broker messages panel.
51const MAX_VISIBLE_MESSAGES: usize = 20;
52
53/// Maps an agent status label to a Unicode symbol.
54///
55/// | Input | Output |
56/// |---|---|
57/// | `"working"` | `"🔵"` |
58/// | `"done"` | `"🟢"` |
59/// | `"verified"` | `"🟢"` |
60/// | `"committed"` | `"🟣"` |
61/// | `"blocked"` | `"🟡"` |
62/// | anything else | `"⚪"` |
63pub fn status_symbol(status: &str) -> &'static str {
64    match status {
65        "working" => "🔵",
66        "done" | "verified" => "🟢",
67        "committed" => "🟣",
68        "blocked" => "🟡",
69        _ => "⚪",
70    }
71}
72
73/// Formats an elapsed duration as a human-readable relative time string.
74///
75/// - Less than 60 seconds: `"Xs ago"` (e.g. `"30s ago"`)
76/// - 1 to 59 minutes: `"Xm ago"` (e.g. `"3m ago"`)
77/// - 60 minutes or more: `"Xh Ym ago"` (e.g. `"1h 15m ago"`)
78pub fn format_age(elapsed: Duration) -> String {
79    let secs = elapsed.as_secs();
80    if secs < 60 {
81        format!("{secs}s ago")
82    } else if secs < 3600 {
83        let mins = secs / 60;
84        format!("{mins}m ago")
85    } else {
86        let hours = secs / 3600;
87        let mins = (secs % 3600) / 60;
88        format!("{hours}h {mins}m ago")
89    }
90}
91
92/// A formatted broker message for display in the messages panel.
93#[derive(Debug, Clone)]
94pub struct MessageEntry {
95    /// Formatted timestamp (e.g., "14:30:45").
96    pub timestamp: String,
97    /// The agent identifier (slugified branch name).
98    pub agent_id: String,
99    /// Message type symbol and label (e.g., "📤 status").
100    pub message_type: String,
101    /// The formatted message content.
102    pub content: String,
103}
104
105/// Maps a broker message type to a Unicode symbol.
106pub fn message_type_symbol(msg_type: &str) -> &'static str {
107    match msg_type {
108        "agent.status" => "📤",
109        "agent.artifact" => "📦",
110        "agent.blocked" => "🚧",
111        "agent.verified" => "✅",
112        "agent.feedback" => "💬",
113        "agent.question" => "❓",
114        _ => "📄",
115    }
116}
117
118/// Formats a broker message for display in the messages panel.
119pub fn format_message_entry(
120    _seq: u64,
121    timestamp: std::time::SystemTime,
122    msg: &BrokerMessage,
123) -> MessageEntry {
124    // Format timestamp as HH:MM:SS
125    let time = timestamp.duration_since(std::time::UNIX_EPOCH).map_or_else(
126        |_| "00:00:00".to_string(),
127        |d| {
128            let secs = d.as_secs() % 86400; // seconds in day
129            let hours = secs / 3600;
130            let mins = (secs % 3600) / 60;
131            let secs = secs % 60;
132            format!("{hours:02}:{mins:02}:{secs:02}")
133        },
134    );
135
136    let msg_type = match msg {
137        BrokerMessage::Status { .. } => "status",
138        BrokerMessage::Artifact { .. } => "artifact",
139        BrokerMessage::Blocked { .. } => "blocked",
140        BrokerMessage::Verified { .. } => "verified",
141        BrokerMessage::Feedback { .. } => "feedback",
142        BrokerMessage::Question { .. } => "question",
143        BrokerMessage::Intent { .. } => "intent",
144    };
145    let symbol = message_type_symbol(&format!("agent.{msg_type}"));
146    let _status_label = msg.status_label().to_string();
147
148    MessageEntry {
149        timestamp: time,
150        agent_id: msg.agent_id().to_string(),
151        message_type: format!("{symbol} {msg_type}"),
152        content: msg.to_string(),
153    }
154}
155
156/// Formats a list of broker messages for display.
157pub fn format_message_entries(
158    messages: &[(u64, std::time::SystemTime, BrokerMessage)],
159) -> Vec<MessageEntry> {
160    messages
161        .iter()
162        .map(|(seq, ts, msg)| format_message_entry(*seq, *ts, msg))
163        .collect()
164}
165
166/// Converts raw agent status entries into formatted display rows.
167///
168/// When an entry's most-recent status message carries a `phase`, the row's
169/// status field renders that phase (with the matching status symbol)
170/// instead of the message-type-derived label. Used by the supervisor row,
171/// where labels like `"feedback"` (the wire message type) are misleading
172/// and the real lifecycle phase is `"watching"`, `"merging"`, etc.
173///
174/// Pure function: performs no I/O, holds no locks, and is deterministic
175/// given the same inputs.
176pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
177    agents
178        .iter()
179        .map(|agent| {
180            let elapsed = now.saturating_duration_since(agent.last_seen);
181            let label = agent.phase.as_deref().unwrap_or(&agent.status);
182            let symbol = status_symbol(label);
183            AgentRow {
184                agent_id: agent.agent_id.clone(),
185                cli: agent.cli.clone(),
186                status: format!("{symbol} {label}"),
187                age: format_age(elapsed),
188                summary: agent.summary.clone(),
189            }
190        })
191        .collect()
192}
193
194/// One entry in the dashboard's agent table, either an agent row or a
195/// visual divider rendered between the pinned supervisor row and the
196/// coding-agent rows beneath it.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum AgentTableRow {
199    /// A normal agent row.
200    Agent(AgentRow),
201    /// A divider separating the pinned supervisor row from coding-agent rows.
202    Divider,
203}
204
205/// Reorders a slice of `AgentRow` so the supervisor row (if present) is
206/// pinned to position 0, followed by a [`AgentTableRow::Divider`], with
207/// the remaining coding-agent rows in their incoming (alphabetical) order.
208///
209/// When no row has `agent_id == "supervisor"`, the output preserves the
210/// incoming order and contains no divider.
211///
212/// Pure function: no I/O, no locks, deterministic.
213pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
214    let mut supervisor: Option<AgentRow> = None;
215    let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
216    for row in rows {
217        if row.agent_id == "supervisor" {
218            supervisor = Some(row);
219        } else {
220            coding.push(row);
221        }
222    }
223
224    let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
225    if let Some(sup) = supervisor {
226        out.push(AgentTableRow::Agent(sup));
227        out.push(AgentTableRow::Divider);
228    }
229    out.extend(coding.into_iter().map(AgentTableRow::Agent));
230    out
231}
232
233/// Produces a summary status line for the dashboard footer.
234///
235/// Returns a string like `"5 agents: 2 working, 1 done, 1 blocked, 1 committed"`.
236pub fn format_status_line(
237    total: usize,
238    working: usize,
239    done: usize,
240    blocked: usize,
241    committed: usize,
242) -> String {
243    format!(
244        "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
245    )
246}
247
248// ---------------------------------------------------------------------------
249// Terminal lifecycle
250// ---------------------------------------------------------------------------
251
252/// Guard that restores the terminal on drop, ensuring cleanup even on panic
253/// or early return.
254struct TerminalGuard {
255    terminal: Terminal<CrosstermBackend<Stdout>>,
256}
257
258impl Drop for TerminalGuard {
259    fn drop(&mut self) {
260        let _ = terminal::disable_raw_mode();
261        let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
262        let _ = self.terminal.show_cursor();
263    }
264}
265
266/// Enters raw mode and the alternate screen, returning a configured terminal.
267fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
268    terminal::enable_raw_mode()
269        .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
270    crossterm::execute!(io::stdout(), EnterAlternateScreen)
271        .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
272    Terminal::new(CrosstermBackend::new(io::stdout()))
273        .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
274}
275
276/// Disables raw mode, leaves the alternate screen, and shows the cursor.
277fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
278    terminal::disable_raw_mode()
279        .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
280    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
281        .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
282    terminal
283        .show_cursor()
284        .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
285}
286
287// ---------------------------------------------------------------------------
288// Draw
289// ---------------------------------------------------------------------------
290
291/// Renders one frame of the dashboard TUI to the given `Frame`.
292///
293/// Public wrapper around the internal `draw_frame` so integration tests can
294/// drive a real frame with `ratatui::backend::TestBackend` and assert against
295/// the resulting buffer.
296pub fn render_dashboard(
297    frame: &mut Frame,
298    rows: &[AgentRow],
299    status_line: &str,
300    message_entries: &[MessageEntry],
301    show_message_log: bool,
302) {
303    draw_frame(frame, rows, status_line, message_entries, show_message_log);
304}
305
306/// Returns the vertical layout constraints for the dashboard frame.
307///
308/// `show_message_log = false` (the v0.5.0 default after the prompt-inbox
309/// removal) produces a three-segment layout: title, agent table, status
310/// line. `show_message_log = true` appends a fourth segment for the
311/// broker messages panel.
312pub(crate) fn build_layout_constraints(show_message_log: bool) -> Vec<Constraint> {
313    if show_message_log {
314        vec![
315            Constraint::Length(1),  // title
316            Constraint::Min(0),     // agent table
317            Constraint::Length(1),  // status line
318            Constraint::Length(12), // messages panel
319        ]
320    } else {
321        vec![
322            Constraint::Length(1), // title
323            Constraint::Min(0),    // agent table
324            Constraint::Length(1), // status line
325        ]
326    }
327}
328
329/// Returns true when the given key code should terminate the dashboard
330/// event loop. Only `q` (lowercase, no modifiers) quits; every other key
331/// — including `Tab`, printable characters, and arrow keys — is ignored.
332///
333/// The supervisor-as-pane removal (v0.5.0) deleted the prompt inbox, so
334/// the dashboard has no input buffer to accumulate characters into and
335/// no focusable element for `Tab` to advance through.
336pub(crate) fn should_quit(code: KeyCode) -> bool {
337    matches!(code, KeyCode::Char('q'))
338}
339
340/// Renders one frame of the dashboard TUI.
341fn draw_frame(
342    frame: &mut Frame,
343    rows: &[AgentRow],
344    status_line: &str,
345    message_entries: &[MessageEntry],
346    show_message_log: bool,
347) {
348    // The prompt-inbox panel was removed in v0.5.0 (supervisor-as-pane-
349    // followups D3). The supervisor pane is the human's input surface for
350    // replying to `agent.question` events; the dashboard is observation-
351    // only.
352    let layout_constraints = build_layout_constraints(show_message_log);
353
354    let chunks = Layout::vertical(layout_constraints).split(frame.area());
355
356    let title =
357        Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
358    frame.render_widget(title, chunks[0]);
359
360    if rows.is_empty() {
361        let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
362        frame.render_widget(empty, chunks[1]);
363    } else {
364        let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
365            .style(Style::default().add_modifier(Modifier::BOLD));
366        // Pin the supervisor row to row 0 and insert a divider beneath it
367        // before rendering. The arrangement is computed from the same
368        // `rows` slice rather than reaching back into the snapshot —
369        // tests can verify the ordering against `arrange_with_supervisor_pinned`
370        // independently of ratatui internals.
371        let arranged = arrange_with_supervisor_pinned(rows.to_vec());
372        let divider_segment = "─".repeat(20);
373        let table_rows: Vec<Row> = arranged
374            .iter()
375            .map(|entry| match entry {
376                AgentTableRow::Agent(r) => Row::new(vec![
377                    r.agent_id.clone(),
378                    r.cli.clone(),
379                    r.status.clone(),
380                    r.age.clone(),
381                    r.summary.clone(),
382                ]),
383                AgentTableRow::Divider => Row::new(vec![
384                    divider_segment.clone(),
385                    divider_segment.clone(),
386                    divider_segment.clone(),
387                    divider_segment.clone(),
388                    divider_segment.clone(),
389                ])
390                .style(Style::default().add_modifier(Modifier::DIM)),
391            })
392            .collect();
393        let widths = [
394            Constraint::Min(15),
395            Constraint::Length(10),
396            Constraint::Length(15),
397            Constraint::Length(10),
398            Constraint::Min(20),
399        ];
400        let table = Table::new(table_rows, widths).header(header);
401        frame.render_widget(table, chunks[1]);
402    }
403
404    let status = Paragraph::new(status_line.to_string());
405    frame.render_widget(status, chunks[2]);
406
407    // Messages panel (only shown when enabled)
408    if show_message_log {
409        let messages_title = format!("Messages ({} recent)", message_entries.len());
410        let messages_block = Block::default().borders(Borders::ALL).title(messages_title);
411        let messages_text = if message_entries.is_empty() {
412            "(no recent messages)".to_string()
413        } else {
414            message_entries
415                .iter()
416                .take(MAX_VISIBLE_MESSAGES)
417                .map(|entry| {
418                    format!(
419                        "{} [{}] {}: {}",
420                        entry.timestamp, entry.agent_id, entry.message_type, entry.content
421                    )
422                })
423                .collect::<Vec<_>>()
424                .join("\n")
425        };
426        let messages = Paragraph::new(messages_text).block(messages_block);
427        frame.render_widget(messages, chunks[3]);
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Main loop
433// ---------------------------------------------------------------------------
434
435/// Runs the dashboard TUI, polling broker state on a 1-second tick.
436///
437/// Takes ownership of [`BrokerHandle`] so the broker shuts down automatically
438/// when the dashboard exits. Press `q` to quit, or set `shutdown` to `true`
439/// to trigger a graceful exit (used by the SIGHUP handler when tmux kills the
440/// session).
441///
442/// The dashboard is observation-only: it does not collect human input
443/// beyond the `q`-to-quit keybind. `agent.question` messages flow through
444/// the broker to the supervisor's inbox; the supervisor pane is the
445/// human's input surface for replies (supervisor-as-pane-followups D3).
446pub fn run_dashboard(
447    state: &Arc<BrokerState>,
448    broker_handle: BrokerHandle,
449    shutdown: &std::sync::atomic::AtomicBool,
450) -> Result<(), PawError> {
451    run_dashboard_with_panes(state, broker_handle, shutdown, &HashMap::new(), None, false)
452}
453
454/// Runs the dashboard with an explicit agent ID → tmux pane index map and
455/// session name. Retained for source compatibility with v0.4 launchers, but
456/// `pane_map` and `session_name` are now unused — the prompt-inbox panel
457/// that consumed them was removed in v0.5.0.
458///
459/// `show_message_log` controls whether the broker messages panel is displayed.
460pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
461    state: &Arc<BrokerState>,
462    broker_handle: BrokerHandle,
463    shutdown: &std::sync::atomic::AtomicBool,
464    _pane_map: &HashMap<String, usize, S>,
465    _session_name: Option<&str>,
466    show_message_log: bool,
467) -> Result<(), PawError> {
468    let _broker_handle = broker_handle;
469    // Install a panic hook that restores the terminal before printing the panic.
470    let original_hook = std::panic::take_hook();
471    std::panic::set_hook(Box::new(move |info| {
472        let _ = terminal::disable_raw_mode();
473        let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
474        original_hook(info);
475    }));
476
477    let terminal = setup_terminal()?;
478    let mut guard = TerminalGuard { terminal };
479
480    loop {
481        // Check for SIGHUP-triggered shutdown (e.g. tmux kill-session)
482        if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
483            break;
484        }
485
486        // Drain up to 32 pending input events before re-rendering. Only
487        // `q` (quit) is handled; every other key is silently ignored.
488        for _ in 0..32 {
489            if !event::poll(Duration::ZERO)
490                .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
491            {
492                break;
493            }
494            let ev = event::read()
495                .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
496            if let Event::Key(key) = ev
497                && key.kind == KeyEventKind::Press
498                && should_quit(key.code)
499            {
500                return restore_terminal(&mut guard.terminal);
501            }
502        }
503
504        let agents = delivery::agent_status_snapshot(state);
505        let now = Instant::now();
506        let rows = format_agent_rows(&agents, now);
507        let working = agents.iter().filter(|a| a.status == "working").count();
508        let done = agents
509            .iter()
510            .filter(|a| a.status == "done" || a.status == "verified")
511            .count();
512        let blocked = agents.iter().filter(|a| a.status == "blocked").count();
513        let committed = agents.iter().filter(|a| a.status == "committed").count();
514        let status_line = format_status_line(agents.len(), working, done, blocked, committed);
515
516        // Retrieve recent messages for the messages panel
517        let recent_msgs = delivery::recent_messages(state, MAX_VISIBLE_MESSAGES);
518        let message_entries = format_message_entries(&recent_msgs);
519
520        guard
521            .terminal
522            .draw(|f| {
523                draw_frame(f, &rows, &status_line, &message_entries, show_message_log);
524            })
525            .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
526
527        thread::sleep(TICK_INTERVAL);
528    }
529
530    // Explicit restore for clean exit; guard also restores on drop as a safety net.
531    restore_terminal(&mut guard.terminal)?;
532    Ok(())
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::broker::messages::{
539        ArtifactPayload, BlockedPayload, FeedbackPayload, QuestionPayload, StatusPayload,
540        VerifiedPayload,
541    };
542
543    // -----------------------------------------------------------------------
544    // status_symbol
545    // -----------------------------------------------------------------------
546
547    #[test]
548    fn status_symbol_working() {
549        assert_eq!(status_symbol("working"), "🔵");
550    }
551
552    #[test]
553    fn status_symbol_done() {
554        assert_eq!(status_symbol("done"), "🟢");
555    }
556
557    #[test]
558    fn status_symbol_verified() {
559        assert_eq!(status_symbol("verified"), "🟢");
560    }
561
562    #[test]
563    fn status_symbol_blocked() {
564        assert_eq!(status_symbol("blocked"), "🟡");
565    }
566
567    #[test]
568    fn status_symbol_committed() {
569        assert_eq!(status_symbol("committed"), "🟣");
570    }
571
572    #[test]
573    fn status_symbol_idle() {
574        assert_eq!(status_symbol("idle"), "⚪");
575    }
576
577    #[test]
578    fn status_symbol_unknown() {
579        assert_eq!(status_symbol("something-unexpected"), "⚪");
580    }
581
582    // -----------------------------------------------------------------------
583    // message_type_symbol
584    // -----------------------------------------------------------------------
585
586    #[test]
587    fn message_type_symbol_status() {
588        assert_eq!(message_type_symbol("agent.status"), "📤");
589    }
590
591    #[test]
592    fn message_type_symbol_artifact() {
593        assert_eq!(message_type_symbol("agent.artifact"), "📦");
594    }
595
596    #[test]
597    fn message_type_symbol_blocked() {
598        assert_eq!(message_type_symbol("agent.blocked"), "🚧");
599    }
600
601    #[test]
602    fn message_type_symbol_verified() {
603        assert_eq!(message_type_symbol("agent.verified"), "✅");
604    }
605
606    #[test]
607    fn message_type_symbol_feedback() {
608        assert_eq!(message_type_symbol("agent.feedback"), "💬");
609    }
610
611    #[test]
612    fn message_type_symbol_question() {
613        assert_eq!(message_type_symbol("agent.question"), "❓");
614    }
615
616    #[test]
617    fn message_type_symbol_unknown() {
618        assert_eq!(message_type_symbol("agent.unknown"), "📄");
619    }
620
621    // -----------------------------------------------------------------------
622    // format_message_entry
623    // -----------------------------------------------------------------------
624
625    #[test]
626    fn format_message_entry_status() {
627        let msg = BrokerMessage::Status {
628            agent_id: "feat-errors".to_string(),
629            payload: StatusPayload {
630                status: "working".to_string(),
631                modified_files: vec!["src/main.rs".to_string()],
632                message: Some("refactoring".to_string()),
633                ..Default::default()
634            },
635        };
636        let entry = format_message_entry(1, std::time::SystemTime::now(), &msg);
637        assert_eq!(entry.agent_id, "feat-errors");
638        assert!(entry.message_type.contains("📤 status"));
639        assert!(entry.content.contains("[feat-errors] status: working"));
640    }
641
642    #[test]
643    fn format_message_entry_artifact() {
644        let msg = BrokerMessage::Artifact {
645            agent_id: "feat-errors".to_string(),
646            payload: ArtifactPayload {
647                status: "done".to_string(),
648                exports: vec!["PawError".to_string()],
649                modified_files: vec!["src/error.rs".to_string()],
650            },
651        };
652        let entry = format_message_entry(2, std::time::SystemTime::now(), &msg);
653        assert_eq!(entry.agent_id, "feat-errors");
654        assert!(entry.message_type.contains("📦 artifact"));
655        assert!(entry.content.contains("[feat-errors] artifact: done"));
656    }
657
658    #[test]
659    fn format_message_entries_empty() {
660        let entries = format_message_entries(&[]);
661        assert!(entries.is_empty());
662    }
663
664    #[test]
665    fn format_message_entries_multiple() {
666        let msg1 = BrokerMessage::Status {
667            agent_id: "feat-a".to_string(),
668            payload: StatusPayload {
669                status: "working".to_string(),
670                modified_files: vec![],
671                message: None,
672                ..Default::default()
673            },
674        };
675        let msg2 = BrokerMessage::Artifact {
676            agent_id: "feat-b".to_string(),
677            payload: ArtifactPayload {
678                status: "done".to_string(),
679                exports: vec![],
680                modified_files: vec![],
681            },
682        };
683        let messages = vec![
684            (1, std::time::SystemTime::now(), msg1),
685            (2, std::time::SystemTime::now(), msg2),
686        ];
687        let entries = format_message_entries(&messages);
688        assert_eq!(entries.len(), 2);
689        assert_eq!(entries[0].agent_id, "feat-a");
690        assert_eq!(entries[1].agent_id, "feat-b");
691    }
692
693    #[test]
694    fn format_message_entries_all_types() {
695        let messages = vec![
696            (
697                1,
698                std::time::SystemTime::now(),
699                BrokerMessage::Status {
700                    agent_id: "feat-a".to_string(),
701                    payload: StatusPayload {
702                        status: "working".to_string(),
703                        modified_files: vec![],
704                        message: None,
705                        ..Default::default()
706                    },
707                },
708            ),
709            (
710                2,
711                std::time::SystemTime::now(),
712                BrokerMessage::Artifact {
713                    agent_id: "feat-b".to_string(),
714                    payload: ArtifactPayload {
715                        status: "done".to_string(),
716                        exports: vec![],
717                        modified_files: vec![],
718                    },
719                },
720            ),
721            (
722                3,
723                std::time::SystemTime::now(),
724                BrokerMessage::Blocked {
725                    agent_id: "feat-c".to_string(),
726                    payload: BlockedPayload {
727                        needs: "types".to_string(),
728                        from: "feat-b".to_string(),
729                    },
730                },
731            ),
732            (
733                4,
734                std::time::SystemTime::now(),
735                BrokerMessage::Verified {
736                    agent_id: "feat-d".to_string(),
737                    payload: VerifiedPayload {
738                        verified_by: "supervisor".to_string(),
739                        message: None,
740                    },
741                },
742            ),
743            (
744                5,
745                std::time::SystemTime::now(),
746                BrokerMessage::Feedback {
747                    agent_id: "feat-e".to_string(),
748                    payload: FeedbackPayload {
749                        from: "supervisor".to_string(),
750                        errors: vec!["error".to_string()],
751                    },
752                },
753            ),
754            (
755                6,
756                std::time::SystemTime::now(),
757                BrokerMessage::Question {
758                    agent_id: "feat-f".to_string(),
759                    payload: QuestionPayload {
760                        question: "question?".to_string(),
761                    },
762                },
763            ),
764        ];
765
766        let entries = format_message_entries(&messages);
767        assert_eq!(entries.len(), 6);
768
769        // Verify all message types are represented
770        let type_symbols: Vec<&str> = entries
771            .iter()
772            .map(|entry| entry.message_type.split(' ').next().unwrap())
773            .collect();
774        assert!(type_symbols.contains(&"📤")); // status
775        assert!(type_symbols.contains(&"📦")); // artifact
776        assert!(type_symbols.contains(&"🚧")); // blocked
777        assert!(type_symbols.contains(&"✅")); // verified
778        assert!(type_symbols.contains(&"💬")); // feedback
779        assert!(type_symbols.contains(&"❓")); // question
780    }
781
782    // -----------------------------------------------------------------------
783    // format_age
784    // -----------------------------------------------------------------------
785
786    #[test]
787    fn format_age_zero_seconds() {
788        assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
789    }
790
791    #[test]
792    fn format_age_thirty_seconds() {
793        assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
794    }
795
796    #[test]
797    fn format_age_three_minutes() {
798        assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
799    }
800
801    #[test]
802    fn format_age_one_hour_exact() {
803        assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
804    }
805
806    #[test]
807    fn format_age_one_hour_fifteen_minutes() {
808        assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
809    }
810
811    // -----------------------------------------------------------------------
812    // format_agent_rows
813    // -----------------------------------------------------------------------
814
815    #[test]
816    fn format_agent_rows_three_agents() {
817        let now = Instant::now();
818        let agents = vec![
819            AgentStatusEntry {
820                agent_id: "feat-a".to_string(),
821                cli: "claude".to_string(),
822                status: "working".to_string(),
823                last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
824                last_seen_seconds: 10,
825                summary: "msg a".to_string(),
826                phase: None,
827            },
828            AgentStatusEntry {
829                agent_id: "feat-b".to_string(),
830                cli: "cursor".to_string(),
831                status: "done".to_string(),
832                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
833                last_seen_seconds: 60,
834                summary: "msg b".to_string(),
835                phase: None,
836            },
837            AgentStatusEntry {
838                agent_id: "feat-c".to_string(),
839                cli: "claude".to_string(),
840                status: "blocked".to_string(),
841                last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
842                last_seen_seconds: 300,
843                summary: String::new(),
844                phase: None,
845            },
846        ];
847        let rows = format_agent_rows(&agents, now);
848        assert_eq!(rows.len(), 3);
849        assert_eq!(rows[0].agent_id, "feat-a");
850        assert_eq!(rows[1].agent_id, "feat-b");
851        assert_eq!(rows[2].agent_id, "feat-c");
852    }
853
854    #[test]
855    fn format_agent_rows_single_done_three_minutes() {
856        let now = Instant::now();
857        let agents = vec![AgentStatusEntry {
858            agent_id: "feat-errors".to_string(),
859            cli: "claude".to_string(),
860            status: "done".to_string(),
861            last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
862            last_seen_seconds: 180,
863            summary: "finished".to_string(),
864            phase: None,
865        }];
866        let rows = format_agent_rows(&agents, now);
867        assert_eq!(rows.len(), 1);
868        assert_eq!(rows[0].agent_id, "feat-errors");
869        assert_eq!(rows[0].age, "3m ago");
870        assert!(rows[0].status.contains("done"));
871    }
872
873    #[test]
874    fn format_agent_rows_with_committed_status() {
875        let now = Instant::now();
876        let agents = vec![
877            AgentStatusEntry {
878                agent_id: "feat-committed".to_string(),
879                cli: "claude".to_string(),
880                status: "committed".to_string(),
881                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
882                last_seen_seconds: 60,
883                summary: "changes committed".to_string(),
884                phase: None,
885            },
886            AgentStatusEntry {
887                agent_id: "feat-working".to_string(),
888                cli: "cursor".to_string(),
889                status: "working".to_string(),
890                last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
891                last_seen_seconds: 30,
892                summary: "in progress".to_string(),
893                phase: None,
894            },
895        ];
896        let rows = format_agent_rows(&agents, now);
897        assert_eq!(rows.len(), 2);
898
899        // Find the committed agent and verify it has the correct symbol
900        let committed_row = rows
901            .iter()
902            .find(|r| r.agent_id == "feat-committed")
903            .unwrap();
904        assert!(committed_row.status.contains("🟣"));
905        assert!(committed_row.status.contains("committed"));
906
907        // Find the working agent and verify it has the correct symbol
908        let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
909        assert!(working_row.status.contains("🔵"));
910        assert!(working_row.status.contains("working"));
911    }
912
913    #[test]
914    fn format_agent_rows_empty_input() {
915        let rows = format_agent_rows(&[], Instant::now());
916        assert!(rows.is_empty());
917    }
918
919    // -----------------------------------------------------------------------
920    // Phase-aware status rendering (tasks 5.4, 5.5)
921    // -----------------------------------------------------------------------
922
923    #[test]
924    fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
925        let now = Instant::now();
926        let agents = vec![AgentStatusEntry {
927            agent_id: "supervisor".to_string(),
928            cli: "claude".to_string(),
929            status: "feedback".to_string(),
930            last_seen: now,
931            last_seen_seconds: 0,
932            summary: String::new(),
933            phase: Some("merging".to_string()),
934        }];
935        let rows = format_agent_rows(&agents, now);
936        assert_eq!(rows.len(), 1);
937        assert!(
938            rows[0].status.contains("merging"),
939            "expected phase 'merging' in status field; got {:?}",
940            rows[0].status,
941        );
942        assert!(
943            !rows[0].status.contains("feedback"),
944            "phase must replace status label, not append; got {:?}",
945            rows[0].status,
946        );
947    }
948
949    #[test]
950    fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
951        let now = Instant::now();
952        let agents = vec![AgentStatusEntry {
953            agent_id: "feat-broker".to_string(),
954            cli: "claude".to_string(),
955            status: "working".to_string(),
956            last_seen: now,
957            last_seen_seconds: 0,
958            summary: String::new(),
959            phase: None,
960        }];
961        let rows = format_agent_rows(&agents, now);
962        assert!(
963            rows[0].status.contains("working"),
964            "expected 'working' in status field; got {:?}",
965            rows[0].status,
966        );
967    }
968
969    // -----------------------------------------------------------------------
970    // arrange_with_supervisor_pinned (tasks 4.4 - 4.6)
971    // -----------------------------------------------------------------------
972
973    fn agent_row(id: &str) -> AgentRow {
974        AgentRow {
975            agent_id: id.to_string(),
976            cli: "claude".to_string(),
977            status: "🔵 working".to_string(),
978            age: "0s ago".to_string(),
979            summary: String::new(),
980        }
981    }
982
983    #[test]
984    fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
985        let rows = vec![
986            agent_row("feat-broker"),
987            agent_row("feat-dashboard"),
988            agent_row("supervisor"),
989        ];
990        let arranged = arrange_with_supervisor_pinned(rows);
991        assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
992        assert!(
993            matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
994            "supervisor must be at row 0; got {:?}",
995            arranged[0]
996        );
997        assert_eq!(
998            arranged[1],
999            AgentTableRow::Divider,
1000            "divider must immediately follow supervisor"
1001        );
1002        assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
1003        assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
1004    }
1005
1006    #[test]
1007    fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
1008        let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
1009        let arranged = arrange_with_supervisor_pinned(rows);
1010        assert_eq!(arranged.len(), 2);
1011        for row in &arranged {
1012            assert!(
1013                !matches!(row, AgentTableRow::Divider),
1014                "no divider when supervisor is absent; got {row:?}"
1015            );
1016        }
1017        assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
1018        assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
1019    }
1020
1021    #[test]
1022    fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
1023        let arranged = arrange_with_supervisor_pinned(Vec::new());
1024        assert!(arranged.is_empty());
1025    }
1026
1027    #[test]
1028    fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
1029        use ratatui::Terminal;
1030        use ratatui::backend::TestBackend;
1031
1032        // Construct three formatted rows with snapshot already in
1033        // alphabetical order (this matches what agent_status_snapshot
1034        // emits before pinning). The pinning happens inside draw_frame.
1035        let rows = vec![
1036            agent_row("feat-broker"),
1037            agent_row("feat-dashboard"),
1038            agent_row("supervisor"),
1039        ];
1040
1041        let backend = TestBackend::new(140, 30);
1042        let mut terminal = Terminal::new(backend).unwrap();
1043        terminal
1044            .draw(|f| draw_frame(f, &rows, "3 agents", &[], false))
1045            .unwrap();
1046
1047        // Flatten the buffer to a single string so we can check row order
1048        // by substring positions across the rendered output.
1049        let buffer = terminal.backend().buffer().clone();
1050        let mut rendered = String::new();
1051        for y in 0..buffer.area.height {
1052            for x in 0..buffer.area.width {
1053                rendered.push_str(buffer[(x, y)].symbol());
1054            }
1055            rendered.push('\n');
1056        }
1057
1058        let pos_supervisor = rendered
1059            .find("supervisor")
1060            .expect("supervisor row should be in rendered frame");
1061        let pos_broker = rendered
1062            .find("feat-broker")
1063            .expect("feat-broker row should be in rendered frame");
1064        let pos_dashboard = rendered
1065            .find("feat-dashboard")
1066            .expect("feat-dashboard row should be in rendered frame");
1067        assert!(
1068            pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
1069            "supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
1070        );
1071
1072        // A divider row containing horizontal-line characters appears
1073        // between the supervisor row and the first coding-agent row.
1074        let pos_divider = rendered[pos_supervisor..]
1075            .find('─')
1076            .map(|p| pos_supervisor + p)
1077            .expect("divider row should contain horizontal-line characters");
1078        assert!(
1079            pos_divider > pos_supervisor && pos_divider < pos_broker,
1080            "divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
1081        );
1082    }
1083
1084    // -----------------------------------------------------------------------
1085    // format_status_line
1086    // -----------------------------------------------------------------------
1087
1088    #[test]
1089    fn format_status_line_mixed() {
1090        assert_eq!(
1091            format_status_line(4, 2, 1, 1, 0),
1092            "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1093        );
1094    }
1095
1096    #[test]
1097    fn format_status_line_all_done() {
1098        assert_eq!(
1099            format_status_line(3, 0, 3, 0, 0),
1100            "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1101        );
1102    }
1103
1104    #[test]
1105    fn format_status_line_zero_agents() {
1106        assert_eq!(
1107            format_status_line(0, 0, 0, 0, 0),
1108            "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1109        );
1110    }
1111
1112    #[test]
1113    fn format_status_line_with_committed() {
1114        assert_eq!(
1115            format_status_line(5, 2, 1, 1, 1),
1116            "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1117        );
1118    }
1119
1120    // -----------------------------------------------------------------------
1121    // Prompt-inbox removal (tasks 6.8, 6.9)
1122    // -----------------------------------------------------------------------
1123
1124    #[test]
1125    fn rendered_frame_contains_no_questions_or_reply_input() {
1126        use ratatui::Terminal;
1127        use ratatui::backend::TestBackend;
1128
1129        let backend = TestBackend::new(140, 30);
1130        let mut terminal = Terminal::new(backend).unwrap();
1131        terminal
1132            .draw(|f| draw_frame(f, &[], "0 agents", &[], false))
1133            .unwrap();
1134
1135        let buffer = terminal.backend().buffer().clone();
1136        let mut rendered = String::new();
1137        for y in 0..buffer.area.height {
1138            for x in 0..buffer.area.width {
1139                rendered.push_str(buffer[(x, y)].symbol());
1140            }
1141            rendered.push('\n');
1142        }
1143
1144        assert!(
1145            !rendered.contains("Questions ("),
1146            "dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
1147        );
1148        assert!(
1149            !rendered.contains("Reply to"),
1150            "dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
1151        );
1152    }
1153
1154    // supervisor-as-pane[-followups] dashboard input contract.
1155    //
1156    // After the prompt-inbox removal in v0.5.0 the dashboard has no
1157    // focused-question or input-buffer state. The tests below assert the
1158    // ignored-input contract for the keys most likely to confuse a user
1159    // who remembers the pre-removal shape (Tab to focus, printable chars
1160    // to type into a buffer).
1161
1162    #[test]
1163    fn tab_key_ignored_no_buffer() {
1164        // Tab is not a quit key — the handler must ignore it. There is no
1165        // observable side effect to assert beyond `should_quit` returning
1166        // false, because the dashboard has no buffer or focus state for
1167        // Tab to mutate.
1168        assert!(
1169            !should_quit(KeyCode::Tab),
1170            "Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
1171        );
1172    }
1173
1174    #[test]
1175    fn printable_char_ignored_no_buffer() {
1176        // Printable characters other than `q` must be ignored — the
1177        // dashboard has no buffer to accumulate them into.
1178        assert!(
1179            !should_quit(KeyCode::Char('a')),
1180            "printable char 'a' must not quit and must not accumulate into any buffer",
1181        );
1182        assert!(
1183            !should_quit(KeyCode::Char(' ')),
1184            "space must not quit and must not accumulate into any buffer",
1185        );
1186        // Sanity-check the positive case so the test really exercises the
1187        // handler contract and not just a constant false.
1188        assert!(
1189            should_quit(KeyCode::Char('q')),
1190            "lowercase 'q' must quit the dashboard",
1191        );
1192    }
1193
1194    #[test]
1195    fn layout_collapses_without_message_log() {
1196        // With show_message_log = false the layout is three segments
1197        // (title, agent table, status line). The pre-inbox-removal shape
1198        // had 5 or 6 segments — a regression to that would imply the
1199        // prompt-inbox panel is back.
1200        let constraints = build_layout_constraints(false);
1201        assert_eq!(
1202            constraints.len(),
1203            3,
1204            "layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
1205            constraints.len(),
1206        );
1207
1208        // With show_message_log = true the layout adds the messages
1209        // panel as a 4th segment. Asserting both shapes catches the case
1210        // where the helper accidentally drops the messages panel or
1211        // grows a spurious 5th segment.
1212        let with_log = build_layout_constraints(true);
1213        assert_eq!(
1214            with_log.len(),
1215            4,
1216            "layout with message log must be exactly 4 segments, got {} constraints",
1217            with_log.len(),
1218        );
1219    }
1220}