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