1use ratatui::{
8 Frame,
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Modifier, Style, Stylize},
11 text::{Line, Span},
12 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
13};
14
15use crate::bus::{BusEnvelope, BusMessage};
16
17#[derive(Debug, Clone)]
18pub struct ProtocolSummary {
19 pub cwd_display: String,
20 pub worker_id: Option<String>,
21 pub worker_name: Option<String>,
22 pub a2a_connected: bool,
23 pub processing: Option<bool>,
24 pub registered_agents: Vec<String>,
25 pub queued_tasks: usize,
26 pub recent_task: Option<String>,
27 pub peer_endpoint_ready: bool,
28}
29
30#[derive(Debug, Clone)]
31pub struct BusLogEntry {
32 pub timestamp: String,
33 pub topic: String,
34 pub sender_id: String,
35 pub kind: String,
36 pub summary: String,
37 pub detail: String,
38 pub kind_color: Color,
39}
40
41impl BusLogEntry {
42 pub fn from_envelope(env: &BusEnvelope) -> Self {
43 let timestamp = env.timestamp.format("%H:%M:%S%.3f").to_string();
44 let topic = env.topic.clone();
45 let sender_id = env.sender_id.clone();
46
47 let (kind, summary, detail, kind_color) = match &env.message {
48 BusMessage::AgentReady {
49 agent_id,
50 capabilities,
51 } => (
52 "READY".to_string(),
53 format!("{agent_id} online ({} caps)", capabilities.len()),
54 format!(
55 "Agent: {agent_id}\nCapabilities: {}",
56 capabilities.join(", ")
57 ),
58 Color::Green,
59 ),
60 BusMessage::AgentShutdown { agent_id } => (
61 "SHUTDOWN".to_string(),
62 format!("{agent_id} shutting down"),
63 format!("Agent: {agent_id}"),
64 Color::Red,
65 ),
66 BusMessage::AgentMessage { from, to, parts } => {
67 let text_preview: String = parts
68 .iter()
69 .filter_map(|p| match p {
70 crate::a2a::types::Part::Text { text } => Some(text.as_str()),
71 _ => None,
72 })
73 .collect::<Vec<_>>()
74 .join(" ");
75 let preview = truncate(&text_preview, 80);
76 let a2a = from == "remote-a2a" || to == "remote-a2a";
77 let kind = if a2a { "A2A•MSG" } else { "MSG" };
78 let detail = crate::tui::bus_log_entry_payload::message(
79 from,
80 to,
81 a2a,
82 parts.len(),
83 &text_preview,
84 );
85 (
86 kind.to_string(),
87 format!("{from} → {to}: {preview}"),
88 detail,
89 Color::Cyan,
90 )
91 }
92 BusMessage::TaskUpdate {
93 task_id,
94 state,
95 message,
96 } => {
97 let msg = message.as_deref().unwrap_or("");
98 (
99 "TASK".to_string(),
100 format!("{task_id} → {state:?} {}", truncate(msg, 50)),
101 format!("Task: {task_id}\nState: {state:?}\nMessage: {msg}"),
102 Color::Yellow,
103 )
104 }
105 BusMessage::ArtifactUpdate { task_id, artifact } => (
106 "ARTIFACT".to_string(),
107 format!("task={task_id} parts={}", artifact.parts.len()),
108 format!(
109 "Task: {task_id}\nArtifact: {}\nParts: {}",
110 artifact.name.as_deref().unwrap_or("(unnamed)"),
111 artifact.parts.len()
112 ),
113 Color::Magenta,
114 ),
115 BusMessage::SharedResult { key, tags, .. } => (
116 "RESULT".to_string(),
117 format!("key={key} tags=[{}]", tags.join(",")),
118 format!("Key: {key}\nTags: {}", tags.join(", ")),
119 Color::Blue,
120 ),
121 BusMessage::ToolRequest {
122 request_id,
123 agent_id,
124 tool_name,
125 arguments,
126 step,
127 } => {
128 let args_str = serde_json::to_string(arguments).unwrap_or_default();
129 (
130 "TOOL→".to_string(),
131 format!("{agent_id} call {tool_name}"),
132 format!(
133 "Request: {request_id}\nAgent: {agent_id}\nStep: {step}\nTool: {tool_name}\nArgs: {}",
134 truncate(&args_str, 200)
135 ),
136 Color::Yellow,
137 )
138 }
139 BusMessage::ToolResponse {
140 request_id,
141 agent_id,
142 tool_name,
143 result,
144 success,
145 step,
146 } => {
147 let icon = if *success { "✓" } else { "✗" };
148 (
149 "←TOOL".to_string(),
150 format!("{icon} {agent_id} {tool_name}"),
151 crate::tui::bus_log_entry_payload::tool_response(
152 request_id, agent_id, *step, tool_name, *success, result,
153 ),
154 if *success { Color::Green } else { Color::Red },
155 )
156 }
157 BusMessage::Heartbeat { agent_id, status } => {
158 let is_a2a = status.starts_with("discovered via A2A");
159 (
160 if is_a2a { "A2A•PEER" } else { "BEAT" }.to_string(),
161 format!("{agent_id} [{status}]"),
162 format!("Agent: {agent_id}\nStatus: {status}"),
163 if is_a2a {
164 Color::LightCyan
165 } else {
166 Color::DarkGray
167 },
168 )
169 }
170 BusMessage::RalphLearning {
171 prd_id,
172 story_id,
173 iteration,
174 learnings,
175 ..
176 } => (
177 "LEARN".to_string(),
178 format!("{story_id} iter {iteration} ({} items)", learnings.len()),
179 format!(
180 "PRD: {prd_id}\nStory: {story_id}\nIteration: {iteration}\nLearnings:\n{}",
181 learnings.join("\n")
182 ),
183 Color::Cyan,
184 ),
185 BusMessage::RalphHandoff {
186 prd_id,
187 from_story,
188 to_story,
189 progress_summary,
190 ..
191 } => (
192 "HANDOFF".to_string(),
193 format!("{from_story} → {to_story}"),
194 format!(
195 "PRD: {prd_id}\nFrom: {from_story}\nTo: {to_story}\nSummary: {progress_summary}"
196 ),
197 Color::Blue,
198 ),
199 BusMessage::RalphProgress {
200 prd_id,
201 passed,
202 total,
203 iteration,
204 status,
205 } => (
206 "PRD".to_string(),
207 format!("{passed}/{total} stories (iter {iteration}) [{status}]"),
208 format!(
209 "PRD: {prd_id}\nPassed: {passed}/{total}\nIteration: {iteration}\nStatus: {status}"
210 ),
211 Color::Yellow,
212 ),
213 BusMessage::ToolOutputFull {
214 agent_id,
215 tool_name,
216 output,
217 success,
218 step,
219 } => {
220 let icon = if *success { "✓" } else { "✗" };
221 let preview = truncate(output, 120);
222 (
223 "TOOL•FULL".to_string(),
224 format!("{icon} {agent_id} step {step} {tool_name}: {preview}"),
225 crate::tui::bus_log_entry_payload::tool_full(
226 agent_id, tool_name, *step, *success, output,
227 ),
228 if *success { Color::Green } else { Color::Red },
229 )
230 }
231 BusMessage::AgentThinking {
232 agent_id,
233 thinking,
234 step,
235 } => {
236 let preview = truncate(thinking, 120);
237 (
238 "THINK".to_string(),
239 format!("{agent_id} step {step}: {preview}"),
240 crate::tui::bus_log_entry_payload::thinking(agent_id, *step, thinking),
241 Color::LightMagenta,
242 )
243 }
244 BusMessage::VoiceSessionStarted {
245 room_name,
246 agent_id,
247 voice_id,
248 } => (
249 "VOICE+".to_string(),
250 format!("{room_name} agent={agent_id} voice={voice_id}"),
251 format!("Room: {room_name}\nAgent: {agent_id}\nVoice: {voice_id}"),
252 Color::LightCyan,
253 ),
254 BusMessage::VoiceTranscript {
255 room_name,
256 text,
257 role,
258 is_final,
259 } => {
260 let fin = if *is_final { " [final]" } else { "" };
261 let preview = truncate(text, 100);
262 (
263 "VOICE•T".to_string(),
264 format!("{room_name} [{role}]{fin}: {preview}"),
265 crate::tui::bus_log_entry_payload::transcript(room_name, role, *is_final, text),
266 Color::LightCyan,
267 )
268 }
269 BusMessage::VoiceAgentStateChanged { room_name, state } => (
270 "VOICE•S".to_string(),
271 format!("{room_name} → {state}"),
272 format!("Room: {room_name}\nState: {state}"),
273 Color::LightCyan,
274 ),
275 BusMessage::VoiceSessionEnded { room_name, reason } => (
276 "VOICE-".to_string(),
277 format!("{room_name} ended: {reason}"),
278 format!("Room: {room_name}\nReason: {reason}"),
279 Color::DarkGray,
280 ),
281 };
282
283 Self {
284 timestamp,
285 topic,
286 sender_id,
287 kind,
288 summary,
289 detail,
290 kind_color,
291 }
292 }
293}
294
295#[derive(Debug)]
296pub struct BusLogState {
297 pub entries: Vec<BusLogEntry>,
298 pub selected_index: usize,
299 pub detail_mode: bool,
300 pub detail_scroll: usize,
301 pub filter: String,
302 pub filter_input_mode: bool,
303 pub auto_scroll: bool,
304 pub list_state: ListState,
305 pub max_entries: usize,
306}
307
308impl Default for BusLogState {
309 fn default() -> Self {
310 Self {
311 entries: Vec::new(),
312 selected_index: 0,
313 detail_mode: false,
314 detail_scroll: 0,
315 filter: String::new(),
316 filter_input_mode: false,
317 auto_scroll: true,
318 list_state: ListState::default(),
319 max_entries: 2_000,
320 }
321 }
322}
323
324impl BusLogState {
325 pub fn new() -> Self {
326 Self::default()
327 }
328
329 pub fn push(&mut self, entry: BusLogEntry) {
330 self.entries.push(entry);
331 if self.entries.len() > self.max_entries {
332 let overflow = self.entries.len() - self.max_entries;
333 self.entries.drain(0..overflow);
334 self.selected_index = self.selected_index.saturating_sub(overflow);
335 }
336 if self.auto_scroll {
337 self.selected_index = self.visible_count().saturating_sub(1);
338 }
339 }
340
341 pub fn ingest(&mut self, env: &BusEnvelope) {
342 self.push(BusLogEntry::from_envelope(env));
343 }
344
345 pub fn filtered_entries(&self) -> Vec<&BusLogEntry> {
346 if self.filter.trim().is_empty() {
347 self.entries.iter().collect()
348 } else {
349 let needle = self.filter.to_lowercase();
350 self.entries
351 .iter()
352 .filter(|entry| {
353 entry.topic.to_lowercase().contains(&needle)
354 || entry.sender_id.to_lowercase().contains(&needle)
355 || entry.kind.to_lowercase().contains(&needle)
356 || entry.summary.to_lowercase().contains(&needle)
357 })
358 .collect()
359 }
360 }
361
362 pub fn visible_count(&self) -> usize {
363 self.filtered_entries().len()
364 }
365
366 pub fn select_prev(&mut self) {
367 self.auto_scroll = false;
368 if self.selected_index > 0 {
369 self.selected_index -= 1;
370 }
371 }
372
373 pub fn select_next(&mut self) {
374 self.auto_scroll = false;
375 let max_index = self.visible_count().saturating_sub(1);
376 if self.selected_index < max_index {
377 self.selected_index += 1;
378 }
379 }
380
381 pub fn enter_detail(&mut self) {
382 self.detail_mode = true;
383 self.detail_scroll = 0;
384 }
385
386 pub fn exit_detail(&mut self) {
387 self.detail_mode = false;
388 self.detail_scroll = 0;
389 }
390
391 pub fn detail_scroll_up(&mut self, amount: usize) {
392 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
393 }
394
395 pub fn detail_scroll_down(&mut self, amount: usize) {
396 self.detail_scroll = self.detail_scroll.saturating_add(amount);
397 }
398
399 pub fn selected_entry(&self) -> Option<&BusLogEntry> {
400 self.filtered_entries().get(self.selected_index).copied()
401 }
402
403 pub fn a2a_message_count(&self) -> usize {
404 self.entries
405 .iter()
406 .filter(|entry| entry.kind == "A2A•MSG")
407 .count()
408 }
409
410 pub fn enter_filter_mode(&mut self) {
411 self.filter_input_mode = true;
412 }
413
414 pub fn exit_filter_mode(&mut self) {
415 self.filter_input_mode = false;
416 }
417
418 pub fn clear_filter(&mut self) {
419 self.filter.clear();
420 self.selected_index = self.visible_count().saturating_sub(1);
421 }
422
423 pub fn push_filter_char(&mut self, c: char) {
424 self.filter.push(c);
425 self.selected_index = 0;
426 }
427
428 pub fn pop_filter_char(&mut self) {
429 self.filter.pop();
430 self.selected_index = 0;
431 }
432}
433
434pub fn render_bus_log(f: &mut Frame, state: &mut BusLogState, area: Rect) {
435 render_bus_log_with_summary(f, state, area, None);
436}
437
438pub fn render_bus_log_with_summary(
439 f: &mut Frame,
440 state: &mut BusLogState,
441 area: Rect,
442 summary: Option<ProtocolSummary>,
443) {
444 if let Some(summary) = summary {
445 let chunks = Layout::default()
446 .direction(Direction::Vertical)
447 .constraints([
448 Constraint::Length(8),
449 Constraint::Min(8),
450 Constraint::Length(2),
451 ])
452 .split(area);
453
454 let worker_label = if summary.a2a_connected {
455 "connected"
456 } else {
457 "offline"
458 };
459 let worker_color = if summary.a2a_connected {
460 Color::Green
461 } else {
462 Color::Red
463 };
464 let processing_label = match summary.processing {
465 Some(true) => "processing",
466 Some(false) => "idle",
467 None => "unknown",
468 };
469 let processing_color = match summary.processing {
470 Some(true) => Color::Yellow,
471 Some(false) => Color::Green,
472 None => Color::DarkGray,
473 };
474 let worker_id = summary.worker_id.as_deref().unwrap_or("n/a");
475 let worker_name = summary.worker_name.as_deref().unwrap_or("n/a");
476 let peer_label = if summary.peer_endpoint_ready {
477 "ready"
478 } else {
479 "off"
480 };
481 let peer_color = if summary.peer_endpoint_ready {
482 Color::Cyan
483 } else {
484 Color::DarkGray
485 };
486 let a2a_count = state.a2a_message_count();
487 let recent_task = summary
488 .recent_task
489 .unwrap_or_else(|| "No recent A2A tasks".to_string());
490 let registered_agents = if summary.registered_agents.is_empty() {
491 "none".to_string()
492 } else {
493 truncate(&summary.registered_agents.join(", "), 120)
494 };
495 let panel = Paragraph::new(vec![
496 Line::from(vec![
497 "A2A worker: ".dim(),
498 Span::styled(worker_label, Style::default().fg(worker_color).bold()),
499 " • ".dim(),
500 Span::raw(worker_name).cyan(),
501 " • ".dim(),
502 Span::raw(worker_id).dim(),
503 ]),
504 Line::from(vec![
505 "A2A peer: ".dim(),
506 Span::styled(peer_label, Style::default().fg(peer_color).bold()),
507 " • ".dim(),
508 Span::raw(format!("{a2a_count} visible A2A msg(s)")),
509 ]),
510 Line::from(vec![
511 "Heartbeat: ".dim(),
512 Span::styled(
513 processing_label,
514 Style::default().fg(processing_color).bold(),
515 ),
516 " • ".dim(),
517 Span::raw(format!("{} queued task(s)", summary.queued_tasks)),
518 ]),
519 Line::from(vec!["Agents: ".dim(), Span::raw(registered_agents)]),
520 Line::from(vec!["Workspace: ".dim(), Span::raw(summary.cwd_display)]),
521 Line::from(vec![
522 "Recent task: ".dim(),
523 Span::raw(truncate(&recent_task, 120)),
524 ]),
525 ])
526 .block(
527 Block::default()
528 .borders(Borders::ALL)
529 .title("Protocol Summary"),
530 )
531 .wrap(Wrap { trim: true });
532 f.render_widget(panel, chunks[0]);
533 render_bus_body(f, state, chunks[1], chunks[2]);
534 } else {
535 let chunks = Layout::default()
536 .direction(Direction::Vertical)
537 .constraints([Constraint::Min(8), Constraint::Length(2)])
538 .split(area);
539 render_bus_body(f, state, chunks[0], chunks[1]);
540 }
541}
542
543fn render_bus_body(f: &mut Frame, state: &mut BusLogState, main_area: Rect, footer_area: Rect) {
544 if state.detail_mode {
545 let detail = state
546 .selected_entry()
547 .map(|entry| entry.detail.clone())
548 .unwrap_or_else(|| "No entry selected".to_string());
549 let widget = Paragraph::new(detail)
550 .block(
551 Block::default()
552 .borders(Borders::ALL)
553 .title("Protocol Detail"),
554 )
555 .wrap(Wrap { trim: false })
556 .scroll((state.detail_scroll.min(u16::MAX as usize) as u16, 0));
557 f.render_widget(widget, main_area);
558 } else {
559 let filtered = state.filtered_entries();
560 let filtered_len = filtered.len();
561 let filter_title = if state.filter.is_empty() {
562 format!("Protocol Bus Log ({filtered_len})")
563 } else if state.filter_input_mode {
564 format!("Protocol Bus Log [{}_] ({filtered_len})", state.filter)
565 } else {
566 format!("Protocol Bus Log [{}] ({filtered_len})", state.filter)
567 };
568 let items: Vec<ListItem<'_>> = filtered
569 .iter()
570 .enumerate()
571 .map(|(idx, entry)| {
572 let prefix = if idx == state.selected_index {
573 "▶ "
574 } else {
575 " "
576 };
577 ListItem::new(Line::from(vec![
578 Span::raw(prefix),
579 Span::styled(
580 format!("[{}] ", entry.kind),
581 Style::default()
582 .fg(entry.kind_color)
583 .add_modifier(Modifier::BOLD),
584 ),
585 Span::raw(format!(
586 "{} {} {}",
587 entry.timestamp, entry.sender_id, entry.summary
588 )),
589 ]))
590 })
591 .collect();
592 drop(filtered);
593 state.list_state.select(Some(
594 state.selected_index.min(filtered_len.saturating_sub(1)),
595 ));
596 let list =
597 List::new(items).block(Block::default().borders(Borders::ALL).title(filter_title));
598 f.render_stateful_widget(list, main_area, &mut state.list_state);
599 }
600
601 let footer = Paragraph::new(Line::from(vec![
602 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
603 Span::raw(": nav "),
604 Span::styled("Enter", Style::default().fg(Color::Yellow)),
605 Span::raw(": detail/apply "),
606 Span::styled("/", Style::default().fg(Color::Yellow)),
607 Span::raw(": filter "),
608 Span::styled("Backspace", Style::default().fg(Color::Yellow)),
609 Span::raw(": edit "),
610 Span::styled("c", Style::default().fg(Color::Yellow)),
611 Span::raw(": clear "),
612 Span::styled("Esc", Style::default().fg(Color::Yellow)),
613 Span::raw(": back/close filter"),
614 ]));
615 f.render_widget(footer, footer_area);
616}
617
618fn truncate(value: &str, max_chars: usize) -> String {
619 let mut chars = value.chars();
620 let truncated: String = chars.by_ref().take(max_chars).collect();
621 if chars.next().is_some() {
622 format!("{truncated}…")
623 } else {
624 truncated
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::{BusLogEntry, BusLogState};
631 use ratatui::style::Color;
632
633 fn entry(summary: &str, topic: &str) -> BusLogEntry {
634 BusLogEntry {
635 timestamp: "00:00:00.000".to_string(),
636 topic: topic.to_string(),
637 sender_id: "tester".to_string(),
638 kind: "MSG".to_string(),
639 summary: summary.to_string(),
640 detail: summary.to_string(),
641 kind_color: Color::Cyan,
642 }
643 }
644
645 #[test]
646 fn bus_filter_mode_can_be_entered_and_exited() {
647 let mut state = BusLogState::new();
648 assert!(!state.filter_input_mode);
649 state.enter_filter_mode();
650 assert!(state.filter_input_mode);
651 state.exit_filter_mode();
652 assert!(!state.filter_input_mode);
653 }
654
655 #[test]
656 fn bus_filter_chars_update_visible_entries() {
657 let mut state = BusLogState::new();
658 state.push(entry("alpha event", "protocol.alpha"));
659 state.push(entry("beta event", "protocol.beta"));
660
661 assert_eq!(state.visible_count(), 2);
662 state.push_filter_char('b');
663 state.push_filter_char('e');
664
665 assert_eq!(state.filter, "be");
666 assert_eq!(state.visible_count(), 1);
667 assert_eq!(
668 state.selected_entry().map(|e| e.summary.as_str()),
669 Some("beta event")
670 );
671 }
672
673 #[test]
674 fn bus_filter_backspace_and_clear_restore_entries() {
675 let mut state = BusLogState::new();
676 state.push(entry("alpha event", "protocol.alpha"));
677 state.push(entry("beta event", "protocol.beta"));
678
679 state.push_filter_char('a');
680 state.push_filter_char('l');
681 assert_eq!(state.visible_count(), 1);
682
683 state.pop_filter_char();
684 assert_eq!(state.filter, "a");
685 assert_eq!(state.visible_count(), 2);
686
687 state.clear_filter();
688 assert!(state.filter.is_empty());
689 assert_eq!(state.visible_count(), 2);
690 }
691
692 #[test]
693 fn bus_detail_and_filter_modes_can_coexist_but_are_independently_cleared() {
694 let mut state = BusLogState::new();
695 state.push(entry("alpha event", "protocol.alpha"));
696
697 state.enter_filter_mode();
698 state.enter_detail();
699 assert!(state.filter_input_mode);
700 assert!(state.detail_mode);
701
702 state.exit_filter_mode();
703 assert!(!state.filter_input_mode);
704 assert!(state.detail_mode);
705
706 state.exit_detail();
707 assert!(!state.detail_mode);
708 }
709
710 #[test]
711 fn bus_filter_editing_resets_selection_to_first_filtered_match() {
712 let mut state = BusLogState::new();
713 state.push(entry("alpha event", "protocol.alpha"));
714 state.push(entry("gamma event", "protocol.gamma"));
715
716 state.selected_index = 1;
717 state.push_filter_char('m');
718
719 assert_eq!(state.selected_index, 0);
720 assert_eq!(
721 state.selected_entry().map(|e| e.summary.as_str()),
722 Some("alpha event")
723 );
724 }
725}