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 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}