Skip to main content

codetether_agent/tui/
bus_log.rs

1//! Protocol bus log view for the TUI
2//!
3//! Displays a real-time, scrollable log of every `BusEnvelope` that
4//! travels over the `AgentBus`, giving full visibility into how
5//! sub-agents, the gRPC layer, and workers communicate.
6
7use 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}