1use crate::bus::{BusEnvelope, BusMessage};
8use ratatui::{
9 Frame,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
14};
15
16#[derive(Debug, Clone)]
20pub struct BusLogEntry {
21 pub timestamp: String,
23 pub topic: String,
25 pub sender_id: String,
27 pub kind: String,
29 pub summary: String,
31 pub detail: String,
33 pub kind_color: Color,
35}
36
37impl BusLogEntry {
38 pub fn from_envelope(env: &BusEnvelope) -> Self {
40 let timestamp = env.timestamp.format("%H:%M:%S%.3f").to_string();
41 let topic = env.topic.clone();
42 let sender_id = env.sender_id.clone();
43
44 let (kind, summary, detail, kind_color) = match &env.message {
45 BusMessage::AgentReady {
46 agent_id,
47 capabilities,
48 } => (
49 "READY".to_string(),
50 format!("{agent_id} online ({} caps)", capabilities.len()),
51 format!(
52 "Agent: {agent_id}\nCapabilities: {}",
53 capabilities.join(", ")
54 ),
55 Color::Green,
56 ),
57 BusMessage::AgentShutdown { agent_id } => (
58 "SHUTDOWN".to_string(),
59 format!("{agent_id} shutting down"),
60 format!("Agent: {agent_id}"),
61 Color::Red,
62 ),
63 BusMessage::AgentMessage { from, to, parts } => {
64 let text_preview: String = parts
65 .iter()
66 .filter_map(|p| match p {
67 crate::a2a::types::Part::Text { text } => Some(text.as_str()),
68 _ => None,
69 })
70 .collect::<Vec<_>>()
71 .join(" ");
72 let preview = truncate(&text_preview, 80);
73 (
74 "MSG".to_string(),
75 format!("{from} → {to}: {preview}"),
76 format!(
77 "From: {from}\nTo: {to}\nParts ({}):\n{text_preview}",
78 parts.len()
79 ),
80 Color::Cyan,
81 )
82 }
83 BusMessage::TaskUpdate {
84 task_id,
85 state,
86 message,
87 } => {
88 let msg = message.as_deref().unwrap_or("");
89 (
90 "TASK".to_string(),
91 format!("{task_id} → {state:?} {}", truncate(msg, 50)),
92 format!("Task: {task_id}\nState: {state:?}\nMessage: {msg}"),
93 Color::Yellow,
94 )
95 }
96 BusMessage::ArtifactUpdate { task_id, artifact } => (
97 "ARTIFACT".to_string(),
98 format!("task={task_id} parts={}", artifact.parts.len()),
99 format!(
100 "Task: {task_id}\nArtifact: {}\nParts: {}",
101 artifact.name.as_deref().unwrap_or("(unnamed)"),
102 artifact.parts.len()
103 ),
104 Color::Magenta,
105 ),
106 BusMessage::SharedResult { key, tags, .. } => (
107 "RESULT".to_string(),
108 format!("key={key} tags=[{}]", tags.join(",")),
109 format!("Key: {key}\nTags: {}", tags.join(", ")),
110 Color::Blue,
111 ),
112 BusMessage::ToolRequest {
113 request_id,
114 agent_id,
115 tool_name,
116 arguments,
117 } => {
118 let args_str = serde_json::to_string(arguments).unwrap_or_default();
119 (
120 "TOOL→".to_string(),
121 format!("{agent_id} call {tool_name}"),
122 format!(
123 "Request: {request_id}\nAgent: {agent_id}\nTool: {tool_name}\nArgs: {}",
124 truncate(&args_str, 200)
125 ),
126 Color::Yellow,
127 )
128 }
129 BusMessage::ToolResponse {
130 request_id,
131 agent_id,
132 tool_name,
133 result,
134 success,
135 } => {
136 let icon = if *success { "✓" } else { "✗" };
137 (
138 "←TOOL".to_string(),
139 format!("{icon} {agent_id} {tool_name}"),
140 format!(
141 "Request: {request_id}\nAgent: {agent_id}\nTool: {tool_name}\nSuccess: {success}\nResult: {}",
142 truncate(result, 200)
143 ),
144 if *success { Color::Green } else { Color::Red },
145 )
146 }
147 BusMessage::Heartbeat { agent_id, status } => (
148 "BEAT".to_string(),
149 format!("{agent_id} [{status}]"),
150 format!("Agent: {agent_id}\nStatus: {status}"),
151 Color::DarkGray,
152 ),
153 BusMessage::RalphLearning {
154 prd_id,
155 story_id,
156 iteration,
157 learnings,
158 ..
159 } => (
160 "LEARN".to_string(),
161 format!("{story_id} iter {iteration} ({} items)", learnings.len()),
162 format!(
163 "PRD: {prd_id}\nStory: {story_id}\nIteration: {iteration}\nLearnings:\n{}",
164 learnings.join("\n")
165 ),
166 Color::Cyan,
167 ),
168 BusMessage::RalphHandoff {
169 prd_id,
170 from_story,
171 to_story,
172 progress_summary,
173 ..
174 } => (
175 "HANDOFF".to_string(),
176 format!("{from_story} → {to_story}"),
177 format!(
178 "PRD: {prd_id}\nFrom: {from_story}\nTo: {to_story}\nSummary: {progress_summary}"
179 ),
180 Color::Blue,
181 ),
182 BusMessage::RalphProgress {
183 prd_id,
184 passed,
185 total,
186 iteration,
187 status,
188 } => (
189 "PRD".to_string(),
190 format!("{passed}/{total} stories (iter {iteration}) [{status}]"),
191 format!(
192 "PRD: {prd_id}\nPassed: {passed}/{total}\nIteration: {iteration}\nStatus: {status}"
193 ),
194 Color::Yellow,
195 ),
196 BusMessage::ToolOutputFull {
197 agent_id,
198 tool_name,
199 output,
200 success,
201 step,
202 } => {
203 let icon = if *success { "✓" } else { "✗" };
204 let preview = truncate(output, 120);
205 (
206 "TOOL•FULL".to_string(),
207 format!("{icon} {agent_id} step {step} {tool_name}: {preview}"),
208 format!(
209 "Agent: {agent_id}\nTool: {tool_name}\nStep: {step}\nSuccess: {success}\n\n--- Full Output ---\n{output}"
210 ),
211 if *success { Color::Green } else { Color::Red },
212 )
213 }
214 BusMessage::AgentThinking {
215 agent_id,
216 thinking,
217 step,
218 } => {
219 let preview = truncate(thinking, 120);
220 (
221 "THINK".to_string(),
222 format!("{agent_id} step {step}: {preview}"),
223 format!("Agent: {agent_id}\nStep: {step}\n\n--- Reasoning ---\n{thinking}"),
224 Color::LightMagenta,
225 )
226 }
227 BusMessage::VoiceSessionStarted {
228 room_name,
229 agent_id,
230 voice_id,
231 } => (
232 "VOICE+".to_string(),
233 format!("{room_name} agent={agent_id} voice={voice_id}"),
234 format!("Room: {room_name}\nAgent: {agent_id}\nVoice: {voice_id}"),
235 Color::LightCyan,
236 ),
237 BusMessage::VoiceTranscript {
238 room_name,
239 text,
240 role,
241 is_final,
242 } => {
243 let fin = if *is_final { " [final]" } else { "" };
244 let preview = truncate(text, 100);
245 (
246 "VOICE•T".to_string(),
247 format!("{room_name} [{role}]{fin}: {preview}"),
248 format!("Room: {room_name}\nRole: {role}\nFinal: {is_final}\n\n{text}"),
249 Color::LightCyan,
250 )
251 }
252 BusMessage::VoiceAgentStateChanged { room_name, state } => (
253 "VOICE•S".to_string(),
254 format!("{room_name} → {state}"),
255 format!("Room: {room_name}\nState: {state}"),
256 Color::LightCyan,
257 ),
258 BusMessage::VoiceSessionEnded { room_name, reason } => (
259 "VOICE-".to_string(),
260 format!("{room_name} ended: {reason}"),
261 format!("Room: {room_name}\nReason: {reason}"),
262 Color::DarkGray,
263 ),
264 };
265
266 Self {
267 timestamp,
268 topic,
269 sender_id,
270 kind,
271 summary,
272 detail,
273 kind_color,
274 }
275 }
276}
277
278#[derive(Debug)]
280pub struct BusLogState {
281 pub entries: Vec<BusLogEntry>,
283 pub selected_index: usize,
285 pub detail_mode: bool,
287 pub detail_scroll: usize,
289 pub filter: String,
291 pub auto_scroll: bool,
293 pub list_state: ListState,
295 pub max_entries: usize,
297}
298
299impl Default for BusLogState {
300 fn default() -> Self {
301 Self {
302 entries: Vec::new(),
303 selected_index: 0,
304 detail_mode: false,
305 detail_scroll: 0,
306 filter: String::new(),
307 auto_scroll: true,
308 list_state: ListState::default(),
309 max_entries: 10_000,
310 }
311 }
312}
313
314impl BusLogState {
315 pub fn new() -> Self {
316 Self::default()
317 }
318
319 pub fn push(&mut self, entry: BusLogEntry) {
321 self.entries.push(entry);
322 if self.entries.len() > self.max_entries {
323 let excess = self.entries.len() - self.max_entries;
324 self.entries.drain(..excess);
325 self.selected_index = self.selected_index.saturating_sub(excess);
326 }
327 if self.auto_scroll && !self.entries.is_empty() {
328 self.selected_index = self.filtered_entries().len().saturating_sub(1);
329 self.list_state.select(Some(self.selected_index));
330 }
331 }
332
333 pub fn ingest(&mut self, env: &BusEnvelope) {
335 let entry = BusLogEntry::from_envelope(env);
336 self.push(entry);
337 }
338
339 pub fn filtered_entries(&self) -> Vec<&BusLogEntry> {
341 if self.filter.is_empty() {
342 self.entries.iter().collect()
343 } else {
344 let f = self.filter.to_lowercase();
345 self.entries
346 .iter()
347 .filter(|e| {
348 e.topic.to_lowercase().contains(&f)
349 || e.kind.to_lowercase().contains(&f)
350 || e.sender_id.to_lowercase().contains(&f)
351 || e.summary.to_lowercase().contains(&f)
352 })
353 .collect()
354 }
355 }
356
357 pub fn select_prev(&mut self) {
359 let len = self.filtered_entries().len();
360 if len == 0 {
361 return;
362 }
363 self.auto_scroll = false;
364 self.selected_index = self.selected_index.saturating_sub(1);
365 self.list_state.select(Some(self.selected_index));
366 }
367
368 pub fn select_next(&mut self) {
370 let len = self.filtered_entries().len();
371 if len == 0 {
372 return;
373 }
374 self.auto_scroll = false;
375 self.selected_index = (self.selected_index + 1).min(len - 1);
376 self.list_state.select(Some(self.selected_index));
377 if self.selected_index == len - 1 {
379 self.auto_scroll = true;
380 }
381 }
382
383 pub fn enter_detail(&mut self) {
385 if !self.filtered_entries().is_empty() {
386 self.detail_mode = true;
387 self.detail_scroll = 0;
388 }
389 }
390
391 pub fn exit_detail(&mut self) {
393 self.detail_mode = false;
394 self.detail_scroll = 0;
395 }
396
397 pub fn detail_scroll_down(&mut self, amount: usize) {
398 self.detail_scroll = self.detail_scroll.saturating_add(amount);
399 }
400
401 pub fn detail_scroll_up(&mut self, amount: usize) {
402 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
403 }
404
405 pub fn selected_entry(&self) -> Option<&BusLogEntry> {
407 let filtered = self.filtered_entries();
408 filtered.get(self.selected_index).copied()
409 }
410
411 pub fn total_count(&self) -> usize {
413 self.entries.len()
414 }
415
416 pub fn visible_count(&self) -> usize {
418 self.filtered_entries().len()
419 }
420}
421
422pub fn render_bus_log(f: &mut Frame, state: &mut BusLogState, area: Rect) {
426 if state.detail_mode {
427 render_entry_detail(f, state, area);
428 return;
429 }
430
431 let chunks = Layout::default()
432 .direction(Direction::Vertical)
433 .constraints([
434 Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
438 .split(area);
439
440 let filter_display = if state.filter.is_empty() {
442 String::new()
443 } else {
444 format!(" filter: \"{}\"", state.filter)
445 };
446 let scroll_icon = if state.auto_scroll { "⬇" } else { "⏸" };
447
448 let header_line = Line::from(vec![
449 Span::styled(
450 format!(" {} ", scroll_icon),
451 Style::default().fg(Color::Cyan),
452 ),
453 Span::styled(
454 format!("{}/{} messages", state.visible_count(), state.total_count()),
455 Style::default().fg(Color::White),
456 ),
457 Span::styled(filter_display, Style::default().fg(Color::Yellow)),
458 ]);
459
460 let header = Paragraph::new(header_line).block(
461 Block::default()
462 .borders(Borders::ALL)
463 .title(" Protocol Bus Log ")
464 .border_style(Style::default().fg(Color::Cyan)),
465 );
466 f.render_widget(header, chunks[0]);
467
468 let (items, filtered_len): (Vec<ListItem>, usize) = {
471 let filtered = state.filtered_entries();
472 let len = filtered.len();
473 let items = filtered
474 .iter()
475 .map(|entry| {
476 let line = Line::from(vec![
477 Span::styled(
478 format!("{} ", entry.timestamp),
479 Style::default().fg(Color::DarkGray),
480 ),
481 Span::styled(
482 format!("{:<8} ", entry.kind),
483 Style::default()
484 .fg(entry.kind_color)
485 .add_modifier(Modifier::BOLD),
486 ),
487 Span::styled(
488 format!("[{}] ", entry.sender_id),
489 Style::default().fg(Color::DarkGray),
490 ),
491 Span::styled(entry.summary.clone(), Style::default().fg(Color::White)),
492 ]);
493 ListItem::new(line)
494 })
495 .collect();
496 (items, len)
497 };
498
499 if filtered_len > 0 && state.selected_index < filtered_len {
501 state.list_state.select(Some(state.selected_index));
502 }
503
504 let list = List::new(items)
505 .block(
506 Block::default()
507 .borders(Borders::ALL)
508 .title(" Messages (↑↓:select Enter:detail /:filter) "),
509 )
510 .highlight_style(
511 Style::default()
512 .add_modifier(Modifier::BOLD)
513 .bg(Color::DarkGray),
514 )
515 .highlight_symbol("▶ ");
516
517 f.render_stateful_widget(list, chunks[1], &mut state.list_state);
518
519 let hints = Paragraph::new(Line::from(vec![
521 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
522 Span::raw(": Back "),
523 Span::styled("Enter", Style::default().fg(Color::Yellow)),
524 Span::raw(": Detail "),
525 Span::styled("/", Style::default().fg(Color::Yellow)),
526 Span::raw(": Filter "),
527 Span::styled("c", Style::default().fg(Color::Yellow)),
528 Span::raw(": Clear "),
529 Span::styled("g", Style::default().fg(Color::Yellow)),
530 Span::raw(": Bottom"),
531 ]));
532 f.render_widget(hints, chunks[2]);
533}
534
535fn render_entry_detail(f: &mut Frame, state: &BusLogState, area: Rect) {
537 let entry = match state.selected_entry() {
538 Some(e) => e,
539 None => {
540 let p = Paragraph::new("No entry selected").block(
541 Block::default()
542 .borders(Borders::ALL)
543 .title(" Entry Detail "),
544 );
545 f.render_widget(p, area);
546 return;
547 }
548 };
549
550 let chunks = Layout::default()
551 .direction(Direction::Vertical)
552 .constraints([
553 Constraint::Length(5), Constraint::Min(1), Constraint::Length(1), ])
557 .split(area);
558
559 let header_lines = vec![
561 Line::from(vec![
562 Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
563 Span::styled(&entry.timestamp, Style::default().fg(Color::White)),
564 ]),
565 Line::from(vec![
566 Span::styled("Topic: ", Style::default().fg(Color::DarkGray)),
567 Span::styled(&entry.topic, Style::default().fg(Color::Cyan)),
568 ]),
569 Line::from(vec![
570 Span::styled("Sender: ", Style::default().fg(Color::DarkGray)),
571 Span::styled(&entry.sender_id, Style::default().fg(Color::White)),
572 Span::raw(" "),
573 Span::styled("Kind: ", Style::default().fg(Color::DarkGray)),
574 Span::styled(
575 &entry.kind,
576 Style::default()
577 .fg(entry.kind_color)
578 .add_modifier(Modifier::BOLD),
579 ),
580 ]),
581 ];
582
583 let header = Paragraph::new(header_lines).block(
584 Block::default()
585 .borders(Borders::ALL)
586 .title(format!(" Entry: {} ", entry.kind))
587 .border_style(Style::default().fg(entry.kind_color)),
588 );
589 f.render_widget(header, chunks[0]);
590
591 let detail_lines: Vec<Line> = entry
593 .detail
594 .lines()
595 .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White))))
596 .collect();
597
598 let body = Paragraph::new(detail_lines)
599 .block(Block::default().borders(Borders::ALL).title(" Detail "))
600 .wrap(Wrap { trim: false })
601 .scroll((state.detail_scroll as u16, 0));
602 f.render_widget(body, chunks[1]);
603
604 let hints = Paragraph::new(Line::from(vec![
606 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
607 Span::raw(": Back "),
608 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
609 Span::raw(": Scroll "),
610 Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
611 Span::raw(": Prev/Next entry"),
612 ]));
613 f.render_widget(hints, chunks[2]);
614}
615
616fn truncate(s: &str, max: usize) -> String {
618 let flat = s.replace('\n', " ");
619 if flat.len() <= max {
620 flat
621 } else {
622 let mut end = max;
623 while end > 0 && !flat.is_char_boundary(end) {
624 end -= 1;
625 }
626 format!("{}…", &flat[..end])
627 }
628}