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