1use 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
34pub type LogEntry = (u64, SystemTime, BrokerMessage);
38
39pub const BIT_STATUS: u16 = 1 << 0;
45pub const BIT_ARTIFACT: u16 = 1 << 1;
47pub const BIT_BLOCKED: u16 = 1 << 2;
49pub const BIT_VERIFIED: u16 = 1 << 3;
51pub const BIT_FEEDBACK: u16 = 1 << 4;
53pub const BIT_QUESTION: u16 = 1 << 5;
55pub const BIT_INTENT: u16 = 1 << 6;
57pub const BIT_VERIFY_NOW: u16 = 1 << 7;
59pub const BIT_ADVANCED_MAIN: u16 = 1 << 8;
61pub const BIT_LEARNING: u16 = 1 << 9;
63
64pub const FILTER_ALL: u16 = 0xFFFF;
68
69pub 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#[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#[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 #[must_use]
132 pub fn all() -> Self {
133 Self(FILTER_ALL)
134 }
135
136 #[must_use]
138 pub fn is_all(self) -> bool {
139 self.0 == FILTER_ALL
140 }
141
142 pub fn reset(&mut self) {
144 self.0 = FILTER_ALL;
145 }
146
147 pub fn toggle(&mut self, bit: u16) {
149 if self.0 == FILTER_ALL {
150 self.0 = bit;
152 } else {
153 self.0 ^= bit;
154 if self.0 == 0 {
155 self.0 = FILTER_ALL;
157 }
158 }
159 }
160
161 #[must_use]
163 pub fn matches(self, msg: &BrokerMessage) -> bool {
164 self.is_all() || (self.0 & message_bit(msg)) != 0
165 }
166
167 #[must_use]
170 pub fn is_chip_active(self, bit: u16) -> bool {
171 !self.is_all() && (self.0 & bit) != 0
172 }
173}
174
175#[derive(Debug)]
182pub struct BrokerLog {
183 buffer: VecDeque<LogEntry>,
185 max: usize,
187 filter: FilterMask,
189 pub visible: bool,
191 last_seq: u64,
193 selected: usize,
195 overlay_open: bool,
197}
198
199impl BrokerLog {
200 #[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 pub fn push(&mut self, entry: LogEntry) {
221 self.buffer.push_front(entry);
222 self.buffer.truncate(self.max);
223 }
224
225 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 #[must_use]
247 pub fn last_seq(&self) -> u64 {
248 self.last_seq
249 }
250
251 #[must_use]
253 pub fn capacity(&self) -> usize {
254 self.max
255 }
256
257 #[must_use]
259 pub fn len(&self) -> usize {
260 self.buffer.len()
261 }
262
263 #[must_use]
265 pub fn is_empty(&self) -> bool {
266 self.buffer.is_empty()
267 }
268
269 #[must_use]
271 pub fn filter(&self) -> FilterMask {
272 self.filter
273 }
274
275 #[must_use]
277 pub fn overlay_open(&self) -> bool {
278 self.overlay_open
279 }
280
281 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 #[must_use]
292 pub fn visible_count(&self) -> usize {
293 self.iter_visible().count()
294 }
295
296 #[must_use]
298 pub fn selected_entry(&self) -> Option<&LogEntry> {
299 self.iter_visible().nth(self.selected)
300 }
301
302 #[must_use]
304 pub fn selected(&self) -> usize {
305 self.selected
306 }
307
308 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 pub fn select_up(&mut self) {
321 self.selected = self.selected.saturating_sub(1);
322 }
323
324 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#[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#[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 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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum LogKeyAction {
481 Handled,
483 Ignored,
485}
486
487pub fn handle_key(log: &mut BrokerLog, code: KeyCode) -> LogKeyAction {
501 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 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
553fn 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 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
586pub fn render(frame: &mut Frame, area: Rect, log: &BrokerLog) {
589 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 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 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
634fn 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
652fn 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 #[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 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 #[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 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 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 #[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); f.toggle(BIT_STATUS); 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 #[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 #[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 #[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 #[test]
1091 fn timestamp_formats_hh_mm_ss() {
1092 assert_eq!(format_timestamp(ts(52_509)), "14:35:09");
1094 }
1095
1096 #[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(); assert_eq!(log.selected(), 0);
1107 log.select_down();
1108 log.select_down();
1109 assert_eq!(log.selected(), 2);
1110 log.select_down(); 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(); assert_eq!(log.selected(), 1);
1130 handle_key(&mut log, KeyCode::Char('1'));
1132 assert_eq!(log.visible_count(), 1);
1133 assert_eq!(log.selected(), 0);
1134 }
1135
1136 #[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')); 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 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 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 #[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 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1237 assert_eq!(back, msg);
1238 }
1239}