Skip to main content

git_paw/dashboard/
broker_log.rs

1//! The dashboard's "Broker log" panel: a bounded ring buffer of observed
2//! broker messages plus the ratatui widget that renders them with per-type
3//! filtering and a details overlay.
4//!
5//! v0.6.0 fills the screen region freed by v0.5.0's prompt-inbox removal
6//! with this panel. It lists every broker message type, newest at the top,
7//! filterable per-type via single-press hotkeys and toggleable on/off.
8//!
9//! # Architecture note
10//!
11//! design.md D3 described feeding the panel from a
12//! `tokio::sync::broadcast::Receiver`. The actual dashboard is a synchronous
13//! thread-polling loop (`std::thread::sleep`), not a tokio task, so there is
14//! no broadcast channel to subscribe to. Instead the panel is fed by
15//! [`BrokerLog::ingest`] from the dashboard's existing per-tick poll of
16//! [`crate::broker::BrokerState`] via a monotonic sequence cursor. This adds
17//! no broker traffic (the same in-process state the agent table already
18//! reads) and yields the watcher-restart resilience design.md D8 requires
19//! for free: the ring buffer lives in the dashboard process and is never
20//! cleared, and the seq cursor only ever advances.
21
22use std::collections::VecDeque;
23use std::time::SystemTime;
24
25use crossterm::event::KeyCode;
26use ratatui::Frame;
27use ratatui::layout::{Alignment, Constraint, Layout, Rect};
28use ratatui::style::{Color, Modifier, Style};
29use ratatui::text::{Line, Span};
30use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
31
32use crate::broker::messages::BrokerMessage;
33
34/// One retained log entry: the broker sequence number, the wall-clock
35/// timestamp the broker recorded, and the message itself. Mirrors the tuple
36/// shape of [`crate::broker::BrokerStateInner::message_log`].
37pub type LogEntry = (u64, SystemTime, BrokerMessage);
38
39// ---------------------------------------------------------------------------
40// Filter bitmask (design.md D4)
41// ---------------------------------------------------------------------------
42
43/// Filter bit for `agent.status` messages.
44pub const BIT_STATUS: u16 = 1 << 0;
45/// Filter bit for `agent.artifact` messages.
46pub const BIT_ARTIFACT: u16 = 1 << 1;
47/// Filter bit for `agent.blocked` messages.
48pub const BIT_BLOCKED: u16 = 1 << 2;
49/// Filter bit for `agent.verified` messages.
50pub const BIT_VERIFIED: u16 = 1 << 3;
51/// Filter bit for `agent.feedback` messages.
52pub const BIT_FEEDBACK: u16 = 1 << 4;
53/// Filter bit for `agent.question` messages.
54pub const BIT_QUESTION: u16 = 1 << 5;
55/// Filter bit for `agent.intent` messages.
56pub const BIT_INTENT: u16 = 1 << 6;
57/// Filter bit for `supervisor.verify-now` messages.
58pub const BIT_VERIFY_NOW: u16 = 1 << 7;
59/// Filter bit for `agent.advanced-main` messages.
60pub const BIT_ADVANCED_MAIN: u16 = 1 << 8;
61/// Filter bit for `agent.learning` messages.
62pub const BIT_LEARNING: u16 = 1 << 9;
63
64/// The "show everything" sentinel. Distinct from the bitwise-OR of every
65/// known bit so that selecting every chip individually is still treated as
66/// an explicit (non-`All`) selection.
67pub const FILTER_ALL: u16 = 0xFFFF;
68
69/// Ordered chip table: `(bit, short label)`, indexed by the `1`-`9` then `0`
70/// hotkeys (the tenth chip is reached with `0`).
71///
72/// One chip per [`BrokerMessage`] variant the panel knows about, per spec
73/// requirement "Per-type filter chips" — one chip per known message type.
74/// The broker-emitted `verify-now`, the supervisor `advanced-main`, and the
75/// aggregator `learning` variants all exist and each get a chip (the
76/// proposal's illustrative list named the `learning` chip; it became real
77/// once the `agent-learning-variant` change merged).
78pub const CHIPS: [(u16, &str); 10] = [
79    (BIT_STATUS, "status"),
80    (BIT_ARTIFACT, "artifact"),
81    (BIT_BLOCKED, "blocked"),
82    (BIT_VERIFIED, "verified"),
83    (BIT_FEEDBACK, "feedback"),
84    (BIT_QUESTION, "question"),
85    (BIT_INTENT, "intent"),
86    (BIT_VERIFY_NOW, "verify-now"),
87    (BIT_ADVANCED_MAIN, "advanced-main"),
88    (BIT_LEARNING, "learning"),
89];
90
91/// Maps a message to its filter bit.
92#[must_use]
93pub fn message_bit(msg: &BrokerMessage) -> u16 {
94    match msg {
95        BrokerMessage::Status { .. } => BIT_STATUS,
96        BrokerMessage::Artifact { .. } => BIT_ARTIFACT,
97        BrokerMessage::Blocked { .. } => BIT_BLOCKED,
98        BrokerMessage::Verified { .. } => BIT_VERIFIED,
99        BrokerMessage::Feedback { .. } => BIT_FEEDBACK,
100        BrokerMessage::Question { .. } => BIT_QUESTION,
101        BrokerMessage::Intent { .. } => BIT_INTENT,
102        BrokerMessage::VerifyNow { .. } => BIT_VERIFY_NOW,
103        BrokerMessage::AdvancedMain { .. } => BIT_ADVANCED_MAIN,
104        BrokerMessage::Learning { .. } => BIT_LEARNING,
105    }
106}
107
108/// The panel's per-type filter state.
109///
110/// The value [`FILTER_ALL`] means "All" mode — every message is visible and
111/// no individual chip is highlighted. Any other value is an explicit
112/// selection set: a message is visible iff its [`message_bit`] is set.
113///
114/// Toggle semantics (matching the spec scenarios):
115/// - Toggling a chip while in `All` mode leaves `All` and narrows to *only*
116///   that type.
117/// - Toggling a chip in selection mode flips its bit; emptying the set
118///   returns to `All`.
119/// - [`FilterMask::reset`] returns to `All` unconditionally.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct FilterMask(u16);
122
123impl Default for FilterMask {
124    fn default() -> Self {
125        Self(FILTER_ALL)
126    }
127}
128
129impl FilterMask {
130    /// The default `All` mask.
131    #[must_use]
132    pub fn all() -> Self {
133        Self(FILTER_ALL)
134    }
135
136    /// Returns `true` when in `All` mode (every message visible).
137    #[must_use]
138    pub fn is_all(self) -> bool {
139        self.0 == FILTER_ALL
140    }
141
142    /// Resets to `All` mode.
143    pub fn reset(&mut self) {
144        self.0 = FILTER_ALL;
145    }
146
147    /// Toggles a single chip bit, applying the `All`-mode transition rules.
148    pub fn toggle(&mut self, bit: u16) {
149        if self.0 == FILTER_ALL {
150            // Leaving All: narrow to just this type.
151            self.0 = bit;
152        } else {
153            self.0 ^= bit;
154            if self.0 == 0 {
155                // Emptied the selection — fall back to All.
156                self.0 = FILTER_ALL;
157            }
158        }
159    }
160
161    /// Returns `true` when the given message passes the active filter.
162    #[must_use]
163    pub fn matches(self, msg: &BrokerMessage) -> bool {
164        self.is_all() || (self.0 & message_bit(msg)) != 0
165    }
166
167    /// Returns `true` when a chip is part of the active explicit selection.
168    /// Always `false` in `All` mode (no individual chip is "selected").
169    #[must_use]
170    pub fn is_chip_active(self, bit: u16) -> bool {
171        !self.is_all() && (self.0 & bit) != 0
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Ring buffer
177// ---------------------------------------------------------------------------
178
179/// The Broker log panel's state: a bounded ring buffer of observed messages
180/// plus the UI state (active filter, visibility, selected row, overlay).
181#[derive(Debug)]
182pub struct BrokerLog {
183    /// Retained messages, newest at the front.
184    buffer: VecDeque<LogEntry>,
185    /// Hard cap on retained messages (`[dashboard.broker_log] max_messages`).
186    max: usize,
187    /// Active per-type filter.
188    filter: FilterMask,
189    /// Whether the panel is currently rendered.
190    pub visible: bool,
191    /// Highest broker sequence number ingested so far; the poll cursor.
192    last_seq: u64,
193    /// Index of the highlighted row *within the currently visible subset*.
194    selected: usize,
195    /// Whether the details overlay is open.
196    overlay_open: bool,
197}
198
199impl BrokerLog {
200    /// Creates an empty log with the given capacity and initial visibility.
201    ///
202    /// A `max` of 0 is clamped to 1 so the buffer can always hold at least
203    /// the newest message (a zero-capacity ring buffer would be useless and
204    /// `truncate(0)` would drop everything immediately).
205    #[must_use]
206    pub fn new(max_messages: usize, visible: bool) -> Self {
207        Self {
208            buffer: VecDeque::new(),
209            max: max_messages.max(1),
210            filter: FilterMask::all(),
211            visible,
212            last_seq: 0,
213            selected: 0,
214            overlay_open: false,
215        }
216    }
217
218    /// Pushes one entry to the front and truncates to the cap, dropping the
219    /// oldest entries beyond `max` (task 2.2).
220    pub fn push(&mut self, entry: LogEntry) {
221        self.buffer.push_front(entry);
222        self.buffer.truncate(self.max);
223    }
224
225    /// Ingests a batch of new messages fetched from the broker state.
226    ///
227    /// `new_msgs` is expected in chronological (seq-ascending) order — the
228    /// shape [`crate::broker::delivery::full_log`] returns. Only entries with
229    /// a sequence number beyond [`BrokerLog::last_seq`] are taken, so calling
230    /// this every tick with the full filtered slice is idempotent and the
231    /// cursor only advances. Pushing in chronological order leaves the newest
232    /// message at the front.
233    pub fn ingest(&mut self, new_msgs: impl IntoIterator<Item = LogEntry>) {
234        for entry in new_msgs {
235            if entry.0 <= self.last_seq {
236                continue;
237            }
238            self.last_seq = entry.0;
239            self.push(entry);
240        }
241    }
242
243    /// The poll cursor: the highest sequence number ingested so far. The
244    /// dashboard passes this to [`crate::broker::delivery::full_log`] to fetch
245    /// only messages newer than what the buffer already holds.
246    #[must_use]
247    pub fn last_seq(&self) -> u64 {
248        self.last_seq
249    }
250
251    /// The configured capacity.
252    #[must_use]
253    pub fn capacity(&self) -> usize {
254        self.max
255    }
256
257    /// Total retained messages, ignoring the active filter.
258    #[must_use]
259    pub fn len(&self) -> usize {
260        self.buffer.len()
261    }
262
263    /// Whether the buffer holds no messages.
264    #[must_use]
265    pub fn is_empty(&self) -> bool {
266        self.buffer.is_empty()
267    }
268
269    /// The active filter mask.
270    #[must_use]
271    pub fn filter(&self) -> FilterMask {
272        self.filter
273    }
274
275    /// Whether the details overlay is open.
276    #[must_use]
277    pub fn overlay_open(&self) -> bool {
278        self.overlay_open
279    }
280
281    /// Yields the messages matching the active filter, newest first
282    /// (task 2.4). The underlying buffer retains every message regardless of
283    /// the filter.
284    pub fn iter_visible(&self) -> impl Iterator<Item = &LogEntry> {
285        self.buffer
286            .iter()
287            .filter(|entry| self.filter.matches(&entry.2))
288    }
289
290    /// Count of messages matching the active filter.
291    #[must_use]
292    pub fn visible_count(&self) -> usize {
293        self.iter_visible().count()
294    }
295
296    /// The currently highlighted entry, if any visible row exists.
297    #[must_use]
298    pub fn selected_entry(&self) -> Option<&LogEntry> {
299        self.iter_visible().nth(self.selected)
300    }
301
302    /// The highlighted row index, clamped to the visible range.
303    #[must_use]
304    pub fn selected(&self) -> usize {
305        self.selected
306    }
307
308    /// Clamps `selected` so it never points past the last visible row. Called
309    /// after any operation that can shrink the visible set (new filter, etc.).
310    fn clamp_selection(&mut self) {
311        let visible = self.visible_count();
312        if visible == 0 {
313            self.selected = 0;
314        } else if self.selected >= visible {
315            self.selected = visible - 1;
316        }
317    }
318
319    /// Moves the highlight one row toward the top (newer message).
320    pub fn select_up(&mut self) {
321        self.selected = self.selected.saturating_sub(1);
322    }
323
324    /// Moves the highlight one row toward the bottom (older message).
325    pub fn select_down(&mut self) {
326        let visible = self.visible_count();
327        if visible > 0 && self.selected + 1 < visible {
328            self.selected += 1;
329        }
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Summary extraction + row formatting (section 4)
335// ---------------------------------------------------------------------------
336
337/// The short type label rendered in a row's type column.
338#[must_use]
339pub fn type_short(msg: &BrokerMessage) -> &'static str {
340    match msg {
341        BrokerMessage::Status { .. } => "status",
342        BrokerMessage::Artifact { .. } => "artifact",
343        BrokerMessage::Blocked { .. } => "blocked",
344        BrokerMessage::Verified { .. } => "verified",
345        BrokerMessage::Feedback { .. } => "feedback",
346        BrokerMessage::Question { .. } => "question",
347        BrokerMessage::Intent { .. } => "intent",
348        BrokerMessage::VerifyNow { .. } => "verify-now",
349        BrokerMessage::AdvancedMain { .. } => "advanced-main",
350        BrokerMessage::Learning { .. } => "learning",
351    }
352}
353
354/// Derives a one-line summary from a message body (task 4.2). One arm per
355/// known variant; the row formatter truncates the result to fit the panel.
356#[must_use]
357pub fn derive_summary(msg: &BrokerMessage) -> String {
358    match msg {
359        BrokerMessage::Status { payload, .. } => match &payload.message {
360            Some(m) if !m.trim().is_empty() => format!("{}: {m}", payload.status),
361            _ => payload.status.clone(),
362        },
363        BrokerMessage::Artifact { payload, .. } => {
364            if let Some(first) = payload.modified_files.first() {
365                format!("{}: {first}", payload.status)
366            } else if !payload.exports.is_empty() {
367                format!("{}: exports {}", payload.status, payload.exports.join(", "))
368            } else {
369                payload.status.clone()
370            }
371        }
372        BrokerMessage::Blocked { payload, .. } => {
373            format!("needs {} from {}", payload.needs, payload.from)
374        }
375        BrokerMessage::Verified { payload, .. } => match &payload.message {
376            Some(m) if !m.trim().is_empty() => format!("by {}: {m}", payload.verified_by),
377            _ => format!("by {}", payload.verified_by),
378        },
379        BrokerMessage::Feedback { payload, .. } => {
380            let n = payload.errors.len();
381            let suffix = if n == 1 { "error" } else { "errors" };
382            format!("from {}: {n} {suffix}", payload.from)
383        }
384        BrokerMessage::Question { payload, .. } => payload.question.clone(),
385        BrokerMessage::Intent { payload, .. } => {
386            // Surface the first declared region (if any) alongside the
387            // human summary so region-scoped intents are distinguishable at
388            // a glance. The row formatter truncates the combined string.
389            let first_region = payload
390                .files
391                .iter()
392                .find_map(|f| f.regions().and_then(<[_]>::first));
393            match first_region {
394                Some(region) => format!("{}: {region}", payload.summary),
395                None => payload.summary.clone(),
396            }
397        }
398        BrokerMessage::VerifyNow { branch_id } => format!("verify {branch_id}"),
399        BrokerMessage::AdvancedMain { payload, .. } => match &payload.summary {
400            Some(s) if !s.trim().is_empty() => s.clone(),
401            _ => format!(
402                "{} merged into {} @ {}",
403                payload.merged_branch, payload.base, payload.new_main_sha
404            ),
405        },
406        BrokerMessage::Learning { payload, .. } => {
407            format!("{}: {}", payload.category, payload.title)
408        }
409    }
410}
411
412/// Formats a broker wall-clock timestamp as `HH:MM:SS` (UTC day clock).
413#[must_use]
414pub fn format_timestamp(ts: SystemTime) -> String {
415    ts.duration_since(SystemTime::UNIX_EPOCH).map_or_else(
416        |_| "00:00:00".to_string(),
417        |d| {
418            let secs = d.as_secs() % 86_400;
419            let hours = secs / 3600;
420            let mins = (secs % 3600) / 60;
421            let secs = secs % 60;
422            format!("{hours:02}:{mins:02}:{secs:02}")
423        },
424    )
425}
426
427/// Truncates `s` to at most `max` characters, appending `…` when it would
428/// otherwise overflow (task 4.3). The ellipsis counts toward `max`.
429#[must_use]
430pub fn truncate_ellipsis(s: &str, max: usize) -> String {
431    if s.chars().count() <= max {
432        return s.to_string();
433    }
434    if max == 0 {
435        return String::new();
436    }
437    let mut out: String = s.chars().take(max - 1).collect();
438    out.push('…');
439    out
440}
441
442/// Composes a single compact row line: `HH:MM:SS · type · agent · summary`,
443/// with the summary truncated so the whole line fits `width` columns without
444/// wrapping (compact-row-format spec). When the fixed prefix alone exceeds
445/// `width`, the whole line is truncated with an ellipsis.
446#[must_use]
447pub fn format_row_line(entry: &LogEntry, width: usize) -> String {
448    let (_, ts, msg) = entry;
449    let prefix = format!(
450        "{} · {} · {} · ",
451        format_timestamp(*ts),
452        type_short(msg),
453        msg.agent_id(),
454    );
455    let prefix_len = prefix.chars().count();
456    if prefix_len >= width {
457        return truncate_ellipsis(&prefix, width);
458    }
459    let summary = derive_summary(msg);
460    format!(
461        "{prefix}{}",
462        truncate_ellipsis(&summary, width - prefix_len)
463    )
464}
465
466/// Pretty-prints a message as indented JSON for the details overlay
467/// (task 7.2). Falls back to the `Display` form if serialization somehow
468/// fails (it should not for a well-formed message).
469#[must_use]
470pub fn pretty_json(msg: &BrokerMessage) -> String {
471    serde_json::to_string_pretty(msg).unwrap_or_else(|_| msg.to_string())
472}
473
474// ---------------------------------------------------------------------------
475// Key handling (section 6)
476// ---------------------------------------------------------------------------
477
478/// The result of routing a key through the Broker log panel.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum LogKeyAction {
481    /// The key mutated panel state; the caller should redraw.
482    Handled,
483    /// The panel did not consume the key; the caller may handle it (e.g. `q`).
484    Ignored,
485}
486
487/// Routes a key press through the Broker log panel, mutating its state.
488///
489/// Bindings (design.md D5):
490/// - `l` — toggle panel visibility
491/// - `a` — reset filter to `All`
492/// - `1`-`9` then `0` — toggle the corresponding chip (ten chips total)
493/// - `Up`/`k`, `Down`/`j` — move the row highlight
494/// - `Enter` — open the details overlay on the highlighted row
495/// - `Esc` — close the overlay
496///
497/// While the overlay is open, only `Esc` (close) is consumed so the rest of
498/// the dashboard's keys (notably `q` to quit) keep working. Returns
499/// [`LogKeyAction::Ignored`] for any key the panel does not own.
500pub fn handle_key(log: &mut BrokerLog, code: KeyCode) -> LogKeyAction {
501    // Overlay mode swallows only Esc; everything else passes through.
502    if log.overlay_open {
503        if code == KeyCode::Esc {
504            log.overlay_open = false;
505            return LogKeyAction::Handled;
506        }
507        return LogKeyAction::Ignored;
508    }
509
510    match code {
511        KeyCode::Char('l') => {
512            log.visible = !log.visible;
513            LogKeyAction::Handled
514        }
515        KeyCode::Char('a') => {
516            log.filter.reset();
517            log.clamp_selection();
518            LogKeyAction::Handled
519        }
520        KeyCode::Char(c @ ('0'..='9')) => {
521            // Digits `1`-`9` select chips 0-8; `0` selects the tenth chip.
522            let idx = if c == '0' {
523                9
524            } else {
525                (c as u8 - b'1') as usize
526            };
527            if let Some((bit, _)) = CHIPS.get(idx) {
528                log.filter.toggle(*bit);
529                log.clamp_selection();
530            }
531            LogKeyAction::Handled
532        }
533        KeyCode::Up | KeyCode::Char('k') => {
534            log.select_up();
535            LogKeyAction::Handled
536        }
537        KeyCode::Down | KeyCode::Char('j') => {
538            log.select_down();
539            LogKeyAction::Handled
540        }
541        KeyCode::Enter => {
542            if log.selected_entry().is_some() {
543                log.overlay_open = true;
544                LogKeyAction::Handled
545            } else {
546                LogKeyAction::Ignored
547            }
548        }
549        _ => LogKeyAction::Ignored,
550    }
551}
552
553// ---------------------------------------------------------------------------
554// Rendering (section 4 + 7)
555// ---------------------------------------------------------------------------
556
557/// Builds the header chip line: `[All] status artifact …`, highlighting `All`
558/// in `All` mode and any active chips otherwise.
559fn chip_line(filter: FilterMask) -> Line<'static> {
560    let active = Style::default()
561        .fg(Color::Black)
562        .bg(Color::Cyan)
563        .add_modifier(Modifier::BOLD);
564    let inactive = Style::default().fg(Color::DarkGray);
565
566    let mut spans: Vec<Span<'static>> = Vec::with_capacity(CHIPS.len() * 2 + 2);
567    spans.push(Span::styled(
568        " All ",
569        if filter.is_all() { active } else { inactive },
570    ));
571    for (i, (bit, label)) in CHIPS.iter().enumerate() {
572        spans.push(Span::raw(" "));
573        let style = if filter.is_chip_active(*bit) {
574            active
575        } else {
576            inactive
577        };
578        // Prefix each chip with its hotkey digit for discoverability: chips
579        // 0-8 use `1`-`9`, and the tenth chip uses `0`.
580        let digit = if i == 9 { 0 } else { i + 1 };
581        spans.push(Span::styled(format!("{digit}:{label}"), style));
582    }
583    Line::from(spans)
584}
585
586/// Renders the Broker log panel into `area` (section 4). When the details
587/// overlay is open, it is drawn on top of the panel (section 7).
588pub fn render(frame: &mut Frame, area: Rect, log: &BrokerLog) {
589    // The title doubles as the in-app key reference (task 6.6): the chip row
590    // below documents the 1-9/0 digit filters; the title documents the rest.
591    let title = format!(
592        "Broker log ({} shown / {} held) — l hide · a all · 1-9·0 filter · ↵ details · Esc close",
593        log.visible_count(),
594        log.len()
595    );
596    let block = Block::default().borders(Borders::ALL).title(title);
597    let inner = block.inner(area);
598    frame.render_widget(block, area);
599
600    // Header chip row, then the scrolling list beneath it.
601    let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
602    frame.render_widget(Paragraph::new(chip_line(log.filter)), rows[0]);
603
604    let list_area = rows[1];
605    let width = list_area.width as usize;
606    if log.visible_count() == 0 {
607        let empty = Paragraph::new("(no messages match the active filter)")
608            .style(Style::default().fg(Color::DarkGray));
609        frame.render_widget(empty, list_area);
610    } else {
611        let highlight = Style::default()
612            .bg(Color::Blue)
613            .fg(Color::White)
614            .add_modifier(Modifier::BOLD);
615        let items: Vec<ListItem> = log
616            .iter_visible()
617            .map(|entry| ListItem::new(format_row_line(entry, width.max(1))))
618            .collect();
619        // Render as a stateful list so ratatui scrolls the viewport to keep the
620        // selected row visible — `Up`/`Down`/`k`/`j` then reach every retained
621        // message, not just the first screenful (a plain `List`/`render_widget`
622        // draws from the top with no offset and cannot scroll).
623        let list = List::new(items).highlight_style(highlight);
624        let mut state = ListState::default();
625        state.select(Some(log.selected));
626        frame.render_stateful_widget(list, list_area, &mut state);
627    }
628
629    if log.overlay_open {
630        render_overlay(frame, area, log);
631    }
632}
633
634/// Renders the details overlay (section 7): a centered modal showing the
635/// highlighted message's pretty-printed JSON, dismissed with `Esc`.
636fn render_overlay(frame: &mut Frame, area: Rect, log: &BrokerLog) {
637    let Some(entry) = log.selected_entry() else {
638        return;
639    };
640    let overlay_area = centered_rect(area, 80, 80);
641    frame.render_widget(Clear, overlay_area);
642    let block = Block::default()
643        .borders(Borders::ALL)
644        .title("Message details — Esc to close")
645        .title_alignment(Alignment::Center)
646        .style(Style::default().bg(Color::Black));
647    let body = pretty_json(&entry.2);
648    let paragraph = Paragraph::new(body).block(block).wrap(Wrap { trim: false });
649    frame.render_widget(paragraph, overlay_area);
650}
651
652/// Returns a `Rect` centered within `area` sized to the given width/height
653/// percentages.
654fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
655    let vertical = Layout::vertical([
656        Constraint::Percentage((100 - percent_y) / 2),
657        Constraint::Percentage(percent_y),
658        Constraint::Percentage((100 - percent_y) / 2),
659    ])
660    .split(area);
661    Layout::horizontal([
662        Constraint::Percentage((100 - percent_x) / 2),
663        Constraint::Percentage(percent_x),
664        Constraint::Percentage((100 - percent_x) / 2),
665    ])
666    .split(vertical[1])[1]
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::broker::messages::{
673        ArtifactPayload, BlockedPayload, FeedbackPayload, FileIntent, IntentPayload,
674        QuestionPayload, StatusPayload, VerifiedPayload,
675    };
676
677    fn ts(secs: u64) -> SystemTime {
678        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs)
679    }
680
681    fn status(agent: &str, status: &str, message: Option<&str>) -> BrokerMessage {
682        BrokerMessage::Status {
683            agent_id: agent.to_string(),
684            payload: StatusPayload {
685                status: status.to_string(),
686                modified_files: vec![],
687                message: message.map(str::to_string),
688                ..Default::default()
689            },
690        }
691    }
692
693    fn entry(seq: u64, msg: BrokerMessage) -> LogEntry {
694        (seq, ts(seq), msg)
695    }
696
697    // -- Ring buffer (task 2.5) ------------------------------------------
698
699    #[test]
700    fn push_beyond_cap_drops_oldest() {
701        let mut log = BrokerLog::new(3, true);
702        for i in 1..=5 {
703            log.push(entry(i, status("feat-a", "working", None)));
704        }
705        assert_eq!(log.len(), 3, "buffer must cap at max");
706        // Newest (seq 5) at front; oldest retained is seq 3.
707        let seqs: Vec<u64> = log.iter_visible().map(|e| e.0).collect();
708        assert_eq!(seqs, vec![5, 4, 3]);
709    }
710
711    #[test]
712    fn new_clamps_zero_capacity_to_one() {
713        let mut log = BrokerLog::new(0, true);
714        log.push(entry(1, status("a", "working", None)));
715        log.push(entry(2, status("a", "working", None)));
716        assert_eq!(log.len(), 1);
717        assert_eq!(log.iter_visible().next().unwrap().0, 2);
718    }
719
720    #[test]
721    fn push_front_keeps_newest_at_top() {
722        let mut log = BrokerLog::new(10, true);
723        log.push(entry(1, status("a", "working", None)));
724        log.push(entry(2, status("b", "done", None)));
725        let seqs: Vec<u64> = log.iter_visible().map(|e| e.0).collect();
726        assert_eq!(seqs, vec![2, 1], "most recent message is first");
727    }
728
729    /// supervisor-introspection task 5.2 / dashboard-broker-log: a phased
730    /// supervisor status (`phase = "audit"`, with a detail body) is still a
731    /// plain `agent.status`, so it classifies under the `status` filter bit
732    /// with no separate phase filter — it shows up in the broker log filtered
733    /// by type = status.
734    #[test]
735    fn phased_supervisor_status_classifies_as_status() {
736        let msg = BrokerMessage::Status {
737            agent_id: "supervisor".to_string(),
738            payload: StatusPayload {
739                status: "working".to_string(),
740                modified_files: vec![],
741                message: Some("auditing feat/auth".to_string()),
742                phase: Some("audit".to_string()),
743                detail: Some(serde_json::json!({"branch": "feat/auth", "audit_step": "tests"})),
744                ..Default::default()
745            },
746        };
747        assert_eq!(message_bit(&msg), BIT_STATUS);
748        assert!(
749            FilterMask::all().matches(&msg),
750            "a phased status passes the default (all) filter"
751        );
752        // Narrowing the filter to just `status` (toggling from All) still
753        // surfaces the phased status — no separate phase filter needed.
754        let mut only_status = FilterMask::all();
755        only_status.toggle(BIT_STATUS);
756        assert!(
757            only_status.matches(&msg),
758            "a phased status passes the status-only filter — no separate phase filter needed"
759        );
760    }
761
762    #[test]
763    fn ingest_only_advances_past_cursor() {
764        let mut log = BrokerLog::new(10, true);
765        log.ingest(vec![
766            entry(1, status("a", "working", None)),
767            entry(2, status("b", "done", None)),
768        ]);
769        assert_eq!(log.last_seq(), 2);
770        assert_eq!(log.len(), 2);
771        // Re-ingesting the same batch plus one new entry is idempotent for
772        // the already-seen entries and only appends the new one.
773        log.ingest(vec![
774            entry(1, status("a", "working", None)),
775            entry(2, status("b", "done", None)),
776            entry(3, status("c", "blocked", None)),
777        ]);
778        assert_eq!(log.len(), 3, "duplicate seqs must not be re-added");
779        assert_eq!(log.last_seq(), 3);
780        let seqs: Vec<u64> = log.iter_visible().map(|e| e.0).collect();
781        assert_eq!(seqs, vec![3, 2, 1]);
782    }
783
784    // -- Filter bitmask (task 2.5) ---------------------------------------
785
786    #[test]
787    fn filter_all_is_default_and_shows_everything() {
788        let f = FilterMask::default();
789        assert!(f.is_all());
790        assert!(f.matches(&status("a", "working", None)));
791    }
792
793    #[test]
794    fn toggling_one_chip_narrows_to_that_type() {
795        let mut f = FilterMask::all();
796        f.toggle(BIT_STATUS);
797        assert!(!f.is_all());
798        assert!(f.matches(&status("a", "working", None)));
799        let intent = BrokerMessage::Intent {
800            agent_id: "a".to_string(),
801            payload: IntentPayload {
802                files: vec![FileIntent::from("x")],
803                summary: "s".to_string(),
804                valid_for_seconds: 60,
805            },
806        };
807        assert!(!f.matches(&intent), "non-status must be hidden");
808    }
809
810    #[test]
811    fn two_chips_combine_inclusively() {
812        let mut f = FilterMask::all();
813        f.toggle(BIT_STATUS);
814        f.toggle(BIT_INTENT);
815        let intent = BrokerMessage::Intent {
816            agent_id: "a".to_string(),
817            payload: IntentPayload {
818                files: vec![FileIntent::from("x")],
819                summary: "s".to_string(),
820                valid_for_seconds: 60,
821            },
822        };
823        assert!(f.matches(&status("a", "working", None)));
824        assert!(f.matches(&intent));
825        let blocked = BrokerMessage::Blocked {
826            agent_id: "a".to_string(),
827            payload: BlockedPayload {
828                needs: "x".to_string(),
829                from: "b".to_string(),
830            },
831        };
832        assert!(!f.matches(&blocked), "unselected type stays hidden");
833    }
834
835    #[test]
836    fn reset_returns_to_all() {
837        let mut f = FilterMask::all();
838        f.toggle(BIT_STATUS);
839        f.reset();
840        assert!(f.is_all());
841    }
842
843    #[test]
844    fn toggling_chip_off_empties_back_to_all() {
845        let mut f = FilterMask::all();
846        f.toggle(BIT_STATUS); // -> only status
847        f.toggle(BIT_STATUS); // -> empty -> All
848        assert!(f.is_all());
849    }
850
851    #[test]
852    fn is_chip_active_false_in_all_mode() {
853        let f = FilterMask::all();
854        assert!(!f.is_chip_active(BIT_STATUS));
855    }
856
857    #[test]
858    fn iter_visible_respects_filter_but_buffer_retains_all() {
859        let mut log = BrokerLog::new(10, true);
860        log.push(entry(1, status("a", "working", None)));
861        log.push(entry(
862            2,
863            BrokerMessage::Blocked {
864                agent_id: "b".to_string(),
865                payload: BlockedPayload {
866                    needs: "x".to_string(),
867                    from: "c".to_string(),
868                },
869            },
870        ));
871        log.filter.toggle(BIT_STATUS);
872        assert_eq!(log.visible_count(), 1, "only status shows");
873        assert_eq!(log.len(), 2, "buffer retains all regardless of filter");
874    }
875
876    // -- Summary extractors, one per variant (task 4.5) ------------------
877
878    #[test]
879    fn summary_status_with_message() {
880        let s = derive_summary(&status("a", "working", Some("rebasing onto main")));
881        assert_eq!(s, "working: rebasing onto main");
882    }
883
884    #[test]
885    fn summary_status_without_message() {
886        assert_eq!(derive_summary(&status("a", "idle", None)), "idle");
887    }
888
889    #[test]
890    fn summary_artifact_uses_first_modified_file() {
891        let msg = BrokerMessage::Artifact {
892            agent_id: "a".to_string(),
893            payload: ArtifactPayload {
894                status: "done".to_string(),
895                exports: vec![],
896                modified_files: vec!["src/error.rs".to_string(), "src/lib.rs".to_string()],
897            },
898        };
899        assert_eq!(derive_summary(&msg), "done: src/error.rs");
900    }
901
902    #[test]
903    fn summary_artifact_falls_back_to_exports_then_status() {
904        let with_exports = BrokerMessage::Artifact {
905            agent_id: "a".to_string(),
906            payload: ArtifactPayload {
907                status: "done".to_string(),
908                exports: vec!["PawError".to_string()],
909                modified_files: vec![],
910            },
911        };
912        assert_eq!(derive_summary(&with_exports), "done: exports PawError");
913        let bare = BrokerMessage::Artifact {
914            agent_id: "a".to_string(),
915            payload: ArtifactPayload {
916                status: "committed".to_string(),
917                exports: vec![],
918                modified_files: vec![],
919            },
920        };
921        assert_eq!(derive_summary(&bare), "committed");
922    }
923
924    #[test]
925    fn summary_blocked() {
926        let msg = BrokerMessage::Blocked {
927            agent_id: "a".to_string(),
928            payload: BlockedPayload {
929                needs: "error types".to_string(),
930                from: "feat-errors".to_string(),
931            },
932        };
933        assert_eq!(derive_summary(&msg), "needs error types from feat-errors");
934    }
935
936    #[test]
937    fn summary_verified_with_and_without_message() {
938        let with = BrokerMessage::Verified {
939            agent_id: "a".to_string(),
940            payload: VerifiedPayload {
941                verified_by: "supervisor".to_string(),
942                message: Some("all tests pass".to_string()),
943            },
944        };
945        assert_eq!(derive_summary(&with), "by supervisor: all tests pass");
946        let without = BrokerMessage::Verified {
947            agent_id: "a".to_string(),
948            payload: VerifiedPayload {
949                verified_by: "supervisor".to_string(),
950                message: None,
951            },
952        };
953        assert_eq!(derive_summary(&without), "by supervisor");
954    }
955
956    #[test]
957    fn summary_feedback_pluralizes() {
958        let one = BrokerMessage::Feedback {
959            agent_id: "a".to_string(),
960            payload: FeedbackPayload {
961                from: "supervisor".to_string(),
962                errors: vec!["e1".to_string()],
963            },
964        };
965        assert_eq!(derive_summary(&one), "from supervisor: 1 error");
966        let many = BrokerMessage::Feedback {
967            agent_id: "a".to_string(),
968            payload: FeedbackPayload {
969                from: "supervisor".to_string(),
970                errors: vec!["e1".to_string(), "e2".to_string()],
971            },
972        };
973        assert_eq!(derive_summary(&many), "from supervisor: 2 errors");
974    }
975
976    #[test]
977    fn summary_question() {
978        let msg = BrokerMessage::Question {
979            agent_id: "a".to_string(),
980            payload: QuestionPayload {
981                question: "rs256 or hs256?".to_string(),
982            },
983        };
984        assert_eq!(derive_summary(&msg), "rs256 or hs256?");
985    }
986
987    #[test]
988    fn summary_intent() {
989        let msg = BrokerMessage::Intent {
990            agent_id: "a".to_string(),
991            payload: IntentPayload {
992                files: vec![FileIntent::from("src/a.rs")],
993                summary: "wire AuthClient".to_string(),
994                valid_for_seconds: 900,
995            },
996        };
997        assert_eq!(derive_summary(&msg), "wire AuthClient");
998    }
999
1000    #[test]
1001    fn summary_intent_with_regions_includes_first_region() {
1002        use crate::broker::messages::Region;
1003        let msg = BrokerMessage::Intent {
1004            agent_id: "a".to_string(),
1005            payload: IntentPayload {
1006                files: vec![FileIntent::Detailed {
1007                    path: "src/auth.rs".to_string(),
1008                    regions: vec![
1009                        Region::Function {
1010                            name: "validate_token".to_string(),
1011                        },
1012                        Region::Function {
1013                            name: "refresh_session".to_string(),
1014                        },
1015                    ],
1016                }],
1017                summary: "harden auth".to_string(),
1018                valid_for_seconds: 900,
1019            },
1020        };
1021        assert_eq!(derive_summary(&msg), "harden auth: function validate_token");
1022    }
1023
1024    #[test]
1025    fn summary_verify_now() {
1026        let msg = BrokerMessage::VerifyNow {
1027            branch_id: "feat-bar".to_string(),
1028        };
1029        assert_eq!(derive_summary(&msg), "verify feat-bar");
1030    }
1031
1032    // -- Truncation (task 4.3) -------------------------------------------
1033
1034    #[test]
1035    fn truncate_shorter_than_max_is_unchanged() {
1036        assert_eq!(truncate_ellipsis("hello", 10), "hello");
1037        assert_eq!(truncate_ellipsis("hello", 5), "hello");
1038    }
1039
1040    #[test]
1041    fn truncate_adds_ellipsis_and_fits_width() {
1042        let out = truncate_ellipsis("hello world", 5);
1043        assert_eq!(out.chars().count(), 5);
1044        assert!(out.ends_with('…'));
1045        assert_eq!(out, "hell…");
1046    }
1047
1048    #[test]
1049    fn truncate_zero_width_is_empty() {
1050        assert_eq!(truncate_ellipsis("hello", 0), "");
1051    }
1052
1053    // -- Row formatting --------------------------------------------------
1054
1055    #[test]
1056    fn row_contains_four_documented_fields() {
1057        let e = entry(
1058            1,
1059            status("feat-auth", "working", Some("rebasing onto main")),
1060        );
1061        let line = format_row_line(&e, 120);
1062        assert!(line.contains("00:00:01"), "timestamp HH:MM:SS: {line}");
1063        assert!(line.contains("status"), "type short form: {line}");
1064        assert!(line.contains("feat-auth"), "agent id: {line}");
1065        assert!(line.contains("rebasing onto main"), "summary: {line}");
1066    }
1067
1068    #[test]
1069    fn row_truncates_long_summary_to_width() {
1070        let long = "x".repeat(300);
1071        let e = entry(1, status("feat-auth", "working", Some(&long)));
1072        let line = format_row_line(&e, 60);
1073        assert_eq!(line.chars().count(), 60, "row must fit the panel width");
1074        assert!(
1075            line.ends_with('…'),
1076            "overflowing summary ends with ellipsis"
1077        );
1078    }
1079
1080    #[test]
1081    fn row_handles_prefix_wider_than_width() {
1082        let e = entry(1, status("feat-auth", "working", Some("anything")));
1083        let line = format_row_line(&e, 8);
1084        assert_eq!(line.chars().count(), 8);
1085        assert!(line.ends_with('…'));
1086    }
1087
1088    // -- Timestamp -------------------------------------------------------
1089
1090    #[test]
1091    fn timestamp_formats_hh_mm_ss() {
1092        // 14:35:09 UTC = 14*3600 + 35*60 + 9 = 52509 seconds into the day.
1093        assert_eq!(format_timestamp(ts(52_509)), "14:35:09");
1094    }
1095
1096    // -- Selection navigation --------------------------------------------
1097
1098    #[test]
1099    fn selection_navigates_within_visible_bounds() {
1100        let mut log = BrokerLog::new(10, true);
1101        for i in 1..=3 {
1102            log.push(entry(i, status("a", "working", None)));
1103        }
1104        assert_eq!(log.selected(), 0);
1105        log.select_up(); // already at top, stays
1106        assert_eq!(log.selected(), 0);
1107        log.select_down();
1108        log.select_down();
1109        assert_eq!(log.selected(), 2);
1110        log.select_down(); // at bottom, stays
1111        assert_eq!(log.selected(), 2);
1112    }
1113
1114    #[test]
1115    fn selection_clamps_when_filter_shrinks_visible_set() {
1116        let mut log = BrokerLog::new(10, true);
1117        log.push(entry(1, status("a", "working", None)));
1118        log.push(entry(
1119            2,
1120            BrokerMessage::Blocked {
1121                agent_id: "b".to_string(),
1122                payload: BlockedPayload {
1123                    needs: "x".to_string(),
1124                    from: "c".to_string(),
1125                },
1126            },
1127        ));
1128        log.select_down(); // selected = 1
1129        assert_eq!(log.selected(), 1);
1130        // Filter to only status (1 visible) — selection must clamp to 0.
1131        handle_key(&mut log, KeyCode::Char('1'));
1132        assert_eq!(log.visible_count(), 1);
1133        assert_eq!(log.selected(), 0);
1134    }
1135
1136    // -- Key handling (section 6) ----------------------------------------
1137
1138    #[test]
1139    fn key_l_toggles_visibility() {
1140        let mut log = BrokerLog::new(10, true);
1141        assert!(log.visible);
1142        assert_eq!(
1143            handle_key(&mut log, KeyCode::Char('l')),
1144            LogKeyAction::Handled
1145        );
1146        assert!(!log.visible);
1147        handle_key(&mut log, KeyCode::Char('l'));
1148        assert!(log.visible);
1149    }
1150
1151    #[test]
1152    fn key_a_resets_filter() {
1153        let mut log = BrokerLog::new(10, true);
1154        handle_key(&mut log, KeyCode::Char('1')); // narrow to status
1155        assert!(!log.filter().is_all());
1156        handle_key(&mut log, KeyCode::Char('a'));
1157        assert!(log.filter().is_all());
1158    }
1159
1160    #[test]
1161    fn key_digits_map_to_chips_in_order() {
1162        for (i, (bit, _)) in CHIPS.iter().enumerate() {
1163            let mut log = BrokerLog::new(10, true);
1164            // Chips 0-8 map to `1`-`9`; the tenth chip (index 9) maps to `0`.
1165            let key = if i == 9 {
1166                '0'
1167            } else {
1168                char::from(b'1' + u8::try_from(i).unwrap())
1169            };
1170            handle_key(&mut log, KeyCode::Char(key));
1171            assert!(
1172                log.filter().is_chip_active(*bit),
1173                "digit {key} must toggle chip index {i}"
1174            );
1175        }
1176    }
1177
1178    #[test]
1179    fn key_enter_opens_overlay_only_when_a_row_exists() {
1180        let mut empty = BrokerLog::new(10, true);
1181        assert_eq!(
1182            handle_key(&mut empty, KeyCode::Enter),
1183            LogKeyAction::Ignored
1184        );
1185        assert!(!empty.overlay_open());
1186
1187        let mut log = BrokerLog::new(10, true);
1188        log.push(entry(1, status("a", "working", None)));
1189        assert_eq!(handle_key(&mut log, KeyCode::Enter), LogKeyAction::Handled);
1190        assert!(log.overlay_open());
1191    }
1192
1193    #[test]
1194    fn key_esc_closes_overlay_and_passes_other_keys_through() {
1195        let mut log = BrokerLog::new(10, true);
1196        log.push(entry(1, status("a", "working", None)));
1197        handle_key(&mut log, KeyCode::Enter);
1198        assert!(log.overlay_open());
1199        // While the overlay is open, non-Esc keys are not consumed so the
1200        // dashboard's `q`-to-quit keeps working.
1201        assert_eq!(
1202            handle_key(&mut log, KeyCode::Char('q')),
1203            LogKeyAction::Ignored
1204        );
1205        assert!(log.overlay_open(), "q must not close the overlay");
1206        assert_eq!(handle_key(&mut log, KeyCode::Esc), LogKeyAction::Handled);
1207        assert!(!log.overlay_open());
1208    }
1209
1210    #[test]
1211    fn unhandled_key_is_ignored() {
1212        let mut log = BrokerLog::new(10, true);
1213        assert_eq!(
1214            handle_key(&mut log, KeyCode::Char('z')),
1215            LogKeyAction::Ignored
1216        );
1217        assert_eq!(
1218            handle_key(&mut log, KeyCode::Char('q')),
1219            LogKeyAction::Ignored
1220        );
1221    }
1222
1223    // -- Pretty JSON (task 7.2) ------------------------------------------
1224
1225    #[test]
1226    fn pretty_json_is_multiline_and_matches_message() {
1227        let msg = status("feat-auth", "working", Some("rebasing"));
1228        let json = pretty_json(&msg);
1229        assert!(
1230            json.contains('\n'),
1231            "pretty JSON must be indented/multiline"
1232        );
1233        assert!(json.contains("agent.status"));
1234        assert!(json.contains("feat-auth"));
1235        // Round-trips back to the same message.
1236        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1237        assert_eq!(back, msg);
1238    }
1239}