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