1use ratatui::{
2 style::{Color, Modifier, Style},
3 text::{Line, Span},
4};
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7pub struct MessageFormatter {
9 max_width: usize,
10}
11
12impl MessageFormatter {
13 pub fn new(max_width: usize) -> Self {
14 Self { max_width }
15 }
16
17 pub fn max_width(&self) -> usize {
19 self.max_width
20 }
21
22 pub fn format_content(&self, content: &str, role: &str) -> Vec<Line<'static>> {
24 let mut lines = Vec::new();
25 let mut in_code_block = false;
26 let mut code_block_start = false;
27 let mut code_block_language = String::new();
28 let mut code_block_lines = Vec::new();
29
30 for line in content.lines() {
31 if line.trim().starts_with("```") {
33 if in_code_block {
34 if !code_block_lines.is_empty() {
36 lines.extend(
37 self.render_code_block(&code_block_lines, &code_block_language),
38 );
39 code_block_lines.clear();
40 code_block_language.clear();
41 }
42 in_code_block = false;
43 code_block_start = false;
44 } else {
45 in_code_block = true;
47 code_block_start = true;
48 let lang = line.trim().trim_start_matches('`').trim();
49 code_block_language = lang.to_string();
50 }
51 continue;
52 }
53
54 if in_code_block {
55 if code_block_start {
56 code_block_start = false;
58 if !line.trim().is_empty() && code_block_language.is_empty() {
59 code_block_language = line.trim().to_string();
60 } else {
61 code_block_lines.push(line.to_string());
62 }
63 } else {
64 code_block_lines.push(line.to_string());
65 }
66 continue;
67 }
68
69 if line.trim().is_empty() {
71 lines.push(Line::from(""));
72 continue;
73 }
74
75 let formatted_line = self.format_inline_text(line, role);
77 lines.extend(self.wrap_line(formatted_line, self.max_width.saturating_sub(4)));
78 }
79
80 if !code_block_lines.is_empty() {
82 lines.extend(self.render_code_block(&code_block_lines, &code_block_language));
83 }
84
85 if lines.is_empty() {
86 lines.push(Line::from(""));
87 }
88
89 lines
90 }
91
92 pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
94 let filename = url
96 .split('/')
97 .next_back()
98 .unwrap_or("image")
99 .split('?')
100 .next()
101 .unwrap_or("image");
102
103 Line::from(vec![
104 Span::styled(" 🖼️ ", Style::default().fg(Color::Cyan)),
105 Span::styled(
106 format!("[Image: {}]", filename),
107 Style::default()
108 .fg(Color::Cyan)
109 .add_modifier(Modifier::ITALIC),
110 ),
111 ])
112 }
113
114 fn render_code_block(&self, lines: &[String], language: &str) -> Vec<Line<'static>> {
116 let mut result = Vec::new();
117 let block_width = self.max_width.saturating_sub(4);
118
119 let header = if language.is_empty() {
121 "┌─ Code ─".to_string() + &"─".repeat(block_width.saturating_sub(9))
122 } else {
123 let lang_header = format!("┌─ {} Code ─", language);
124 let header_len = lang_header.len();
125 lang_header + &"─".repeat(block_width.saturating_sub(header_len))
126 };
127
128 result.push(Line::from(Span::styled(
129 header,
130 Style::default()
131 .fg(Color::DarkGray)
132 .add_modifier(Modifier::BOLD),
133 )));
134
135 let highlighted_lines = self.highlight_code_block_syntect(lines, language);
137
138 for line in highlighted_lines {
139 let formatted_line = if line.trim().is_empty() {
140 "│".to_string()
141 } else {
142 format!("│ {}", line)
143 };
144
145 result.push(Line::from(Span::styled(
146 formatted_line,
147 Style::default().fg(Color::DarkGray),
148 )));
149 }
150
151 result.push(Line::from(Span::styled(
152 "└".to_string() + &"─".repeat(block_width.saturating_sub(1)),
153 Style::default().fg(Color::DarkGray),
154 )));
155
156 result
157 }
158
159 fn highlight_code_block_syntect(&self, lines: &[String], _language: &str) -> Vec<String> {
160 lines.iter().map(|l| l.trim_end().to_string()).collect()
161 }
162
163 fn format_inline_text(&self, line: &str, role: &str) -> Vec<Span<'static>> {
165 let mut spans = Vec::new();
166 let mut current = String::new();
167 let mut in_bold = false;
168 let mut in_italic = false;
169 let mut in_code = false;
170
171 let role_color = match role {
172 "user" => Color::White,
173 "assistant" => Color::Cyan,
174 "system" => Color::Yellow,
175 "tool" => Color::Green,
176 _ => Color::White,
177 };
178
179 let mut chars = line.chars().peekable();
180
181 while let Some(c) = chars.next() {
182 match c {
183 '*' => {
184 if chars.peek() == Some(&'*') {
185 if !current.is_empty() {
187 spans.push(Span::styled(
188 current.clone(),
189 Style::default().fg(role_color).add_modifier(if in_bold {
190 Modifier::BOLD
191 } else {
192 Modifier::empty()
193 }),
194 ));
195 current.clear();
196 }
197 chars.next(); in_bold = !in_bold;
199 } else {
200 if !current.is_empty() {
202 spans.push(Span::styled(
203 current.clone(),
204 Style::default().fg(role_color).add_modifier(if in_italic {
205 Modifier::ITALIC
206 } else {
207 Modifier::empty()
208 }),
209 ));
210 current.clear();
211 }
212 in_italic = !in_italic;
213 }
214 }
215 '`' => {
216 if !current.is_empty() {
217 spans.push(Span::styled(
218 current.clone(),
219 Style::default().fg(role_color),
220 ));
221 current.clear();
222 }
223 in_code = !in_code;
224 }
225 _ => {
226 current.push(c);
227 }
228 }
229 }
230
231 if !current.is_empty() {
232 spans.push(Span::styled(current, Style::default().fg(role_color)));
233 }
234
235 if spans.is_empty() {
236 spans.push(Span::styled(
237 line.to_string(),
238 Style::default().fg(role_color),
239 ));
240 }
241
242 spans
243 }
244
245 fn wrap_line(&self, spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
267 if spans.is_empty() {
268 return vec![Line::from("")];
269 }
270 if width == 0 {
271 return vec![Line::from(spans)];
272 }
273
274 let mut out: Vec<Line<'static>> = Vec::new();
275 let mut cur: Vec<Span<'static>> = Vec::new();
276 let mut cur_w: usize = 0;
277
278 for span in spans {
279 let style = span.style;
280 let mut text = span.content.into_owned();
281 while !text.is_empty() {
282 let remaining = width.saturating_sub(cur_w);
283 if remaining == 0 {
284 out.push(Line::from(std::mem::take(&mut cur)));
285 cur_w = 0;
286 continue;
287 }
288 let (taken, rest) = take_fit(&text, remaining, cur_w == 0);
289 if taken.is_empty() {
290 out.push(Line::from(std::mem::take(&mut cur)));
292 cur_w = 0;
293 continue;
294 }
295 cur_w += UnicodeWidthStr::width(taken.as_str());
296 cur.push(Span::styled(taken, style));
297 text = rest;
298 if !text.is_empty() {
299 out.push(Line::from(std::mem::take(&mut cur)));
300 cur_w = 0;
301 }
302 }
303 }
304 if !cur.is_empty() {
305 out.push(Line::from(cur));
306 }
307 if out.is_empty() {
308 out.push(Line::from(""));
309 }
310 out
311 }
312}
313
314fn take_fit(text: &str, width: usize, at_start: bool) -> (String, String) {
333 let trimmed = if at_start { text.trim_start() } else { text };
334 let mut end_byte = 0usize;
335 let mut last_ws_byte: Option<usize> = None;
336 let mut w: usize = 0;
337 for (i, ch) in trimmed.char_indices() {
338 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
339 if w + cw > width {
340 break;
341 }
342 w += cw;
343 end_byte = i + ch.len_utf8();
344 if ch.is_whitespace() {
345 last_ws_byte = Some(end_byte);
346 }
347 }
348 if end_byte == trimmed.len() {
349 return (trimmed.to_string(), String::new());
350 }
351 let split = last_ws_byte.unwrap_or(end_byte).max(1).min(trimmed.len());
352 let taken = trimmed[..split].trim_end().to_string();
353 let rest = trimmed[split..].to_string();
354 (taken, rest)
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_code_block_detection() {
363 let formatter = MessageFormatter::new(80);
364 let content = "```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```";
365 let lines = formatter.format_content(content, "assistant");
366 assert!(!lines.is_empty());
367 }
368
369 #[test]
370 fn test_syntax_highlighting() {
371 let formatter = MessageFormatter::new(80);
372 let lines = vec![
373 "fn main() {".to_string(),
374 " println!(\"Hello!\");".to_string(),
375 "}".to_string(),
376 ];
377 let highlighted = formatter.highlight_code_block_syntect(&lines, "rust");
378 assert_eq!(highlighted.len(), 3);
379 }
380 #[test]
381 fn take_fit_breaks_on_whitespace() {
382 let (taken, rest) = take_fit("hello world foo", 8, true);
383 assert_eq!(taken, "hello");
384 assert_eq!(rest, "world foo");
385 }
386
387 #[test]
388 fn take_fit_hard_breaks_long_token() {
389 let (taken, rest) = take_fit("abcdefghij", 4, true);
390 assert_eq!(taken, "abcd");
391 assert_eq!(rest, "efghij");
392 }
393
394 #[test]
395 fn take_fit_trims_leading_ws_at_start() {
396 let (taken, rest) = take_fit(" hello", 8, true);
397 assert_eq!(taken, "hello");
398 assert!(rest.is_empty());
399 }
400
401 #[test]
402 fn take_fit_whole_input_fits() {
403 let (taken, rest) = take_fit("short", 10, true);
404 assert_eq!(taken, "short");
405 assert!(rest.is_empty());
406 }
407
408 #[test]
409 fn wrap_line_empty_returns_single_blank() {
410 let f = MessageFormatter::new(20);
411 let out = f.wrap_line(vec![], 16);
412 assert_eq!(out.len(), 1);
413 }
414
415 #[test]
416 fn wrap_line_splits_at_whitespace() {
417 let f = MessageFormatter::new(20);
418 let spans = vec![Span::raw("hello world foo bar")];
419 let out = f.wrap_line(spans, 10);
420 assert!(out.len() >= 2);
421 for line in &out {
422 assert!(line.width() <= 10, "line too wide: {}", line.width());
423 }
424 }
425
426 #[test]
427 fn wrap_line_preserves_style_across_wraps() {
428 let f = MessageFormatter::new(20);
429 let styled = Style::default().add_modifier(Modifier::BOLD);
430 let spans = vec![Span::styled("alpha beta gamma delta", styled)];
431 let out = f.wrap_line(spans, 10);
432 for line in &out {
433 for span in &line.spans {
434 assert_eq!(span.style, styled);
435 }
436 }
437 }
438
439 #[test]
440 fn wrap_line_width_zero_is_noop() {
441 let f = MessageFormatter::new(20);
442 let spans = vec![Span::raw("anything")];
443 let out = f.wrap_line(spans, 0);
444 assert_eq!(out.len(), 1);
445 }
446}