Skip to main content

codetether_agent/tui/ui/
tool_panel.rs

1use ratatui::{
2    style::{Color, Style},
3    text::{Line, Span},
4};
5
6use super::status_bar::format_timestamp;
7use crate::tui::app::text::truncate_preview;
8use crate::tui::chat::message::{ChatMessage, MessageType};
9use crate::tui::color_palette::ColorPalette;
10use crate::tui::message_formatter::MessageFormatter;
11
12/// Max visible lines inside the compact tool panel.
13pub const TOOL_PANEL_VISIBLE_LINES: usize = 6;
14/// Max preview lines captured for a single tool activity item.
15const TOOL_PANEL_ITEM_MAX_LINES: usize = 18;
16/// Max bytes processed for a single tool activity preview.
17const TOOL_PANEL_ITEM_MAX_BYTES: usize = 6_000;
18
19pub struct RenderEntry<'a> {
20    pub tool_activity: Vec<&'a ChatMessage>,
21    pub message: Option<&'a ChatMessage>,
22}
23
24pub struct ToolPanelRender {
25    pub lines: Vec<Line<'static>>,
26    pub max_scroll: usize,
27}
28
29pub fn build_render_entries(messages: &[ChatMessage]) -> Vec<RenderEntry<'_>> {
30    let mut entries = Vec::new();
31    let mut pending_tool_activity = Vec::new();
32
33    for message in messages {
34        if is_tool_activity(&message.message_type) {
35            pending_tool_activity.push(message);
36            continue;
37        }
38
39        entries.push(RenderEntry {
40            tool_activity: std::mem::take(&mut pending_tool_activity),
41            message: Some(message),
42        });
43    }
44
45    if !pending_tool_activity.is_empty() {
46        entries.push(RenderEntry {
47            tool_activity: pending_tool_activity,
48            message: None,
49        });
50    }
51
52    entries
53}
54
55pub fn is_tool_activity(message_type: &MessageType) -> bool {
56    matches!(
57        message_type,
58        MessageType::ToolCall { .. } | MessageType::ToolResult { .. } | MessageType::Thinking(_)
59    )
60}
61
62pub fn separator_pattern(entry: &RenderEntry<'_>) -> &'static str {
63    match entry.message.map(|message| &message.message_type) {
64        Some(MessageType::System | MessageType::Error) | None => "·",
65        _ => "─",
66    }
67}
68
69pub fn render_chat_message(
70    lines: &mut Vec<Line<'static>>,
71    message: &ChatMessage,
72    formatter: &MessageFormatter,
73    palette: &ColorPalette,
74) {
75    match &message.message_type {
76        MessageType::User => render_formatted_message(
77            lines,
78            message,
79            formatter,
80            "user",
81            "▸ ",
82            palette.get_message_color("user"),
83        ),
84        MessageType::Assistant => render_formatted_message(
85            lines,
86            message,
87            formatter,
88            "assistant",
89            "◆ ",
90            palette.get_message_color("assistant"),
91        ),
92        MessageType::System => render_formatted_message(
93            lines,
94            message,
95            formatter,
96            "system",
97            "⚙ ",
98            palette.get_message_color("system"),
99        ),
100        MessageType::Error => render_formatted_message(
101            lines,
102            message,
103            formatter,
104            "error",
105            "✖ ",
106            palette.get_message_color("error"),
107        ),
108        MessageType::Image { .. } => {
109            let timestamp = format_timestamp(message.timestamp);
110            lines.push(Line::from(vec![
111                Span::styled(
112                    format!("[{timestamp}] "),
113                    Style::default()
114                        .fg(Color::DarkGray)
115                        .add_modifier(ratatui::style::Modifier::DIM),
116                ),
117                Span::styled("🖼️  image", Style::default().fg(Color::Cyan).italic()),
118            ]));
119            lines.push(Line::from(Span::styled(
120                format!("  {}", truncate_preview(&message.content, 120)),
121                Style::default().fg(Color::Cyan).dim(),
122            )));
123        }
124        MessageType::File { path, size } => {
125            let timestamp = format_timestamp(message.timestamp);
126            let size_label = size.map(|s| format!(" ({s} bytes)")).unwrap_or_default();
127            lines.push(Line::from(vec![
128                Span::styled(
129                    format!("[{timestamp}] "),
130                    Style::default()
131                        .fg(Color::DarkGray)
132                        .add_modifier(ratatui::style::Modifier::DIM),
133                ),
134                Span::styled(
135                    format!("📎 file: {path}{size_label}"),
136                    Style::default().fg(Color::Yellow),
137                ),
138            ]));
139        }
140        MessageType::ToolCall { .. }
141        | MessageType::ToolResult { .. }
142        | MessageType::Thinking(_) => {}
143    }
144}
145
146fn render_formatted_message(
147    lines: &mut Vec<Line<'static>>,
148    message: &ChatMessage,
149    formatter: &MessageFormatter,
150    label: &str,
151    icon: &str,
152    color: Color,
153) {
154    let timestamp = format_timestamp(message.timestamp);
155    let mut header_spans = vec![
156        Span::styled(
157            format!("[{timestamp}] "),
158            Style::default()
159                .fg(Color::DarkGray)
160                .add_modifier(ratatui::style::Modifier::DIM),
161        ),
162        Span::styled(icon.to_string(), Style::default().fg(color).bold()),
163        Span::styled(label.to_string(), Style::default().fg(color).bold()),
164    ];
165    if let Some(u) = message.usage.as_ref() {
166        header_spans.push(Span::styled(
167            format!(
168                "  · {} in / {} out · {}",
169                format_tokens(u.prompt_tokens),
170                format_tokens(u.completion_tokens),
171                format_latency(u.duration_ms),
172            ),
173            Style::default()
174                .fg(Color::DarkGray)
175                .add_modifier(ratatui::style::Modifier::DIM),
176        ));
177    }
178    lines.push(Line::from(header_spans));
179    let formatted = crate::tui::ui::chat_view::format_cache::format_message_cached(
180        message,
181        label,
182        formatter,
183        formatter.max_width(),
184    );
185    for line in formatted {
186        let mut spans = vec![Span::styled("  ", Style::default().fg(color))];
187        spans.extend(line.spans.into_iter());
188        lines.push(Line::from(spans));
189    }
190}
191
192fn format_tokens(n: usize) -> String {
193    if n >= 1_000_000 {
194        format!("{:.1}M", n as f64 / 1_000_000.0)
195    } else if n >= 1_000 {
196        format!("{:.1}k", n as f64 / 1_000.0)
197    } else {
198        n.to_string()
199    }
200}
201
202fn format_latency(ms: u64) -> String {
203    if ms >= 1_000 {
204        format!("{:.1}s", ms as f64 / 1_000.0)
205    } else {
206        format!("{ms}ms")
207    }
208}
209
210pub fn build_tool_activity_panel(
211    messages: &[&ChatMessage],
212    scroll_offset: usize,
213    width: usize,
214) -> ToolPanelRender {
215    let header_width = width.max(24);
216    let preview_width = header_width.saturating_sub(10).max(24);
217    let mut body_lines = Vec::new();
218
219    for message in messages {
220        render_tool_activity_item(&mut body_lines, message, preview_width);
221    }
222
223    if body_lines.is_empty() {
224        body_lines.push(Line::from(vec![
225            Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
226            Span::styled(
227                "No tool activity captured",
228                Style::default().fg(Color::DarkGray).dim(),
229            ),
230        ]));
231    }
232
233    let visible_lines = TOOL_PANEL_VISIBLE_LINES.min(body_lines.len()).max(1);
234    let max_scroll = body_lines.len().saturating_sub(visible_lines);
235    let start = scroll_offset.min(max_scroll);
236    let end = (start + visible_lines).min(body_lines.len());
237    let mut lines = Vec::new();
238    let timestamp = messages
239        .first()
240        .map(|message| format_timestamp(message.timestamp))
241        .unwrap_or_else(|| "--:--:--".to_string());
242    let scroll_label = if max_scroll == 0 {
243        format!("{} lines", body_lines.len())
244    } else {
245        format!("{}-{} / {}", start + 1, end, body_lines.len())
246    };
247    let header = format!("[{timestamp}] ▣ tools {} • {scroll_label}", messages.len());
248    lines.push(Line::from(Span::styled(
249        truncate_preview(&header, header_width),
250        Style::default().fg(Color::Cyan).bold(),
251    )));
252    lines.extend(body_lines[start..end].iter().cloned());
253    let footer = if max_scroll == 0 {
254        "└ ready".to_string()
255    } else {
256        format!("└ preview scroll {}", start + 1)
257    };
258    lines.push(Line::from(Span::styled(
259        truncate_preview(&footer, header_width),
260        Style::default().fg(Color::DarkGray).dim(),
261    )));
262
263    ToolPanelRender { lines, max_scroll }
264}
265
266fn render_tool_activity_item(
267    body_lines: &mut Vec<Line<'static>>,
268    message: &ChatMessage,
269    preview_width: usize,
270) {
271    match &message.message_type {
272        MessageType::ToolCall { name, arguments } => {
273            body_lines.push(Line::from(vec![
274                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
275                Span::styled("🔧 ", Style::default().fg(Color::Cyan).bold()),
276                Span::styled(name.clone(), Style::default().fg(Color::Cyan).bold()),
277            ]));
278            push_preview_lines(
279                body_lines,
280                arguments,
281                preview_width,
282                Style::default().fg(Color::DarkGray).dim(),
283                "(no arguments)",
284            );
285        }
286        MessageType::ToolResult {
287            name,
288            output,
289            success,
290            duration_ms,
291        } => {
292            let (icon, color, status) = if *success {
293                ("✅ ", Color::Green, "success")
294            } else {
295                ("❌ ", Color::Red, "error")
296            };
297            let duration_label = duration_ms
298                .map(|ms| format!(" • {ms}ms"))
299                .unwrap_or_default();
300            body_lines.push(Line::from(vec![
301                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
302                Span::styled(icon, Style::default().fg(color).bold()),
303                Span::styled(
304                    format!("{name} • {status}{duration_label}"),
305                    Style::default().fg(color).bold(),
306                ),
307            ]));
308            push_preview_lines(
309                body_lines,
310                output,
311                preview_width,
312                Style::default().fg(color).dim(),
313                "(empty output)",
314            );
315        }
316        MessageType::Thinking(thoughts) => {
317            body_lines.push(Line::from(vec![
318                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
319                Span::styled(
320                    "💭 thinking",
321                    Style::default().fg(Color::DarkGray).dim().italic(),
322                ),
323            ]));
324            push_preview_lines(
325                body_lines,
326                thoughts,
327                preview_width,
328                Style::default().fg(Color::DarkGray).dim().italic(),
329                "(no reasoning text)",
330            );
331        }
332        _ => {}
333    }
334}
335
336fn push_preview_lines(
337    body_lines: &mut Vec<Line<'static>>,
338    text: &str,
339    preview_width: usize,
340    style: Style,
341    empty_label: &str,
342) {
343    let preview = preview_excerpt(text, preview_width);
344    if preview.lines.is_empty() {
345        body_lines.push(Line::from(vec![
346            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
347            Span::styled(empty_label.to_string(), style),
348        ]));
349        return;
350    }
351
352    for line in preview.lines {
353        body_lines.push(Line::from(vec![
354            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
355            Span::styled(line, style),
356        ]));
357    }
358
359    if preview.truncated {
360        body_lines.push(Line::from(vec![
361            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
362            Span::styled("…", Style::default().fg(Color::DarkGray).dim()),
363        ]));
364    }
365}
366
367struct PreviewExcerpt {
368    lines: Vec<String>,
369    truncated: bool,
370}
371
372fn preview_excerpt(text: &str, preview_width: usize) -> PreviewExcerpt {
373    let truncated_bytes = truncate_at_char_boundary(text, TOOL_PANEL_ITEM_MAX_BYTES);
374    let bytes_truncated = truncated_bytes.len() < text.len();
375    let mut lines = Vec::new();
376    let mut remaining = truncated_bytes.lines();
377
378    for line in remaining.by_ref().take(TOOL_PANEL_ITEM_MAX_LINES) {
379        lines.push(truncate_preview(line, preview_width));
380    }
381
382    PreviewExcerpt {
383        lines,
384        truncated: bytes_truncated || remaining.next().is_some(),
385    }
386}
387
388fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> &str {
389    if text.len() <= max_bytes {
390        return text;
391    }
392
393    let mut cutoff = 0;
394    for (idx, ch) in text.char_indices() {
395        let next = idx + ch.len_utf8();
396        if next > max_bytes {
397            break;
398        }
399        cutoff = next;
400    }
401
402    &text[..cutoff]
403}