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    lines.push(Line::from(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    let formatted = crate::tui::ui::chat_view::format_cache::format_message_cached(
166        message,
167        label,
168        formatter,
169        formatter.max_width(),
170    );
171    for line in formatted {
172        let mut spans = vec![Span::styled("  ", Style::default().fg(color))];
173        spans.extend(line.spans.into_iter());
174        lines.push(Line::from(spans));
175    }
176}
177
178pub fn build_tool_activity_panel(
179    messages: &[&ChatMessage],
180    scroll_offset: usize,
181    width: usize,
182) -> ToolPanelRender {
183    let header_width = width.max(24);
184    let preview_width = header_width.saturating_sub(10).max(24);
185    let mut body_lines = Vec::new();
186
187    for message in messages {
188        render_tool_activity_item(&mut body_lines, message, preview_width);
189    }
190
191    if body_lines.is_empty() {
192        body_lines.push(Line::from(vec![
193            Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
194            Span::styled(
195                "No tool activity captured",
196                Style::default().fg(Color::DarkGray).dim(),
197            ),
198        ]));
199    }
200
201    let visible_lines = TOOL_PANEL_VISIBLE_LINES.min(body_lines.len()).max(1);
202    let max_scroll = body_lines.len().saturating_sub(visible_lines);
203    let start = scroll_offset.min(max_scroll);
204    let end = (start + visible_lines).min(body_lines.len());
205    let mut lines = Vec::new();
206    let timestamp = messages
207        .first()
208        .map(|message| format_timestamp(message.timestamp))
209        .unwrap_or_else(|| "--:--:--".to_string());
210    let scroll_label = if max_scroll == 0 {
211        format!("{} lines", body_lines.len())
212    } else {
213        format!("{}-{} / {}", start + 1, end, body_lines.len())
214    };
215    let header = format!("[{timestamp}] ▣ tools {} • {scroll_label}", messages.len());
216    lines.push(Line::from(Span::styled(
217        truncate_preview(&header, header_width),
218        Style::default().fg(Color::Cyan).bold(),
219    )));
220    lines.extend(body_lines[start..end].iter().cloned());
221    let footer = if max_scroll == 0 {
222        "└ ready".to_string()
223    } else {
224        format!("└ preview scroll {}", start + 1)
225    };
226    lines.push(Line::from(Span::styled(
227        truncate_preview(&footer, header_width),
228        Style::default().fg(Color::DarkGray).dim(),
229    )));
230
231    ToolPanelRender { lines, max_scroll }
232}
233
234fn render_tool_activity_item(
235    body_lines: &mut Vec<Line<'static>>,
236    message: &ChatMessage,
237    preview_width: usize,
238) {
239    match &message.message_type {
240        MessageType::ToolCall { name, arguments } => {
241            body_lines.push(Line::from(vec![
242                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
243                Span::styled("🔧 ", Style::default().fg(Color::Cyan).bold()),
244                Span::styled(name.clone(), Style::default().fg(Color::Cyan).bold()),
245            ]));
246            push_preview_lines(
247                body_lines,
248                arguments,
249                preview_width,
250                Style::default().fg(Color::DarkGray).dim(),
251                "(no arguments)",
252            );
253        }
254        MessageType::ToolResult {
255            name,
256            output,
257            success,
258            duration_ms,
259        } => {
260            let (icon, color, status) = if *success {
261                ("✅ ", Color::Green, "success")
262            } else {
263                ("❌ ", Color::Red, "error")
264            };
265            let duration_label = duration_ms
266                .map(|ms| format!(" • {ms}ms"))
267                .unwrap_or_default();
268            body_lines.push(Line::from(vec![
269                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
270                Span::styled(icon, Style::default().fg(color).bold()),
271                Span::styled(
272                    format!("{name} • {status}{duration_label}"),
273                    Style::default().fg(color).bold(),
274                ),
275            ]));
276            push_preview_lines(
277                body_lines,
278                output,
279                preview_width,
280                Style::default().fg(color).dim(),
281                "(empty output)",
282            );
283        }
284        MessageType::Thinking(thoughts) => {
285            body_lines.push(Line::from(vec![
286                Span::styled("│ ", Style::default().fg(Color::DarkGray).dim()),
287                Span::styled(
288                    "💭 thinking",
289                    Style::default().fg(Color::DarkGray).dim().italic(),
290                ),
291            ]));
292            push_preview_lines(
293                body_lines,
294                thoughts,
295                preview_width,
296                Style::default().fg(Color::DarkGray).dim().italic(),
297                "(no reasoning text)",
298            );
299        }
300        _ => {}
301    }
302}
303
304fn push_preview_lines(
305    body_lines: &mut Vec<Line<'static>>,
306    text: &str,
307    preview_width: usize,
308    style: Style,
309    empty_label: &str,
310) {
311    let preview = preview_excerpt(text, preview_width);
312    if preview.lines.is_empty() {
313        body_lines.push(Line::from(vec![
314            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
315            Span::styled(empty_label.to_string(), style),
316        ]));
317        return;
318    }
319
320    for line in preview.lines {
321        body_lines.push(Line::from(vec![
322            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
323            Span::styled(line, style),
324        ]));
325    }
326
327    if preview.truncated {
328        body_lines.push(Line::from(vec![
329            Span::styled("│   ", Style::default().fg(Color::DarkGray).dim()),
330            Span::styled("…", Style::default().fg(Color::DarkGray).dim()),
331        ]));
332    }
333}
334
335struct PreviewExcerpt {
336    lines: Vec<String>,
337    truncated: bool,
338}
339
340fn preview_excerpt(text: &str, preview_width: usize) -> PreviewExcerpt {
341    let truncated_bytes = truncate_at_char_boundary(text, TOOL_PANEL_ITEM_MAX_BYTES);
342    let bytes_truncated = truncated_bytes.len() < text.len();
343    let mut lines = Vec::new();
344    let mut remaining = truncated_bytes.lines();
345
346    for line in remaining.by_ref().take(TOOL_PANEL_ITEM_MAX_LINES) {
347        lines.push(truncate_preview(line, preview_width));
348    }
349
350    PreviewExcerpt {
351        lines,
352        truncated: bytes_truncated || remaining.next().is_some(),
353    }
354}
355
356fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> &str {
357    if text.len() <= max_bytes {
358        return text;
359    }
360
361    let mut cutoff = 0;
362    for (idx, ch) in text.char_indices() {
363        let next = idx + ch.len_utf8();
364        if next > max_bytes {
365            break;
366        }
367        cutoff = next;
368    }
369
370    &text[..cutoff]
371}