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