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