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