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 };
154
155 Self {
156 timestamp,
157 topic,
158 sender_id,
159 kind,
160 summary,
161 detail,
162 kind_color,
163 }
164 }
165}
166
167#[derive(Debug)]
169pub struct BusLogState {
170 pub entries: Vec<BusLogEntry>,
172 pub selected_index: usize,
174 pub detail_mode: bool,
176 pub detail_scroll: usize,
178 pub filter: String,
180 pub auto_scroll: bool,
182 pub list_state: ListState,
184 pub max_entries: usize,
186}
187
188impl Default for BusLogState {
189 fn default() -> Self {
190 Self {
191 entries: Vec::new(),
192 selected_index: 0,
193 detail_mode: false,
194 detail_scroll: 0,
195 filter: String::new(),
196 auto_scroll: true,
197 list_state: ListState::default(),
198 max_entries: 10_000,
199 }
200 }
201}
202
203impl BusLogState {
204 pub fn new() -> Self {
205 Self::default()
206 }
207
208 pub fn push(&mut self, entry: BusLogEntry) {
210 self.entries.push(entry);
211 if self.entries.len() > self.max_entries {
212 let excess = self.entries.len() - self.max_entries;
213 self.entries.drain(..excess);
214 self.selected_index = self.selected_index.saturating_sub(excess);
215 }
216 if self.auto_scroll && !self.entries.is_empty() {
217 self.selected_index = self.filtered_entries().len().saturating_sub(1);
218 self.list_state.select(Some(self.selected_index));
219 }
220 }
221
222 pub fn ingest(&mut self, env: &BusEnvelope) {
224 let entry = BusLogEntry::from_envelope(env);
225 self.push(entry);
226 }
227
228 pub fn filtered_entries(&self) -> Vec<&BusLogEntry> {
230 if self.filter.is_empty() {
231 self.entries.iter().collect()
232 } else {
233 let f = self.filter.to_lowercase();
234 self.entries
235 .iter()
236 .filter(|e| {
237 e.topic.to_lowercase().contains(&f)
238 || e.kind.to_lowercase().contains(&f)
239 || e.sender_id.to_lowercase().contains(&f)
240 || e.summary.to_lowercase().contains(&f)
241 })
242 .collect()
243 }
244 }
245
246 pub fn select_prev(&mut self) {
248 let len = self.filtered_entries().len();
249 if len == 0 {
250 return;
251 }
252 self.auto_scroll = false;
253 self.selected_index = self.selected_index.saturating_sub(1);
254 self.list_state.select(Some(self.selected_index));
255 }
256
257 pub fn select_next(&mut self) {
259 let len = self.filtered_entries().len();
260 if len == 0 {
261 return;
262 }
263 self.auto_scroll = false;
264 self.selected_index = (self.selected_index + 1).min(len - 1);
265 self.list_state.select(Some(self.selected_index));
266 if self.selected_index == len - 1 {
268 self.auto_scroll = true;
269 }
270 }
271
272 pub fn enter_detail(&mut self) {
274 if !self.filtered_entries().is_empty() {
275 self.detail_mode = true;
276 self.detail_scroll = 0;
277 }
278 }
279
280 pub fn exit_detail(&mut self) {
282 self.detail_mode = false;
283 self.detail_scroll = 0;
284 }
285
286 pub fn detail_scroll_down(&mut self, amount: usize) {
287 self.detail_scroll = self.detail_scroll.saturating_add(amount);
288 }
289
290 pub fn detail_scroll_up(&mut self, amount: usize) {
291 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
292 }
293
294 pub fn selected_entry(&self) -> Option<&BusLogEntry> {
296 let filtered = self.filtered_entries();
297 filtered.get(self.selected_index).copied()
298 }
299
300 pub fn total_count(&self) -> usize {
302 self.entries.len()
303 }
304
305 pub fn visible_count(&self) -> usize {
307 self.filtered_entries().len()
308 }
309}
310
311pub fn render_bus_log(f: &mut Frame, state: &mut BusLogState, area: Rect) {
315 if state.detail_mode {
316 render_entry_detail(f, state, area);
317 return;
318 }
319
320 let chunks = Layout::default()
321 .direction(Direction::Vertical)
322 .constraints([
323 Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
327 .split(area);
328
329 let filter_display = if state.filter.is_empty() {
331 String::new()
332 } else {
333 format!(" filter: \"{}\"", state.filter)
334 };
335 let scroll_icon = if state.auto_scroll { "⬇" } else { "⏸" };
336
337 let header_line = Line::from(vec![
338 Span::styled(
339 format!(" {} ", scroll_icon),
340 Style::default().fg(Color::Cyan),
341 ),
342 Span::styled(
343 format!("{}/{} messages", state.visible_count(), state.total_count()),
344 Style::default().fg(Color::White),
345 ),
346 Span::styled(filter_display, Style::default().fg(Color::Yellow)),
347 ]);
348
349 let header = Paragraph::new(header_line).block(
350 Block::default()
351 .borders(Borders::ALL)
352 .title(" Protocol Bus Log ")
353 .border_style(Style::default().fg(Color::Cyan)),
354 );
355 f.render_widget(header, chunks[0]);
356
357 let (items, filtered_len): (Vec<ListItem>, usize) = {
360 let filtered = state.filtered_entries();
361 let len = filtered.len();
362 let items = filtered
363 .iter()
364 .map(|entry| {
365 let line = Line::from(vec![
366 Span::styled(
367 format!("{} ", entry.timestamp),
368 Style::default().fg(Color::DarkGray),
369 ),
370 Span::styled(
371 format!("{:<8} ", entry.kind),
372 Style::default()
373 .fg(entry.kind_color)
374 .add_modifier(Modifier::BOLD),
375 ),
376 Span::styled(
377 format!("[{}] ", entry.sender_id),
378 Style::default().fg(Color::DarkGray),
379 ),
380 Span::styled(entry.summary.clone(), Style::default().fg(Color::White)),
381 ]);
382 ListItem::new(line)
383 })
384 .collect();
385 (items, len)
386 };
387
388 if filtered_len > 0 && state.selected_index < filtered_len {
390 state.list_state.select(Some(state.selected_index));
391 }
392
393 let list = List::new(items)
394 .block(
395 Block::default()
396 .borders(Borders::ALL)
397 .title(" Messages (↑↓:select Enter:detail /:filter) "),
398 )
399 .highlight_style(
400 Style::default()
401 .add_modifier(Modifier::BOLD)
402 .bg(Color::DarkGray),
403 )
404 .highlight_symbol("▶ ");
405
406 f.render_stateful_widget(list, chunks[1], &mut state.list_state);
407
408 let hints = Paragraph::new(Line::from(vec![
410 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
411 Span::raw(": Back "),
412 Span::styled("Enter", Style::default().fg(Color::Yellow)),
413 Span::raw(": Detail "),
414 Span::styled("/", Style::default().fg(Color::Yellow)),
415 Span::raw(": Filter "),
416 Span::styled("c", Style::default().fg(Color::Yellow)),
417 Span::raw(": Clear "),
418 Span::styled("g", Style::default().fg(Color::Yellow)),
419 Span::raw(": Bottom"),
420 ]));
421 f.render_widget(hints, chunks[2]);
422}
423
424fn render_entry_detail(f: &mut Frame, state: &BusLogState, area: Rect) {
426 let entry = match state.selected_entry() {
427 Some(e) => e,
428 None => {
429 let p = Paragraph::new("No entry selected").block(
430 Block::default()
431 .borders(Borders::ALL)
432 .title(" Entry Detail "),
433 );
434 f.render_widget(p, area);
435 return;
436 }
437 };
438
439 let chunks = Layout::default()
440 .direction(Direction::Vertical)
441 .constraints([
442 Constraint::Length(5), Constraint::Min(1), Constraint::Length(1), ])
446 .split(area);
447
448 let header_lines = vec![
450 Line::from(vec![
451 Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
452 Span::styled(&entry.timestamp, Style::default().fg(Color::White)),
453 ]),
454 Line::from(vec![
455 Span::styled("Topic: ", Style::default().fg(Color::DarkGray)),
456 Span::styled(&entry.topic, Style::default().fg(Color::Cyan)),
457 ]),
458 Line::from(vec![
459 Span::styled("Sender: ", Style::default().fg(Color::DarkGray)),
460 Span::styled(&entry.sender_id, Style::default().fg(Color::White)),
461 Span::raw(" "),
462 Span::styled("Kind: ", Style::default().fg(Color::DarkGray)),
463 Span::styled(
464 &entry.kind,
465 Style::default()
466 .fg(entry.kind_color)
467 .add_modifier(Modifier::BOLD),
468 ),
469 ]),
470 ];
471
472 let header = Paragraph::new(header_lines).block(
473 Block::default()
474 .borders(Borders::ALL)
475 .title(format!(" Entry: {} ", entry.kind))
476 .border_style(Style::default().fg(entry.kind_color)),
477 );
478 f.render_widget(header, chunks[0]);
479
480 let detail_lines: Vec<Line> = entry
482 .detail
483 .lines()
484 .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White))))
485 .collect();
486
487 let body = Paragraph::new(detail_lines)
488 .block(Block::default().borders(Borders::ALL).title(" Detail "))
489 .wrap(Wrap { trim: false })
490 .scroll((state.detail_scroll as u16, 0));
491 f.render_widget(body, chunks[1]);
492
493 let hints = Paragraph::new(Line::from(vec![
495 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
496 Span::raw(": Back "),
497 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
498 Span::raw(": Scroll "),
499 Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
500 Span::raw(": Prev/Next entry"),
501 ]));
502 f.render_widget(hints, chunks[2]);
503}
504
505fn truncate(s: &str, max: usize) -> String {
507 let flat = s.replace('\n', " ");
508 if flat.len() <= max {
509 flat
510 } else {
511 let mut end = max;
512 while end > 0 && !flat.is_char_boundary(end) {
513 end -= 1;
514 }
515 format!("{}…", &flat[..end])
516 }
517}