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
12pub const TOOL_PANEL_VISIBLE_LINES: usize = 6;
14const TOOL_PANEL_ITEM_MAX_LINES: usize = 18;
16const 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}