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 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// ─── State ───────────────────────────────────────────────────────────────
17
18/// A flattened, display-ready log entry derived from a `BusEnvelope`.
19#[derive(Debug, Clone)]
20pub struct BusLogEntry {
21    /// When the envelope was created (formatted)
22    pub timestamp: String,
23    /// The routing topic
24    pub topic: String,
25    /// Who sent it
26    pub sender_id: String,
27    /// Human-readable message kind label
28    pub kind: String,
29    /// One-line summary of the payload
30    pub summary: String,
31    /// Full detail text (shown in detail pane)
32    pub detail: String,
33    /// Display color for the kind badge
34    pub kind_color: Color,
35}
36
37impl BusLogEntry {
38    /// Build a display entry from a raw envelope.
39    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/// State for the bus log view.
279#[derive(Debug)]
280pub struct BusLogState {
281    /// All captured log entries (newest last).
282    pub entries: Vec<BusLogEntry>,
283    /// Current selection index in the list.
284    pub selected_index: usize,
285    /// Whether showing detail for the selected entry.
286    pub detail_mode: bool,
287    /// Scroll offset inside the detail pane.
288    pub detail_scroll: usize,
289    /// Optional topic filter (empty = show all).
290    pub filter: String,
291    /// Whether auto-scroll is on (follows newest entry).
292    pub auto_scroll: bool,
293    /// ListState for StatefulWidget rendering.
294    pub list_state: ListState,
295    /// Maximum entries to keep (ring buffer behaviour).
296    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    /// Push a new entry, trimming old ones if over capacity.
320    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    /// Ingest a raw bus envelope.
334    pub fn ingest(&mut self, env: &BusEnvelope) {
335        let entry = BusLogEntry::from_envelope(env);
336        self.push(entry);
337    }
338
339    /// Get entries filtered by the current topic filter.
340    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    /// Move selection up.
358    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    /// Move selection down.
369    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        // Re-enable auto-scroll if at the bottom
378        if self.selected_index == len - 1 {
379            self.auto_scroll = true;
380        }
381    }
382
383    /// Enter detail mode for selected entry.
384    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    /// Exit detail mode.
392    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    /// Get the currently selected entry (from filtered list).
406    pub fn selected_entry(&self) -> Option<&BusLogEntry> {
407        let filtered = self.filtered_entries();
408        filtered.get(self.selected_index).copied()
409    }
410
411    /// Total entry count (unfiltered).
412    pub fn total_count(&self) -> usize {
413        self.entries.len()
414    }
415
416    /// Visible (filtered) entry count.
417    pub fn visible_count(&self) -> usize {
418        self.filtered_entries().len()
419    }
420}
421
422// ─── Rendering ───────────────────────────────────────────────────────────
423
424/// Render the bus protocol log view.
425pub 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), // Header
435            Constraint::Min(1),    // Log list
436            Constraint::Length(1), // Key hints
437        ])
438        .split(area);
439
440    // ── Header ──
441    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    // ── Log list ──
469    // Build items as owned data, then sync ListState separately to avoid borrow conflicts.
470    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    // Sync ListState
500    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    // ── Key hints ──
520    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
535/// Render full-screen detail for a single bus log entry.
536fn 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), // Metadata header
554            Constraint::Min(1),    // Detail body
555            Constraint::Length(1), // Key hints
556        ])
557        .split(area);
558
559    // ── Metadata header ──
560    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    // ── Detail body ──
592    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    // ── Key hints ──
605    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
616/// Truncate a string for display.
617fn 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}