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
7pub mod broker_log;
8
9use std::collections::HashMap;
10use std::io::{self, Stdout};
11use std::sync::Arc;
12use std::thread;
13use std::time::{Duration, Instant};
14
15use crossterm::event::{self, Event, KeyCode, KeyEventKind};
16use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
17use ratatui::Frame;
18use ratatui::Terminal;
19use ratatui::backend::CrosstermBackend;
20use ratatui::layout::{Alignment, Constraint, Layout};
21use ratatui::style::{Modifier, Style};
22use ratatui::widgets::{Paragraph, Row, Table};
23
24use crate::broker::delivery;
25use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
26use crate::dashboard::broker_log::{BrokerLog, LogKeyAction};
27use crate::error::PawError;
28
29/// Tick interval for the dashboard draw loop.
30///
31/// Also bounds the worst-case typing latency: any keystroke that arrives
32/// mid-sleep is picked up on the next tick. 50ms is comfortably below the
33/// ~100ms perceptual threshold for interactive UIs while keeping the
34/// broker-state snapshot rate modest (~20 Hz against an in-process lock).
35const TICK_INTERVAL: Duration = Duration::from_millis(50);
36
37/// Placeholder shown in the agent table's CLI column when an agent's CLI
38/// cannot be resolved (neither its `agent.status` payload nor the seeded
39/// `agent_clis` map names one). A visible `"?"` reads as "unknown" rather
40/// than a blank cell that looks like a rendering bug (W15-15).
41const UNKNOWN_CLI: &str = "?";
42
43/// The `agent_id` of the supervisor's pinned row. The supervisor is the only
44/// publisher whose `phase` introspection field is surfaced unconditionally in
45/// the agent table (see [`format_agent_rows`]).
46const SUPERVISOR_AGENT_ID: &str = "supervisor";
47
48/// The one `phase` value the dashboard honours on a *non-supervisor* row.
49///
50/// `detect-stuck` (the bundled sweep helper) publishes a synthetic
51/// `agent.status` with `phase = "stuck-on-prompt"` *targeting the stalled
52/// coding agent's row* so the stall is visible there without scraping panes.
53/// This is a supervisor-authored alert about the subject agent, not the coding
54/// agent's own introspection, so it is the documented exception to the
55/// "phase is supervisor-only" rule in the `supervisor-introspection`
56/// capability.
57const STUCK_ON_PROMPT_PHASE: &str = "stuck-on-prompt";
58
59/// A formatted row for display in the agent status table.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AgentRow {
62    /// The agent identifier (slugified branch name).
63    pub agent_id: String,
64    /// The CLI name (e.g. `"claude"`).
65    pub cli: String,
66    /// Status symbol and label (e.g. `"๐Ÿ”ต working"`).
67    pub status: String,
68    /// Relative time since last message (e.g. `"3m ago"`).
69    pub age: String,
70}
71
72/// Maps an agent status label to a Unicode symbol.
73///
74/// | Input | Output |
75/// |---|---|
76/// | `"working"` | `"๐Ÿ”ต"` |
77/// | `"done"` | `"๐ŸŸข"` |
78/// | `"verified"` | `"๐ŸŸข"` |
79/// | `"committed"` | `"๐ŸŸฃ"` |
80/// | `"blocked"` | `"๐ŸŸก"` |
81/// | anything else | `"โšช"` |
82pub fn status_symbol(status: &str) -> &'static str {
83    match status {
84        "working" => "๐Ÿ”ต",
85        "done" | "verified" => "๐ŸŸข",
86        "committed" => "๐ŸŸฃ",
87        "blocked" => "๐ŸŸก",
88        _ => "โšช",
89    }
90}
91
92/// Formats an elapsed duration as a human-readable relative time string.
93///
94/// - Less than 60 seconds: `"Xs ago"` (e.g. `"30s ago"`)
95/// - 1 to 59 minutes: `"Xm ago"` (e.g. `"3m ago"`)
96/// - 60 minutes or more: `"Xh Ym ago"` (e.g. `"1h 15m ago"`)
97pub fn format_age(elapsed: Duration) -> String {
98    let secs = elapsed.as_secs();
99    if secs < 60 {
100        format!("{secs}s ago")
101    } else if secs < 3600 {
102        let mins = secs / 60;
103        format!("{mins}m ago")
104    } else {
105        let hours = secs / 3600;
106        let mins = (secs % 3600) / 60;
107        format!("{hours}h {mins}m ago")
108    }
109}
110
111/// Converts raw agent status entries into formatted display rows.
112///
113/// The `phase` introspection field is the supervisor's lifecycle surface
114/// (`supervisor-introspection` capability): when present on the supervisor
115/// row, the status field renders that phase (with the matching status symbol)
116/// instead of the message-type-derived label โ€” labels like `"feedback"` (the
117/// wire message type) are misleading, and the real lifecycle phase is `"sweep"`,
118/// `"audit"`, `"merge"`, etc.
119///
120/// `phase` is honoured **only** for the supervisor row. A non-supervisor row
121/// ignores its `phase` and renders the message-type-derived status label โ€”
122/// coding agents do not emit introspection phases in v0.6.0. The single
123/// exception is the supervisor-published [`STUCK_ON_PROMPT_PHASE`] alert, which
124/// `detect-stuck` targets at the stalled coding agent's row by design.
125///
126/// Pure function: performs no I/O, holds no locks, and is deterministic
127/// given the same inputs.
128pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
129    agents
130        .iter()
131        .map(|agent| {
132            let elapsed = now.saturating_duration_since(agent.last_seen);
133            // Surface `phase` for the supervisor row, plus the one
134            // supervisor-authored `stuck-on-prompt` alert that targets a
135            // coding agent's row. Every other non-supervisor phase is ignored.
136            let honour_phase = agent.agent_id == SUPERVISOR_AGENT_ID
137                || agent.phase.as_deref() == Some(STUCK_ON_PROMPT_PHASE);
138            let label = match agent.phase.as_deref() {
139                Some(phase) if honour_phase => phase,
140                _ => &agent.status,
141            };
142            let symbol = status_symbol(label);
143            let cli = if agent.cli.trim().is_empty() {
144                UNKNOWN_CLI.to_string()
145            } else {
146                agent.cli.clone()
147            };
148            AgentRow {
149                agent_id: agent.agent_id.clone(),
150                cli,
151                status: format!("{symbol} {label}"),
152                age: format_age(elapsed),
153            }
154        })
155        .collect()
156}
157
158/// One entry in the dashboard's agent table, either an agent row or a
159/// visual divider rendered between the pinned supervisor row and the
160/// coding-agent rows beneath it.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum AgentTableRow {
163    /// A normal agent row.
164    Agent(AgentRow),
165    /// A divider separating the pinned supervisor row from coding-agent rows.
166    Divider,
167}
168
169/// Reorders a slice of `AgentRow` so the supervisor row (if present) is
170/// pinned to position 0, followed by a [`AgentTableRow::Divider`], with
171/// the remaining coding-agent rows in their incoming (alphabetical) order.
172///
173/// When no row has `agent_id == "supervisor"`, the output preserves the
174/// incoming order and contains no divider.
175///
176/// Pure function: no I/O, no locks, deterministic.
177pub fn arrange_with_supervisor_pinned(rows: Vec<AgentRow>) -> Vec<AgentTableRow> {
178    let mut supervisor: Option<AgentRow> = None;
179    let mut coding: Vec<AgentRow> = Vec::with_capacity(rows.len());
180    for row in rows {
181        if row.agent_id == "supervisor" {
182            supervisor = Some(row);
183        } else {
184            coding.push(row);
185        }
186    }
187
188    let mut out: Vec<AgentTableRow> = Vec::with_capacity(coding.len() + 2);
189    if let Some(sup) = supervisor {
190        out.push(AgentTableRow::Agent(sup));
191        out.push(AgentTableRow::Divider);
192    }
193    out.extend(coding.into_iter().map(AgentTableRow::Agent));
194    out
195}
196
197/// Produces a summary status line for the dashboard footer.
198///
199/// Returns a string like `"5 agents: 2 working, 1 done, 1 blocked, 1 committed"`.
200pub fn format_status_line(
201    total: usize,
202    working: usize,
203    done: usize,
204    blocked: usize,
205    committed: usize,
206) -> String {
207    format!(
208        "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
209    )
210}
211
212// ---------------------------------------------------------------------------
213// Terminal lifecycle
214// ---------------------------------------------------------------------------
215
216/// Guard that restores the terminal on drop, ensuring cleanup even on panic
217/// or early return.
218struct TerminalGuard {
219    terminal: Terminal<CrosstermBackend<Stdout>>,
220}
221
222impl Drop for TerminalGuard {
223    fn drop(&mut self) {
224        let _ = terminal::disable_raw_mode();
225        let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
226        let _ = self.terminal.show_cursor();
227    }
228}
229
230/// Enters raw mode and the alternate screen, returning a configured terminal.
231fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
232    terminal::enable_raw_mode()
233        .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
234    crossterm::execute!(io::stdout(), EnterAlternateScreen)
235        .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
236    Terminal::new(CrosstermBackend::new(io::stdout()))
237        .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
238}
239
240/// Disables raw mode, leaves the alternate screen, and shows the cursor.
241fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
242    terminal::disable_raw_mode()
243        .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
244    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
245        .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
246    terminal
247        .show_cursor()
248        .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
249}
250
251// ---------------------------------------------------------------------------
252// Draw
253// ---------------------------------------------------------------------------
254
255/// Renders one frame of the dashboard TUI to the given `Frame`.
256///
257/// Public wrapper around the internal `draw_frame` so integration tests can
258/// drive a real frame with `ratatui::backend::TestBackend` and assert against
259/// the resulting buffer. `panel_height` is the visible Broker log panel's row
260/// count (from `[dashboard.broker_log] height_lines`).
261pub fn render_dashboard(
262    frame: &mut Frame,
263    rows: &[AgentRow],
264    status_line: &str,
265    broker_log: &BrokerLog,
266    panel_height: u16,
267) {
268    draw_frame(frame, rows, status_line, broker_log, panel_height);
269}
270
271/// Minimum number of rows the agent-status table keeps when the Broker log
272/// panel is visible (header plus a few agent rows). Expressed as a `Min`
273/// constraint so the table still absorbs the terminal's slack on tall
274/// terminals, but on a terminal too short to grant both their full heights
275/// ratatui shrinks the panel's `Length` before driving the table below this
276/// floor โ€” the enlarged panel cannot starve the table.
277pub(crate) const MIN_AGENT_TABLE_HEIGHT: u16 = 6;
278
279/// Returns the vertical layout constraints for the dashboard frame.
280///
281/// `show_panel = false` (the v0.5.0 layout after the prompt-inbox removal)
282/// produces a three-segment layout: title, agent table, status line. This is
283/// the byte-equivalent baseline the Broker log panel must reproduce when
284/// hidden. `show_panel = true` appends a fourth segment for the Broker log
285/// panel, sized to `panel_height` rows (from `[dashboard.broker_log]
286/// height_lines`, default `20` โ€” materially larger than the v0.6.0 fixed
287/// `12`).
288pub(crate) fn build_layout_constraints(show_panel: bool, panel_height: u16) -> Vec<Constraint> {
289    if show_panel {
290        vec![
291            Constraint::Length(1),                   // title
292            Constraint::Min(MIN_AGENT_TABLE_HEIGHT), // agent table
293            Constraint::Length(1),                   // status line
294            Constraint::Length(panel_height),        // broker log panel
295        ]
296    } else {
297        vec![
298            Constraint::Length(1), // title
299            Constraint::Min(0),    // agent table
300            Constraint::Length(1), // status line
301        ]
302    }
303}
304
305/// Returns true when the given key code should terminate the dashboard
306/// event loop. Only `q` (lowercase, no modifiers) quits; every other key
307/// โ€” including `Tab`, printable characters, and arrow keys โ€” is ignored.
308///
309/// The supervisor-as-pane removal (v0.5.0) deleted the prompt inbox, so
310/// the dashboard has no input buffer to accumulate characters into and
311/// no focusable element for `Tab` to advance through.
312pub(crate) fn should_quit(code: KeyCode) -> bool {
313    matches!(code, KeyCode::Char('q'))
314}
315
316/// Renders one frame of the dashboard TUI. `panel_height` sizes the visible
317/// Broker log panel's segment (from `[dashboard.broker_log] height_lines`).
318fn draw_frame(
319    frame: &mut Frame,
320    rows: &[AgentRow],
321    status_line: &str,
322    broker_log: &BrokerLog,
323    panel_height: u16,
324) {
325    // The prompt-inbox panel was removed in v0.5.0 (supervisor-as-pane-
326    // followups D3). The supervisor pane is the human's input surface for
327    // replying to `agent.question` events; the dashboard is observation-
328    // only. v0.6.0 fills the freed region with the Broker log panel when
329    // `broker_log.visible`; when hidden the layout is byte-equivalent to
330    // the v0.5.0 three-segment shape.
331    let layout_constraints = build_layout_constraints(broker_log.visible, panel_height);
332
333    let chunks = Layout::vertical(layout_constraints).split(frame.area());
334
335    let title =
336        Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
337    frame.render_widget(title, chunks[0]);
338
339    if rows.is_empty() {
340        let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
341        frame.render_widget(empty, chunks[1]);
342    } else {
343        let header = Row::new(["Agent", "CLI", "Status", "Last Update"])
344            .style(Style::default().add_modifier(Modifier::BOLD));
345        // Pin the supervisor row to row 0 and insert a divider beneath it
346        // before rendering. The arrangement is computed from the same
347        // `rows` slice rather than reaching back into the snapshot โ€”
348        // tests can verify the ordering against `arrange_with_supervisor_pinned`
349        // independently of ratatui internals.
350        let arranged = arrange_with_supervisor_pinned(rows.to_vec());
351        let divider_segment = "โ”€".repeat(20);
352        let table_rows: Vec<Row> = arranged
353            .iter()
354            .map(|entry| match entry {
355                AgentTableRow::Agent(r) => Row::new(vec![
356                    r.agent_id.clone(),
357                    r.cli.clone(),
358                    r.status.clone(),
359                    r.age.clone(),
360                ]),
361                AgentTableRow::Divider => Row::new(vec![
362                    divider_segment.clone(),
363                    divider_segment.clone(),
364                    divider_segment.clone(),
365                    divider_segment.clone(),
366                ])
367                .style(Style::default().add_modifier(Modifier::DIM)),
368            })
369            .collect();
370        let widths = [
371            Constraint::Min(15),
372            Constraint::Length(10),
373            Constraint::Length(15),
374            // Wide enough to render the full "Last Update" header label (11
375            // chars) and relative-time values like "1h 15m ago" without
376            // truncation โ€” space reclaimed from the dropped Summary column.
377            Constraint::Length(12),
378        ];
379        let table = Table::new(table_rows, widths).header(header);
380        frame.render_widget(table, chunks[1]);
381    }
382
383    // When the Broker log panel is hidden, its title bar (which documents the
384    // `l` toggle) is gone, so append a one-line restore hint to the always-
385    // present status line. The agent-table/segment layout stays byte-identical
386    // to v0.5.0 โ€” only the status text gains the suffix.
387    let status_text = if broker_log.visible {
388        status_line.to_string()
389    } else {
390        format!("{status_line}  ยท  broker log hidden โ€” press l to show")
391    };
392    let status = Paragraph::new(status_text);
393    frame.render_widget(status, chunks[2]);
394
395    // Broker log panel: occupies the v0.5.0-freed inbox region when visible.
396    // When hidden there is no fourth chunk, so the layout above is identical
397    // to v0.5.0's three-segment shape (spec: "Hidden layout matches v0.5.0").
398    if broker_log.visible {
399        broker_log::render(frame, chunks[3], broker_log);
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Main loop
405// ---------------------------------------------------------------------------
406
407/// Runs the dashboard TUI, polling broker state on a 1-second tick.
408///
409/// Takes ownership of [`BrokerHandle`] so the broker shuts down automatically
410/// when the dashboard exits. Press `q` to quit, or set `shutdown` to `true`
411/// to trigger a graceful exit (used by the SIGHUP handler when tmux kills the
412/// session).
413///
414/// The dashboard is observation-only: it does not collect human input
415/// beyond the `q`-to-quit keybind. `agent.question` messages flow through
416/// the broker to the supervisor's inbox; the supervisor pane is the
417/// human's input surface for replies (supervisor-as-pane-followups D3).
418pub fn run_dashboard(
419    state: &Arc<BrokerState>,
420    broker_handle: BrokerHandle,
421    shutdown: &std::sync::atomic::AtomicBool,
422) -> Result<(), PawError> {
423    run_dashboard_with_panes(
424        state,
425        broker_handle,
426        shutdown,
427        &HashMap::new(),
428        None,
429        500,
430        false,
431        crate::config::BrokerLogConfig::default().height_lines,
432    )
433}
434
435/// Runs the dashboard with an explicit agent ID โ†’ tmux pane index map and
436/// session name. Retained for source compatibility with v0.4 launchers, but
437/// `pane_map` and `session_name` are now unused โ€” the prompt-inbox panel
438/// that consumed them was removed in v0.5.0.
439///
440/// `max_messages` caps the Broker log panel's ring buffer, `default_visible`
441/// sets its initial visibility, and `height_lines` sizes the visible panel's
442/// vertical segment (all from `[dashboard.broker_log]`).
443// Launcher seam: the three broker-log scalars are plumbed individually
444// (alongside the retained-for-compat pane_map/session_name params) rather than
445// bundled, matching the existing call style.
446#[allow(clippy::too_many_arguments)]
447pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
448    state: &Arc<BrokerState>,
449    broker_handle: BrokerHandle,
450    shutdown: &std::sync::atomic::AtomicBool,
451    _pane_map: &HashMap<String, usize, S>,
452    _session_name: Option<&str>,
453    max_messages: usize,
454    default_visible: bool,
455    height_lines: u16,
456) -> Result<(), PawError> {
457    let _broker_handle = broker_handle;
458    // Install a panic hook that restores the terminal before printing the panic.
459    let original_hook = std::panic::take_hook();
460    std::panic::set_hook(Box::new(move |info| {
461        let _ = terminal::disable_raw_mode();
462        let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
463        original_hook(info);
464    }));
465
466    let terminal = setup_terminal()?;
467    let mut guard = TerminalGuard { terminal };
468
469    // The Broker log ring buffer is owned by the dashboard process for its
470    // whole lifetime. It is fed each tick from the broker's in-process
471    // message log via a monotonic seq cursor and is never cleared, so a
472    // transient broker-watcher restart leaves history intact (design.md D8).
473    let mut broker_log = BrokerLog::new(max_messages, default_visible);
474
475    loop {
476        // Check for SIGHUP-triggered shutdown (e.g. tmux kill-session)
477        if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
478            break;
479        }
480
481        // Drain up to 32 pending input events before re-rendering. `q` quits;
482        // the Broker log panel claims its own keys (l / a / 1-9 / Up / Down /
483        // Enter / Esc); everything else is ignored.
484        for _ in 0..32 {
485            if !event::poll(Duration::ZERO)
486                .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
487            {
488                break;
489            }
490            let ev = event::read()
491                .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
492            if let Event::Key(key) = ev
493                && key.kind == KeyEventKind::Press
494            {
495                // Offer the key to the panel first. It returns `Ignored` for
496                // keys it does not own (notably `q`), which then fall through
497                // to the quit check.
498                if broker_log::handle_key(&mut broker_log, key.code) == LogKeyAction::Ignored
499                    && should_quit(key.code)
500                {
501                    return restore_terminal(&mut guard.terminal);
502                }
503            }
504        }
505
506        let agents = delivery::agent_status_snapshot(state);
507        let now = Instant::now();
508        let rows = format_agent_rows(&agents, now);
509        let working = agents.iter().filter(|a| a.status == "working").count();
510        let done = agents
511            .iter()
512            .filter(|a| a.status == "done" || a.status == "verified")
513            .count();
514        let blocked = agents.iter().filter(|a| a.status == "blocked").count();
515        let committed = agents.iter().filter(|a| a.status == "committed").count();
516        let status_line = format_status_line(agents.len(), working, done, blocked, committed);
517
518        // Feed the Broker log: pull only messages newer than the cursor and
519        // push them onto the ring buffer (newest ends up at the top). This is
520        // the same in-process state the agent table reads โ€” no extra traffic.
521        broker_log.ingest(delivery::full_log(state, broker_log.last_seq()));
522
523        guard
524            .terminal
525            .draw(|f| {
526                draw_frame(f, &rows, &status_line, &broker_log, height_lines);
527            })
528            .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
529
530        thread::sleep(TICK_INTERVAL);
531    }
532
533    // Explicit restore for clean exit; guard also restores on drop as a safety net.
534    restore_terminal(&mut guard.terminal)?;
535    Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    /// A hidden Broker log panel for `draw_frame` calls that exercise the
543    /// agent-table/observation layout. Hidden so the rendered frame is the
544    /// v0.5.0 three-segment shape these assertions expect.
545    fn hidden_log() -> BrokerLog {
546        BrokerLog::new(500, false)
547    }
548
549    /// The production default panel height (`[dashboard.broker_log]
550    /// height_lines`), for `draw_frame` calls in tests.
551    fn default_panel_height() -> u16 {
552        crate::config::BrokerLogConfig::default().height_lines
553    }
554
555    // -----------------------------------------------------------------------
556    // status_symbol
557    // -----------------------------------------------------------------------
558
559    #[test]
560    fn status_symbol_working() {
561        assert_eq!(status_symbol("working"), "๐Ÿ”ต");
562    }
563
564    #[test]
565    fn status_symbol_done() {
566        assert_eq!(status_symbol("done"), "๐ŸŸข");
567    }
568
569    #[test]
570    fn status_symbol_verified() {
571        assert_eq!(status_symbol("verified"), "๐ŸŸข");
572    }
573
574    #[test]
575    fn status_symbol_blocked() {
576        assert_eq!(status_symbol("blocked"), "๐ŸŸก");
577    }
578
579    #[test]
580    fn status_symbol_committed() {
581        assert_eq!(status_symbol("committed"), "๐ŸŸฃ");
582    }
583
584    #[test]
585    fn status_symbol_idle() {
586        assert_eq!(status_symbol("idle"), "โšช");
587    }
588
589    #[test]
590    fn status_symbol_unknown() {
591        assert_eq!(status_symbol("something-unexpected"), "โšช");
592    }
593
594    // -----------------------------------------------------------------------
595    // format_age
596    // -----------------------------------------------------------------------
597
598    #[test]
599    fn format_age_zero_seconds() {
600        assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
601    }
602
603    #[test]
604    fn format_age_thirty_seconds() {
605        assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
606    }
607
608    #[test]
609    fn format_age_three_minutes() {
610        assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
611    }
612
613    #[test]
614    fn format_age_one_hour_exact() {
615        assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
616    }
617
618    #[test]
619    fn format_age_one_hour_fifteen_minutes() {
620        assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
621    }
622
623    // -----------------------------------------------------------------------
624    // format_agent_rows
625    // -----------------------------------------------------------------------
626
627    #[test]
628    fn format_agent_rows_three_agents() {
629        let now = Instant::now();
630        let agents = vec![
631            AgentStatusEntry {
632                agent_id: "feat-a".to_string(),
633                cli: "claude".to_string(),
634                status: "working".to_string(),
635                last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
636                last_seen_seconds: 10,
637                phase: None,
638            },
639            AgentStatusEntry {
640                agent_id: "feat-b".to_string(),
641                cli: "cursor".to_string(),
642                status: "done".to_string(),
643                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
644                last_seen_seconds: 60,
645                phase: None,
646            },
647            AgentStatusEntry {
648                agent_id: "feat-c".to_string(),
649                cli: "claude".to_string(),
650                status: "blocked".to_string(),
651                last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
652                last_seen_seconds: 300,
653                phase: None,
654            },
655        ];
656        let rows = format_agent_rows(&agents, now);
657        assert_eq!(rows.len(), 3);
658        assert_eq!(rows[0].agent_id, "feat-a");
659        assert_eq!(rows[1].agent_id, "feat-b");
660        assert_eq!(rows[2].agent_id, "feat-c");
661    }
662
663    #[test]
664    fn format_agent_rows_single_done_three_minutes() {
665        let now = Instant::now();
666        let agents = vec![AgentStatusEntry {
667            agent_id: "feat-errors".to_string(),
668            cli: "claude".to_string(),
669            status: "done".to_string(),
670            last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
671            last_seen_seconds: 180,
672            phase: None,
673        }];
674        let rows = format_agent_rows(&agents, now);
675        assert_eq!(rows.len(), 1);
676        assert_eq!(rows[0].agent_id, "feat-errors");
677        assert_eq!(rows[0].age, "3m ago");
678        assert!(rows[0].status.contains("done"));
679    }
680
681    #[test]
682    fn format_agent_rows_with_committed_status() {
683        let now = Instant::now();
684        let agents = vec![
685            AgentStatusEntry {
686                agent_id: "feat-committed".to_string(),
687                cli: "claude".to_string(),
688                status: "committed".to_string(),
689                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
690                last_seen_seconds: 60,
691                phase: None,
692            },
693            AgentStatusEntry {
694                agent_id: "feat-working".to_string(),
695                cli: "cursor".to_string(),
696                status: "working".to_string(),
697                last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
698                last_seen_seconds: 30,
699                phase: None,
700            },
701        ];
702        let rows = format_agent_rows(&agents, now);
703        assert_eq!(rows.len(), 2);
704
705        // Find the committed agent and verify it has the correct symbol
706        let committed_row = rows
707            .iter()
708            .find(|r| r.agent_id == "feat-committed")
709            .unwrap();
710        assert!(committed_row.status.contains("๐ŸŸฃ"));
711        assert!(committed_row.status.contains("committed"));
712
713        // Find the working agent and verify it has the correct symbol
714        let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
715        assert!(working_row.status.contains("๐Ÿ”ต"));
716        assert!(working_row.status.contains("working"));
717    }
718
719    #[test]
720    fn format_agent_rows_empty_input() {
721        let rows = format_agent_rows(&[], Instant::now());
722        assert!(rows.is_empty());
723    }
724
725    #[test]
726    fn agent_row_exposes_only_four_fields_no_summary() {
727        // Scenario: AgentRow exposes no summary field. The agent-status table
728        // no longer renders a Summary column, so the row struct carries exactly
729        // `agent_id`, `cli`, `status`, `age` and nothing else. This construction
730        // names every field exhaustively โ€” if a `summary` (or any other) field
731        // were reintroduced, this would fail to compile.
732        let now = Instant::now();
733        let agents = vec![AgentStatusEntry {
734            agent_id: "feat-errors".to_string(),
735            cli: "claude".to_string(),
736            status: "done".to_string(),
737            last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
738            last_seen_seconds: 180,
739            phase: None,
740        }];
741        let rows = format_agent_rows(&agents, now);
742        assert_eq!(rows.len(), 1);
743        let AgentRow {
744            agent_id,
745            cli,
746            status,
747            age,
748        } = &rows[0];
749        assert_eq!(agent_id, "feat-errors");
750        assert_eq!(cli, "claude");
751        assert!(status.contains("done"));
752        assert_eq!(age, "3m ago");
753    }
754
755    // -----------------------------------------------------------------------
756    // CLI column population (W15-15)
757    // -----------------------------------------------------------------------
758
759    #[test]
760    fn format_agent_rows_populates_cli_for_every_agent() {
761        // W15-15: the CLI column was blank for coding agents (only the
762        // supervisor row carried a CLI). Every row must render its CLI.
763        let now = Instant::now();
764        let agents = vec![
765            AgentStatusEntry {
766                agent_id: "supervisor".to_string(),
767                cli: "claude-oss".to_string(),
768                status: "working".to_string(),
769                last_seen: now,
770                last_seen_seconds: 0,
771                phase: Some("watching".to_string()),
772            },
773            AgentStatusEntry {
774                agent_id: "feat-a".to_string(),
775                cli: "claude-oss".to_string(),
776                status: "working".to_string(),
777                last_seen: now,
778                last_seen_seconds: 0,
779                phase: None,
780            },
781            AgentStatusEntry {
782                agent_id: "feat-b".to_string(),
783                cli: "claude-oss".to_string(),
784                status: "working".to_string(),
785                last_seen: now,
786                last_seen_seconds: 0,
787                phase: None,
788            },
789        ];
790        let rows = format_agent_rows(&agents, now);
791        assert_eq!(rows.len(), 3);
792        for row in &rows {
793            assert_eq!(
794                row.cli, "claude-oss",
795                "every agent row must render its CLI, not just the supervisor: {row:?}",
796            );
797        }
798    }
799
800    #[test]
801    fn format_agent_rows_shows_placeholder_for_unresolved_cli() {
802        // W15-15: an unresolved CLI shows the documented "?" placeholder
803        // rather than a blank cell that reads as a rendering bug.
804        let now = Instant::now();
805        let agents = vec![AgentStatusEntry {
806            agent_id: "feat-mystery".to_string(),
807            cli: String::new(),
808            status: "working".to_string(),
809            last_seen: now,
810            last_seen_seconds: 0,
811            phase: None,
812        }];
813        let rows = format_agent_rows(&agents, now);
814        assert_eq!(rows.len(), 1);
815        assert_eq!(
816            rows[0].cli, UNKNOWN_CLI,
817            "blank CLI must render the documented placeholder, not an empty string",
818        );
819        assert!(!rows[0].cli.is_empty());
820    }
821
822    // -----------------------------------------------------------------------
823    // Bug 8: dashboard accepts committed -> working re-entry
824    // -----------------------------------------------------------------------
825
826    #[test]
827    fn dashboard_row_transitions_committed_to_working_within_ttl() {
828        use crate::broker::BrokerState;
829        use crate::broker::delivery::{agent_status_snapshot, publish_message};
830        use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
831        use std::sync::Arc;
832
833        let state = Arc::new(BrokerState::new(None)); // default TTL 60s
834        publish_message(
835            &state,
836            &BrokerMessage::Artifact {
837                agent_id: "feat-x".to_string(),
838                payload: ArtifactPayload {
839                    status: "committed".to_string(),
840                    exports: vec![],
841                    modified_files: vec![],
842                },
843            },
844        );
845        // Render shows committed.
846        let snap = agent_status_snapshot(&state);
847        let rows = format_agent_rows(&snap, Instant::now());
848        let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
849        assert!(row.status.contains("committed"), "should start committed");
850
851        // Agent keeps working within the TTL window.
852        publish_message(
853            &state,
854            &BrokerMessage::Status {
855                agent_id: "feat-x".to_string(),
856                payload: StatusPayload {
857                    status: "working".to_string(),
858                    modified_files: vec!["src/lib.rs".to_string()],
859                    message: None,
860                    ..Default::default()
861                },
862            },
863        );
864        let snap = agent_status_snapshot(&state);
865        let rows = format_agent_rows(&snap, Instant::now());
866        let row = rows.iter().find(|r| r.agent_id == "feat-x").unwrap();
867        assert!(
868            row.status.contains("working") && row.status.contains("๐Ÿ”ต"),
869            "dashboard row must transition committed -> working, got {:?}",
870            row.status
871        );
872    }
873
874    #[test]
875    fn dashboard_row_stays_committed_when_ttl_zero() {
876        // v0.5.0 byte-equivalence: with TTL=0 the row stays committed.
877        use crate::broker::BrokerState;
878        use crate::broker::delivery::{agent_status_snapshot, publish_message};
879        use crate::broker::messages::{ArtifactPayload, BrokerMessage, StatusPayload};
880        use std::sync::Arc;
881
882        let state = Arc::new(BrokerState::new(None));
883        state.set_republish_working_ttl(Duration::ZERO);
884        publish_message(
885            &state,
886            &BrokerMessage::Artifact {
887                agent_id: "feat-y".to_string(),
888                payload: ArtifactPayload {
889                    status: "committed".to_string(),
890                    exports: vec![],
891                    modified_files: vec![],
892                },
893            },
894        );
895        publish_message(
896            &state,
897            &BrokerMessage::Status {
898                agent_id: "feat-y".to_string(),
899                payload: StatusPayload {
900                    status: "working".to_string(),
901                    modified_files: vec!["src/lib.rs".to_string()],
902                    message: None,
903                    ..Default::default()
904                },
905            },
906        );
907        let snap = agent_status_snapshot(&state);
908        let rows = format_agent_rows(&snap, Instant::now());
909        let row = rows.iter().find(|r| r.agent_id == "feat-y").unwrap();
910        assert!(
911            row.status.contains("committed"),
912            "with TTL=0 the dashboard row must stay committed, got {:?}",
913            row.status
914        );
915    }
916
917    // -----------------------------------------------------------------------
918    // Phase-aware status rendering (tasks 5.4, 5.5)
919    // -----------------------------------------------------------------------
920
921    #[test]
922    fn format_agent_rows_prefers_phase_over_status_for_supervisor() {
923        let now = Instant::now();
924        let agents = vec![AgentStatusEntry {
925            agent_id: "supervisor".to_string(),
926            cli: "claude".to_string(),
927            status: "feedback".to_string(),
928            last_seen: now,
929            last_seen_seconds: 0,
930            phase: Some("merging".to_string()),
931        }];
932        let rows = format_agent_rows(&agents, now);
933        assert_eq!(rows.len(), 1);
934        assert!(
935            rows[0].status.contains("merging"),
936            "expected phase 'merging' in status field; got {:?}",
937            rows[0].status,
938        );
939        assert!(
940            !rows[0].status.contains("feedback"),
941            "phase must replace status label, not append; got {:?}",
942            rows[0].status,
943        );
944    }
945
946    #[test]
947    fn format_agent_rows_falls_back_to_status_when_phase_is_none() {
948        let now = Instant::now();
949        let agents = vec![AgentStatusEntry {
950            agent_id: "feat-broker".to_string(),
951            cli: "claude".to_string(),
952            status: "working".to_string(),
953            last_seen: now,
954            last_seen_seconds: 0,
955            phase: None,
956        }];
957        let rows = format_agent_rows(&agents, now);
958        assert!(
959            rows[0].status.contains("working"),
960            "expected 'working' in status field; got {:?}",
961            rows[0].status,
962        );
963    }
964
965    // -----------------------------------------------------------------------
966    // supervisor-introspection: phase honoured for supervisor row only
967    // (tasks 3.1 - 3.4)
968    // -----------------------------------------------------------------------
969
970    /// Builds an entry with an explicit phase for the introspection tests.
971    fn entry_with_phase(agent_id: &str, status: &str, phase: Option<&str>) -> AgentStatusEntry {
972        AgentStatusEntry {
973            agent_id: agent_id.to_string(),
974            cli: "claude".to_string(),
975            status: status.to_string(),
976            last_seen: Instant::now(),
977            last_seen_seconds: 0,
978            phase: phase.map(str::to_string),
979        }
980    }
981
982    #[test]
983    fn format_agent_rows_supervisor_shows_introspection_phase() {
984        // Scenario: supervisor row shows phase when present.
985        let now = Instant::now();
986        let agents = vec![entry_with_phase("supervisor", "working", Some("audit"))];
987        let rows = format_agent_rows(&agents, now);
988        assert!(
989            rows[0].status.contains("audit"),
990            "supervisor row must surface the introspection phase; got {:?}",
991            rows[0].status,
992        );
993    }
994
995    #[test]
996    fn format_agent_rows_supervisor_falls_back_when_phase_absent() {
997        // Scenario: supervisor row falls back to the status label when phase
998        // absent (v0.5.0 layout preserved).
999        let now = Instant::now();
1000        let agents = vec![entry_with_phase("supervisor", "working", None)];
1001        let rows = format_agent_rows(&agents, now);
1002        assert!(
1003            rows[0].status.contains("working"),
1004            "without a phase the supervisor row renders the status label; got {:?}",
1005            rows[0].status,
1006        );
1007    }
1008
1009    #[test]
1010    fn format_agent_rows_non_supervisor_ignores_phase() {
1011        // Scenario: non-supervisor agent rows unchanged โ€” a coding agent that
1012        // set a phase still renders as v0.5.0 (phase ignored).
1013        let now = Instant::now();
1014        let agents = vec![entry_with_phase("feat-auth", "working", Some("audit"))];
1015        let rows = format_agent_rows(&agents, now);
1016        assert!(
1017            rows[0].status.contains("working"),
1018            "a coding agent's phase must be ignored; got {:?}",
1019            rows[0].status,
1020        );
1021        assert!(
1022            !rows[0].status.contains("audit"),
1023            "the introspection phase must not leak onto a coding-agent row; got {:?}",
1024            rows[0].status,
1025        );
1026    }
1027
1028    #[test]
1029    fn format_agent_rows_non_supervisor_still_shows_stuck_on_prompt() {
1030        // The one documented exception: the supervisor-published
1031        // `stuck-on-prompt` alert targets the coding agent's row by design and
1032        // must remain visible there.
1033        let now = Instant::now();
1034        let agents = vec![entry_with_phase(
1035            "feat-auth",
1036            "working",
1037            Some(STUCK_ON_PROMPT_PHASE),
1038        )];
1039        let rows = format_agent_rows(&agents, now);
1040        assert!(
1041            rows[0].status.contains(STUCK_ON_PROMPT_PHASE),
1042            "the supervisor-authored stuck-on-prompt alert must surface on the \
1043             coding-agent row; got {:?}",
1044            rows[0].status,
1045        );
1046    }
1047
1048    #[test]
1049    fn format_agent_rows_supervisor_phase_snapshot_layout() {
1050        // Snapshot: supervisor row with `phase` present renders the exact
1051        // `{symbol} {phase}` status field; without `phase` it matches the
1052        // v0.5.0 `{symbol} {status}` layout.
1053        let now = Instant::now();
1054        let with_phase = format_agent_rows(
1055            &[entry_with_phase("supervisor", "feedback", Some("merge"))],
1056            now,
1057        );
1058        assert_eq!(with_phase[0].status, "โšช merge");
1059
1060        let without_phase =
1061            format_agent_rows(&[entry_with_phase("supervisor", "working", None)], now);
1062        assert_eq!(without_phase[0].status, "๐Ÿ”ต working");
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // arrange_with_supervisor_pinned (tasks 4.4 - 4.6)
1067    // -----------------------------------------------------------------------
1068
1069    fn agent_row(id: &str) -> AgentRow {
1070        AgentRow {
1071            agent_id: id.to_string(),
1072            cli: "claude".to_string(),
1073            status: "๐Ÿ”ต working".to_string(),
1074            age: "0s ago".to_string(),
1075        }
1076    }
1077
1078    #[test]
1079    fn arrange_with_supervisor_pinned_yields_supervisor_then_divider_then_coding() {
1080        let rows = vec![
1081            agent_row("feat-broker"),
1082            agent_row("feat-dashboard"),
1083            agent_row("supervisor"),
1084        ];
1085        let arranged = arrange_with_supervisor_pinned(rows);
1086        assert_eq!(arranged.len(), 4, "supervisor + divider + 2 coding agents");
1087        assert!(
1088            matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "supervisor"),
1089            "supervisor must be at row 0; got {:?}",
1090            arranged[0]
1091        );
1092        assert_eq!(
1093            arranged[1],
1094            AgentTableRow::Divider,
1095            "divider must immediately follow supervisor"
1096        );
1097        assert!(matches!(&arranged[2], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"),);
1098        assert!(matches!(&arranged[3], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"),);
1099    }
1100
1101    #[test]
1102    fn arrange_with_supervisor_pinned_emits_no_divider_when_supervisor_absent() {
1103        let rows = vec![agent_row("feat-broker"), agent_row("feat-dashboard")];
1104        let arranged = arrange_with_supervisor_pinned(rows);
1105        assert_eq!(arranged.len(), 2);
1106        for row in &arranged {
1107            assert!(
1108                !matches!(row, AgentTableRow::Divider),
1109                "no divider when supervisor is absent; got {row:?}"
1110            );
1111        }
1112        assert!(matches!(&arranged[0], AgentTableRow::Agent(r) if r.agent_id == "feat-broker"));
1113        assert!(matches!(&arranged[1], AgentTableRow::Agent(r) if r.agent_id == "feat-dashboard"));
1114    }
1115
1116    #[test]
1117    fn arrange_with_supervisor_pinned_empty_input_yields_empty_output() {
1118        let arranged = arrange_with_supervisor_pinned(Vec::new());
1119        assert!(arranged.is_empty());
1120    }
1121
1122    #[test]
1123    fn supervisor_row_appears_above_coding_rows_in_rendered_frame() {
1124        use ratatui::Terminal;
1125        use ratatui::backend::TestBackend;
1126
1127        // Construct three formatted rows with snapshot already in
1128        // alphabetical order (this matches what agent_status_snapshot
1129        // emits before pinning). The pinning happens inside draw_frame.
1130        let rows = vec![
1131            agent_row("feat-broker"),
1132            agent_row("feat-dashboard"),
1133            agent_row("supervisor"),
1134        ];
1135
1136        let backend = TestBackend::new(140, 30);
1137        let mut terminal = Terminal::new(backend).unwrap();
1138        terminal
1139            .draw(|f| draw_frame(f, &rows, "3 agents", &hidden_log(), default_panel_height()))
1140            .unwrap();
1141
1142        // Flatten the buffer to a single string so we can check row order
1143        // by substring positions across the rendered output.
1144        let buffer = terminal.backend().buffer().clone();
1145        let mut rendered = String::new();
1146        for y in 0..buffer.area.height {
1147            for x in 0..buffer.area.width {
1148                rendered.push_str(buffer[(x, y)].symbol());
1149            }
1150            rendered.push('\n');
1151        }
1152
1153        let pos_supervisor = rendered
1154            .find("supervisor")
1155            .expect("supervisor row should be in rendered frame");
1156        let pos_broker = rendered
1157            .find("feat-broker")
1158            .expect("feat-broker row should be in rendered frame");
1159        let pos_dashboard = rendered
1160            .find("feat-dashboard")
1161            .expect("feat-dashboard row should be in rendered frame");
1162        assert!(
1163            pos_supervisor < pos_broker && pos_supervisor < pos_dashboard,
1164            "supervisor row must render above coding-agent rows; supervisor@{pos_supervisor}, broker@{pos_broker}, dashboard@{pos_dashboard}",
1165        );
1166
1167        // A divider row containing horizontal-line characters appears
1168        // between the supervisor row and the first coding-agent row.
1169        let pos_divider = rendered[pos_supervisor..]
1170            .find('โ”€')
1171            .map(|p| pos_supervisor + p)
1172            .expect("divider row should contain horizontal-line characters");
1173        assert!(
1174            pos_divider > pos_supervisor && pos_divider < pos_broker,
1175            "divider must render between supervisor and first coding row; divider@{pos_divider}, supervisor@{pos_supervisor}, broker@{pos_broker}",
1176        );
1177    }
1178
1179    #[test]
1180    fn header_row_has_four_columns_and_no_summary() {
1181        use ratatui::Terminal;
1182        use ratatui::backend::TestBackend;
1183
1184        // Scenario: Table has a header row. With at least one agent rendered,
1185        // the header must label exactly Agent, CLI, Status, Last Update and
1186        // must NOT contain a Summary column (the dead column was removed).
1187        let rows = vec![agent_row("feat-broker")];
1188
1189        let backend = TestBackend::new(140, 30);
1190        let mut terminal = Terminal::new(backend).unwrap();
1191        terminal
1192            .draw(|f| draw_frame(f, &rows, "1 agent", &hidden_log(), default_panel_height()))
1193            .unwrap();
1194
1195        let buffer = terminal.backend().buffer().clone();
1196        let mut rendered = String::new();
1197        for y in 0..buffer.area.height {
1198            for x in 0..buffer.area.width {
1199                rendered.push_str(buffer[(x, y)].symbol());
1200            }
1201            rendered.push('\n');
1202        }
1203
1204        for label in ["Agent", "CLI", "Status", "Last Update"] {
1205            assert!(
1206                rendered.contains(label),
1207                "header must contain the {label:?} column label; got:\n{rendered}",
1208            );
1209        }
1210        assert!(
1211            !rendered.contains("Summary"),
1212            "header must NOT contain a 'Summary' column label; got:\n{rendered}",
1213        );
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // format_status_line
1218    // -----------------------------------------------------------------------
1219
1220    #[test]
1221    fn format_status_line_mixed() {
1222        assert_eq!(
1223            format_status_line(4, 2, 1, 1, 0),
1224            "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1225        );
1226    }
1227
1228    #[test]
1229    fn format_status_line_all_done() {
1230        assert_eq!(
1231            format_status_line(3, 0, 3, 0, 0),
1232            "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1233        );
1234    }
1235
1236    #[test]
1237    fn format_status_line_zero_agents() {
1238        assert_eq!(
1239            format_status_line(0, 0, 0, 0, 0),
1240            "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1241        );
1242    }
1243
1244    #[test]
1245    fn format_status_line_with_committed() {
1246        assert_eq!(
1247            format_status_line(5, 2, 1, 1, 1),
1248            "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1249        );
1250    }
1251
1252    // -----------------------------------------------------------------------
1253    // Prompt-inbox removal (tasks 6.8, 6.9)
1254    // -----------------------------------------------------------------------
1255
1256    #[test]
1257    fn rendered_frame_contains_no_questions_or_reply_input() {
1258        use ratatui::Terminal;
1259        use ratatui::backend::TestBackend;
1260
1261        let backend = TestBackend::new(140, 30);
1262        let mut terminal = Terminal::new(backend).unwrap();
1263        terminal
1264            .draw(|f| draw_frame(f, &[], "0 agents", &hidden_log(), default_panel_height()))
1265            .unwrap();
1266
1267        let buffer = terminal.backend().buffer().clone();
1268        let mut rendered = String::new();
1269        for y in 0..buffer.area.height {
1270            for x in 0..buffer.area.width {
1271                rendered.push_str(buffer[(x, y)].symbol());
1272            }
1273            rendered.push('\n');
1274        }
1275
1276        assert!(
1277            !rendered.contains("Questions ("),
1278            "dashboard MUST NOT render a 'Questions (' prompt-inbox header; got:\n{rendered}",
1279        );
1280        assert!(
1281            !rendered.contains("Reply to"),
1282            "dashboard MUST NOT render a 'Reply to' input prompt; got:\n{rendered}",
1283        );
1284    }
1285
1286    // supervisor-as-pane[-followups] dashboard input contract.
1287    //
1288    // After the prompt-inbox removal in v0.5.0 the dashboard has no
1289    // focused-question or input-buffer state. The tests below assert the
1290    // ignored-input contract for the keys most likely to confuse a user
1291    // who remembers the pre-removal shape (Tab to focus, printable chars
1292    // to type into a buffer).
1293
1294    #[test]
1295    fn tab_key_ignored_no_buffer() {
1296        // Tab is not a quit key โ€” the handler must ignore it. There is no
1297        // observable side effect to assert beyond `should_quit` returning
1298        // false, because the dashboard has no buffer or focus state for
1299        // Tab to mutate.
1300        assert!(
1301            !should_quit(KeyCode::Tab),
1302            "Tab must not quit the dashboard and must not have any other side effect (no input buffer exists)",
1303        );
1304    }
1305
1306    #[test]
1307    fn printable_char_ignored_no_buffer() {
1308        // Printable characters other than `q` must be ignored โ€” the
1309        // dashboard has no buffer to accumulate them into.
1310        assert!(
1311            !should_quit(KeyCode::Char('a')),
1312            "printable char 'a' must not quit and must not accumulate into any buffer",
1313        );
1314        assert!(
1315            !should_quit(KeyCode::Char(' ')),
1316            "space must not quit and must not accumulate into any buffer",
1317        );
1318        // Sanity-check the positive case so the test really exercises the
1319        // handler contract and not just a constant false.
1320        assert!(
1321            should_quit(KeyCode::Char('q')),
1322            "lowercase 'q' must quit the dashboard",
1323        );
1324    }
1325
1326    #[test]
1327    fn layout_collapses_without_message_log() {
1328        // With show_message_log = false the layout is three segments
1329        // (title, agent table, status line). The pre-inbox-removal shape
1330        // had 5 or 6 segments โ€” a regression to that would imply the
1331        // prompt-inbox panel is back.
1332        let constraints = build_layout_constraints(false, default_panel_height());
1333        assert_eq!(
1334            constraints.len(),
1335            3,
1336            "layout without message log must be exactly 3 segments (title, table, status), got {} constraints",
1337            constraints.len(),
1338        );
1339
1340        // With show_message_log = true the layout adds the messages
1341        // panel as a 4th segment. Asserting both shapes catches the case
1342        // where the helper accidentally drops the messages panel or
1343        // grows a spurious 5th segment.
1344        let with_log = build_layout_constraints(true, default_panel_height());
1345        assert_eq!(
1346            with_log.len(),
1347            4,
1348            "layout with message log must be exactly 4 segments, got {} constraints",
1349            with_log.len(),
1350        );
1351
1352        // The panel segment is the new configurable height, no longer the
1353        // v0.6.0 fixed `Length(12)`.
1354        assert_eq!(
1355            with_log[3],
1356            Constraint::Length(default_panel_height()),
1357            "the broker-log panel segment must be the configured height, not the old fixed 12",
1358        );
1359    }
1360
1361    #[test]
1362    fn visible_panel_default_height_exceeds_twelve() {
1363        // Task 3.1 / spec "Visible panel gets more than twelve rows by
1364        // default": with the default height the panel segment is a fixed
1365        // `Length` strictly greater than the v0.6.0 fixed 12. We assert the
1366        // computed constraint, not pixels (the TUI draw loop is
1367        // coverage-exempt).
1368        let constraints = build_layout_constraints(true, default_panel_height());
1369        let panel = constraints[3];
1370        match panel {
1371            Constraint::Length(n) => assert!(
1372                n > 12,
1373                "default panel height must be strictly greater than 12, got {n}",
1374            ),
1375            other => panic!("panel segment must be a Length constraint, got {other:?}"),
1376        }
1377    }
1378
1379    #[test]
1380    fn configured_height_sets_panel_segment_length() {
1381        // Task 3.2 / spec "Configured height_lines sets the panel height":
1382        // an explicit height is reflected exactly in the panel segment.
1383        let constraints = build_layout_constraints(true, 24);
1384        assert_eq!(
1385            constraints[3],
1386            Constraint::Length(24),
1387            "configured height_lines must size the panel segment exactly",
1388        );
1389    }
1390
1391    #[test]
1392    fn agent_table_keeps_positive_minimum() {
1393        // Task 3.3 / spec "Agent table keeps a positive minimum height": the
1394        // table segment is a `Min` with a positive lower bound, so the
1395        // enlarged panel cannot starve it (ratatui honours `Min` before the
1396        // panel's `Length`).
1397        let constraints = build_layout_constraints(true, default_panel_height());
1398        match constraints[1] {
1399            Constraint::Min(m) => assert!(
1400                m > 0,
1401                "agent-table segment must keep a positive minimum height, got Min({m})",
1402            ),
1403            other => panic!("agent-table segment must be a Min constraint, got {other:?}"),
1404        }
1405    }
1406
1407    // -----------------------------------------------------------------------
1408    // Broker log layout integration (tasks 5.1-5.3)
1409    // -----------------------------------------------------------------------
1410
1411    use ratatui::Terminal;
1412    use ratatui::backend::TestBackend;
1413    use ratatui::buffer::Buffer;
1414
1415    fn draw_to_buffer(rows: &[AgentRow], status: &str, log: &broker_log::BrokerLog) -> Buffer {
1416        let backend = TestBackend::new(120, 30);
1417        let mut terminal = Terminal::new(backend).unwrap();
1418        terminal
1419            .draw(|f| draw_frame(f, rows, status, log, default_panel_height()))
1420            .unwrap();
1421        terminal.backend().buffer().clone()
1422    }
1423
1424    fn sample_log_entry(seq: u64) -> broker_log::LogEntry {
1425        (
1426            seq,
1427            std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1428            crate::broker::messages::BrokerMessage::Status {
1429                agent_id: "feat-auth".to_string(),
1430                payload: crate::broker::messages::StatusPayload {
1431                    status: "working".to_string(),
1432                    modified_files: vec![],
1433                    message: Some("rebasing onto main".to_string()),
1434                    ..Default::default()
1435                },
1436            },
1437        )
1438    }
1439
1440    fn log_entry_with_message(seq: u64, msg: &str) -> broker_log::LogEntry {
1441        (
1442            seq,
1443            std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(seq),
1444            crate::broker::messages::BrokerMessage::Status {
1445                agent_id: "feat-auth".to_string(),
1446                payload: crate::broker::messages::StatusPayload {
1447                    status: "working".to_string(),
1448                    modified_files: vec![],
1449                    message: Some(msg.to_string()),
1450                    ..Default::default()
1451                },
1452            },
1453        )
1454    }
1455
1456    fn buffer_text(buffer: &Buffer) -> String {
1457        let mut rendered = String::new();
1458        for y in 0..buffer.area.height {
1459            for x in 0..buffer.area.width {
1460                rendered.push_str(buffer[(x, y)].symbol());
1461            }
1462            rendered.push('\n');
1463        }
1464        rendered
1465    }
1466
1467    #[test]
1468    fn scrolling_reaches_messages_beyond_the_first_screen() {
1469        // Bug: a plain List with no offset only ever showed the first
1470        // screenful. With stateful-list scrolling, moving the selection to the
1471        // bottom must scroll the viewport so the oldest message becomes visible.
1472        let rows = vec![agent_row("feat-auth")];
1473        let mut log = BrokerLog::new(500, true);
1474        // 40 distinct messages; push_front means msg-00 ends up at the bottom.
1475        for i in 0..40 {
1476            log.push(log_entry_with_message(i, &format!("scroll-msg-{i:02}")));
1477        }
1478        // At offset 0 the oldest (scroll-msg-00) is off-screen.
1479        let at_top = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1480        assert!(
1481            !at_top.contains("scroll-msg-00"),
1482            "precondition: the oldest message should be off-screen before scrolling; got:\n{at_top}"
1483        );
1484        // Move the selection to the bottom row.
1485        for _ in 0..39 {
1486            log.select_down();
1487        }
1488        let scrolled = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1489        assert!(
1490            scrolled.contains("scroll-msg-00"),
1491            "scrolling to the bottom must reveal the oldest message; got:\n{scrolled}"
1492        );
1493    }
1494
1495    #[test]
1496    fn hidden_panel_status_line_shows_restore_hint() {
1497        let rows = vec![agent_row("feat-auth")];
1498        let log = BrokerLog::new(500, false); // hidden
1499        let rendered = buffer_text(&draw_to_buffer(&rows, "1 agents", &log));
1500        assert!(
1501            rendered.contains("press l to show"),
1502            "hidden panel must hint the `l` toggle in the status line; got:\n{rendered}"
1503        );
1504        assert!(
1505            !rendered.contains("Broker log ("),
1506            "hidden panel must not render the panel title region; got:\n{rendered}"
1507        );
1508    }
1509
1510    #[test]
1511    fn hidden_panel_layout_is_byte_equivalent_regardless_of_buffer_contents() {
1512        // Task 5.3: with the panel hidden, the rendered frame must match the
1513        // v0.5.0 post-inbox-removal layout โ€” i.e. the Broker log must have
1514        // zero effect on the rendered bytes. We prove this by rendering a
1515        // hidden panel with an empty buffer and a hidden panel holding many
1516        // messages: the buffers must be byte-identical.
1517        let rows = vec![agent_row("feat-auth"), agent_row("feat-db")];
1518
1519        let empty = BrokerLog::new(500, false);
1520        let mut full = BrokerLog::new(500, false);
1521        for i in 1..=50 {
1522            full.push(sample_log_entry(i));
1523        }
1524
1525        let buf_empty = draw_to_buffer(&rows, "2 agents", &empty);
1526        let buf_full = draw_to_buffer(&rows, "2 agents", &full);
1527        assert_eq!(
1528            buf_empty, buf_full,
1529            "a hidden Broker log must not alter the rendered frame regardless of buffered messages",
1530        );
1531    }
1532
1533    #[test]
1534    fn visible_panel_renders_broker_log_region() {
1535        // Tasks 5.1/5.2: when visible the panel occupies the fourth segment
1536        // and renders its titled region with the buffered row.
1537        let rows = vec![agent_row("feat-auth")];
1538        let mut log = BrokerLog::new(500, true);
1539        log.push(sample_log_entry(1));
1540
1541        let buffer = draw_to_buffer(&rows, "1 agents", &log);
1542        let mut rendered = String::new();
1543        for y in 0..buffer.area.height {
1544            for x in 0..buffer.area.width {
1545                rendered.push_str(buffer[(x, y)].symbol());
1546            }
1547            rendered.push('\n');
1548        }
1549        assert!(
1550            rendered.contains("Broker log"),
1551            "visible panel must render its titled region; got:\n{rendered}",
1552        );
1553        assert!(
1554            rendered.contains("rebasing onto main"),
1555            "visible panel must render the buffered message summary; got:\n{rendered}",
1556        );
1557    }
1558
1559    #[test]
1560    fn toggling_visibility_returns_to_hidden_layout() {
1561        // Toggling the panel off via the `l` key must restore the exact
1562        // hidden-layout bytes (round-trip safety for the toggle hotkey).
1563        let rows = vec![agent_row("feat-auth")];
1564        let mut log = BrokerLog::new(500, false);
1565        log.push(sample_log_entry(1));
1566        let hidden_before = draw_to_buffer(&rows, "1 agents", &log);
1567
1568        broker_log::handle_key(&mut log, KeyCode::Char('l')); // show
1569        assert!(log.visible);
1570        broker_log::handle_key(&mut log, KeyCode::Char('l')); // hide again
1571        assert!(!log.visible);
1572        let hidden_after = draw_to_buffer(&rows, "1 agents", &log);
1573
1574        assert_eq!(
1575            hidden_before, hidden_after,
1576            "hiding the panel again must reproduce the hidden layout exactly",
1577        );
1578    }
1579}