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 let mut in_math_block = false;
31 let mut math_delim: &str = "";
32 let mut math_block_lines: Vec<String> = Vec::new();
33
34 for line in content.lines() {
35 let trimmed = line.trim();
36
37 if !in_code_block {
39 if in_math_block {
40 let close = if math_delim == "\\[" { "\\]" } else { "$$" };
41 if math_delim == "\\[" && trimmed.ends_with(close) {
42 let before = trimmed.trim_end_matches(close);
43 if !before.trim().is_empty() {
44 math_block_lines.push(before.to_string());
45 }
46 lines.extend(self.render_math_block(&math_block_lines));
47 math_block_lines.clear();
48 in_math_block = false;
49 continue;
50 }
51 if math_delim == "$$" && trimmed == "$$" {
52 lines.extend(self.render_math_block(&math_block_lines));
53 math_block_lines.clear();
54 in_math_block = false;
55 continue;
56 }
57 math_block_lines.push(line.to_string());
58 continue;
59 }
60
61 if trimmed.starts_with("\\[") {
62 let after = &trimmed[2..];
63 if let Some(idx) = after.rfind("\\]") {
65 let inner = after[..idx].trim();
66 if !inner.is_empty() {
67 math_block_lines.push(inner.to_string());
68 }
69 lines.extend(self.render_math_block(&math_block_lines));
70 math_block_lines.clear();
71 continue;
72 }
73 in_math_block = true;
74 math_delim = "\\[";
75 if !after.trim().is_empty() {
76 math_block_lines.push(after.to_string());
77 }
78 continue;
79 }
80
81 if trimmed == "$$" {
82 in_math_block = true;
83 math_delim = "$$";
84 continue;
85 }
86 }
87
88 if line.trim().starts_with("```") {
90 if in_code_block {
91 if !code_block_lines.is_empty() {
93 lines.extend(
94 self.render_code_block(&code_block_lines, &code_block_language),
95 );
96 code_block_lines.clear();
97 code_block_language.clear();
98 }
99 in_code_block = false;
100 code_block_start = false;
101 } else {
102 in_code_block = true;
104 code_block_start = true;
105 let lang = line.trim().trim_start_matches('`').trim();
106 code_block_language = lang.to_string();
107 }
108 continue;
109 }
110
111 if in_code_block {
112 if code_block_start {
113 code_block_start = false;
115 if !line.trim().is_empty() && code_block_language.is_empty() {
116 code_block_language = line.trim().to_string();
117 } else {
118 code_block_lines.push(line.to_string());
119 }
120 } else {
121 code_block_lines.push(line.to_string());
122 }
123 continue;
124 }
125
126 if line.trim().is_empty() {
128 lines.push(Line::from(""));
129 continue;
130 }
131
132 let formatted_line = self.format_inline_text(line, role);
134 lines.extend(self.wrap_line(formatted_line, self.max_width.saturating_sub(4)));
135 }
136
137 if !code_block_lines.is_empty() {
139 lines.extend(self.render_code_block(&code_block_lines, &code_block_language));
140 }
141
142 if !math_block_lines.is_empty() {
144 lines.extend(self.render_math_block(&math_block_lines));
145 }
146
147 if lines.is_empty() {
148 lines.push(Line::from(""));
149 }
150
151 lines
152 }
153
154 pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
156 let filename = url
158 .split('/')
159 .next_back()
160 .unwrap_or("image")
161 .split('?')
162 .next()
163 .unwrap_or("image");
164
165 Line::from(vec![
166 Span::styled(" đŒïž ", Style::default().fg(Color::Cyan)),
167 Span::styled(
168 format!("[Image: {}]", filename),
169 Style::default()
170 .fg(Color::Cyan)
171 .add_modifier(Modifier::ITALIC),
172 ),
173 ])
174 }
175
176 fn render_code_block(&self, lines: &[String], language: &str) -> Vec<Line<'static>> {
178 let mut result = Vec::new();
179 let block_width = self.max_width.saturating_sub(4);
180
181 let header = if language.is_empty() {
183 "ââ Code â".to_string() + &"â".repeat(block_width.saturating_sub(9))
184 } else {
185 let lang_header = format!("ââ {} Code â", language);
186 let header_len = lang_header.len();
187 lang_header + &"â".repeat(block_width.saturating_sub(header_len))
188 };
189
190 result.push(Line::from(Span::styled(
191 header,
192 Style::default()
193 .fg(Color::DarkGray)
194 .add_modifier(Modifier::BOLD),
195 )));
196
197 let highlighted_lines = self.highlight_code_block_syntect(lines, language);
199
200 for line in highlighted_lines {
201 let formatted_line = if line.trim().is_empty() {
202 "â".to_string()
203 } else {
204 format!("â {}", line)
205 };
206
207 result.push(Line::from(Span::styled(
208 formatted_line,
209 Style::default().fg(Color::DarkGray),
210 )));
211 }
212
213 result.push(Line::from(Span::styled(
214 "â".to_string() + &"â".repeat(block_width.saturating_sub(1)),
215 Style::default().fg(Color::DarkGray),
216 )));
217
218 result
219 }
220
221 fn highlight_code_block_syntect(&self, lines: &[String], _language: &str) -> Vec<String> {
222 lines.iter().map(|l| l.trim_end().to_string()).collect()
223 }
224
225 fn render_math_block(&self, lines: &[String]) -> Vec<Line<'static>> {
232 let mut result = Vec::new();
233 let block_width = self.max_width.saturating_sub(4);
234
235 let header = "ââ Math â".to_string() + &"â".repeat(block_width.saturating_sub(9));
236 result.push(Line::from(Span::styled(
237 header,
238 Style::default()
239 .fg(Color::Magenta)
240 .add_modifier(Modifier::BOLD),
241 )));
242
243 for line in lines {
244 let pretty = prettify_math(line);
245 let formatted = if pretty.trim().is_empty() {
246 "â".to_string()
247 } else {
248 format!("â {}", pretty.trim_end())
249 };
250 result.push(Line::from(Span::styled(
251 formatted,
252 Style::default().fg(Color::Magenta),
253 )));
254 }
255
256 result.push(Line::from(Span::styled(
257 "â".to_string() + &"â".repeat(block_width.saturating_sub(1)),
258 Style::default().fg(Color::Magenta),
259 )));
260
261 result
262 }
263
264 fn format_inline_text(&self, line: &str, role: &str) -> Vec<Span<'static>> {
266 let mut spans = Vec::new();
267 let mut current = String::new();
268 let mut in_bold = false;
269 let mut in_italic = false;
270 let mut in_code = false;
271
272 let role_color = match role {
273 "user" => Color::White,
274 "assistant" => Color::Cyan,
275 "system" => Color::Yellow,
276 "tool" => Color::Green,
277 _ => Color::White,
278 };
279
280 let mut chars = line.chars().peekable();
281
282 while let Some(c) = chars.next() {
283 match c {
284 '*' => {
285 if chars.peek() == Some(&'*') {
286 if !current.is_empty() {
288 spans.push(Span::styled(
289 current.clone(),
290 Style::default().fg(role_color).add_modifier(if in_bold {
291 Modifier::BOLD
292 } else {
293 Modifier::empty()
294 }),
295 ));
296 current.clear();
297 }
298 chars.next(); in_bold = !in_bold;
300 } else {
301 if !current.is_empty() {
303 spans.push(Span::styled(
304 current.clone(),
305 Style::default().fg(role_color).add_modifier(if in_italic {
306 Modifier::ITALIC
307 } else {
308 Modifier::empty()
309 }),
310 ));
311 current.clear();
312 }
313 in_italic = !in_italic;
314 }
315 }
316 '`' => {
317 if !current.is_empty() {
318 spans.push(Span::styled(
319 current.clone(),
320 Style::default().fg(role_color),
321 ));
322 current.clear();
323 }
324 in_code = !in_code;
325 }
326 '\\' if chars.peek() == Some(&'(') => {
327 chars.next(); if !current.is_empty() {
329 spans.push(Span::styled(
330 current.clone(),
331 Style::default().fg(role_color),
332 ));
333 current.clear();
334 }
335 let mut math = String::new();
336 let mut closed = false;
337 while let Some(mc) = chars.next() {
338 if mc == '\\' && chars.peek() == Some(&')') {
339 chars.next(); closed = true;
341 break;
342 }
343 math.push(mc);
344 }
345 if closed {
346 spans.push(Span::styled(
347 prettify_math(&math),
348 Style::default()
349 .fg(Color::Magenta)
350 .add_modifier(Modifier::ITALIC),
351 ));
352 } else {
353 current.push_str("\\(");
355 current.push_str(&math);
356 }
357 }
358 _ => {
359 current.push(c);
360 }
361 }
362 }
363
364 if !current.is_empty() {
365 spans.push(Span::styled(current, Style::default().fg(role_color)));
366 }
367
368 if spans.is_empty() {
369 spans.push(Span::styled(
370 line.to_string(),
371 Style::default().fg(role_color),
372 ));
373 }
374
375 spans
376 }
377
378 fn wrap_line(&self, spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
400 if spans.is_empty() {
401 return vec![Line::from("")];
402 }
403 if width == 0 {
404 return vec![Line::from(spans)];
405 }
406
407 let mut out: Vec<Line<'static>> = Vec::new();
408 let mut cur: Vec<Span<'static>> = Vec::new();
409 let mut cur_w: usize = 0;
410
411 for span in spans {
412 let style = span.style;
413 let mut text = span.content.into_owned();
414 while !text.is_empty() {
415 let remaining = width.saturating_sub(cur_w);
416 if remaining == 0 {
417 out.push(Line::from(std::mem::take(&mut cur)));
418 cur_w = 0;
419 continue;
420 }
421 let (taken, rest) = take_fit(&text, remaining, cur_w == 0);
422 if taken.is_empty() {
423 out.push(Line::from(std::mem::take(&mut cur)));
425 cur_w = 0;
426 continue;
427 }
428 cur_w += UnicodeWidthStr::width(taken.as_str());
429 cur.push(Span::styled(taken, style));
430 text = rest;
431 if !text.is_empty() {
432 out.push(Line::from(std::mem::take(&mut cur)));
433 cur_w = 0;
434 }
435 }
436 }
437 if !cur.is_empty() {
438 out.push(Line::from(cur));
439 }
440 if out.is_empty() {
441 out.push(Line::from(""));
442 }
443 out
444 }
445}
446
447fn prettify_math(input: &str) -> String {
454 const REPLACEMENTS: &[(&str, &str)] = &[
456 ("\\Rightarrow", "â"),
458 ("\\Leftarrow", "â"),
459 ("\\rightarrow", "â"),
460 ("\\leftarrow", "â"),
461 ("\\leftrightarrow", "â"),
462 ("\\mapsto", "âŠ"),
463 ("\\mathbb{C}", "â"),
464 ("\\mathbb{R}", "â"),
465 ("\\mathbb{Z}", "â€"),
466 ("\\mathbb{N}", "â"),
467 ("\\mathbb{Q}", "â"),
468 ("\\mathbb C", "â"),
469 ("\\mathbb R", "â"),
470 ("\\mathbb Z", "â€"),
471 ("\\mathbb N", "â"),
472 ("\\mathbb Q", "â"),
473 ("\\otimes", "â"),
474 ("\\oplus", "â"),
475 ("\\times", "Ă"),
476 ("\\cdot", "·"),
477 ("\\cdots", "âŻ"),
478 ("\\ldots", "âŠ"),
479 ("\\dots", "âŠ"),
480 ("\\vdots", "âź"),
481 ("\\ddots", "â±"),
482 ("\\sum", "ÎŁ"),
483 ("\\prod", "â"),
484 ("\\int", "â«"),
485 ("\\infty", "â"),
486 ("\\partial", "â"),
487 ("\\nabla", "â"),
488 ("\\forall", "â"),
489 ("\\exists", "â"),
490 ("\\nexists", "â"),
491 ("\\emptyset", "â
"),
492 ("\\subset", "â"),
493 ("\\subseteq", "â"),
494 ("\\supset", "â"),
495 ("\\supseteq", "â"),
496 ("\\cup", "âȘ"),
497 ("\\cap", "â©"),
498 ("\\wedge", "â§"),
499 ("\\vee", "âš"),
500 ("\\neg", "ÂŹ"),
501 ("\\lnot", "ÂŹ"),
502 ("\\equiv", "âĄ"),
503 ("\\approx", "â"),
504 ("\\sim", "âŒ"),
505 ("\\simeq", "â"),
506 ("\\cong", "â
"),
507 ("\\propto", "â"),
508 ("\\leq", "â€"),
509 ("\\geq", "â„"),
510 ("\\neq", "â "),
511 ("\\ne", "â "),
512 ("\\pm", "±"),
513 ("\\mp", "â"),
514 ("\\sqrt", "â"),
515 ("\\dim", "dim"),
516 ("\\det", "det"),
517 ("\\ker", "ker"),
518 ("\\to", "â"),
519 ("\\in", "â"),
520 ("\\notin", "â"),
521 ("\\ni", "â"),
522 ("\\alpha", "α"),
524 ("\\beta", "ÎČ"),
525 ("\\gamma", "Îł"),
526 ("\\delta", "ÎŽ"),
527 ("\\epsilon", "Δ"),
528 ("\\varepsilon", "Δ"),
529 ("\\zeta", "ζ"),
530 ("\\eta", "η"),
531 ("\\theta", "Ξ"),
532 ("\\vartheta", "Ï"),
533 ("\\iota", "Îč"),
534 ("\\kappa", "Îș"),
535 ("\\lambda", "λ"),
536 ("\\mu", "Ό"),
537 ("\\nu", "Μ"),
538 ("\\xi", "Ο"),
539 ("\\pi", "Ï"),
540 ("\\varpi", "Ï"),
541 ("\\rho", "Ï"),
542 ("\\varrho", "ϱ"),
543 ("\\sigma", "Ï"),
544 ("\\varsigma", "Ï"),
545 ("\\tau", "Ï"),
546 ("\\upsilon", "Ï
"),
547 ("\\phi", "Ï"),
548 ("\\varphi", "Ï"),
549 ("\\chi", "Ï"),
550 ("\\psi", "Ï"),
551 ("\\omega", "Ï"),
552 ("\\Gamma", "Î"),
554 ("\\Delta", "Î"),
555 ("\\Theta", "Î"),
556 ("\\Lambda", "Î"),
557 ("\\Xi", "Î"),
558 ("\\Pi", "Î "),
559 ("\\Sigma", "ÎŁ"),
560 ("\\Upsilon", "΄"),
561 ("\\Phi", "Ί"),
562 ("\\Psi", "Κ"),
563 ("\\Omega", "Ω"),
564 ];
565
566 let mut out = input.to_string();
567 for (from, to) in REPLACEMENTS {
568 if out.contains(from) {
569 out = out.replace(from, to);
570 }
571 }
572 out
573}
574
575fn take_fit(text: &str, width: usize, at_start: bool) -> (String, String) {
594 let trimmed = if at_start { text.trim_start() } else { text };
595 let mut end_byte = 0usize;
596 let mut last_ws_byte: Option<usize> = None;
597 let mut w: usize = 0;
598 for (i, ch) in trimmed.char_indices() {
599 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
600 if w + cw > width {
601 break;
602 }
603 w += cw;
604 end_byte = i + ch.len_utf8();
605 if ch.is_whitespace() {
606 last_ws_byte = Some(end_byte);
607 }
608 }
609 if end_byte == trimmed.len() {
610 return (trimmed.to_string(), String::new());
611 }
612 let split = last_ws_byte.unwrap_or(end_byte).max(1).min(trimmed.len());
613 let taken = trimmed[..split].trim_end().to_string();
614 let rest = trimmed[split..].to_string();
615 (taken, rest)
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn test_code_block_detection() {
624 let formatter = MessageFormatter::new(80);
625 let content = "```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```";
626 let lines = formatter.format_content(content, "assistant");
627 assert!(!lines.is_empty());
628 }
629
630 #[test]
631 fn test_syntax_highlighting() {
632 let formatter = MessageFormatter::new(80);
633 let lines = vec![
634 "fn main() {".to_string(),
635 " println!(\"Hello!\");".to_string(),
636 "}".to_string(),
637 ];
638 let highlighted = formatter.highlight_code_block_syntect(&lines, "rust");
639 assert_eq!(highlighted.len(), 3);
640 }
641 #[test]
642 fn take_fit_breaks_on_whitespace() {
643 let (taken, rest) = take_fit("hello world foo", 8, true);
644 assert_eq!(taken, "hello");
645 assert_eq!(rest, "world foo");
646 }
647
648 #[test]
649 fn take_fit_hard_breaks_long_token() {
650 let (taken, rest) = take_fit("abcdefghij", 4, true);
651 assert_eq!(taken, "abcd");
652 assert_eq!(rest, "efghij");
653 }
654
655 #[test]
656 fn take_fit_trims_leading_ws_at_start() {
657 let (taken, rest) = take_fit(" hello", 8, true);
658 assert_eq!(taken, "hello");
659 assert!(rest.is_empty());
660 }
661
662 #[test]
663 fn take_fit_whole_input_fits() {
664 let (taken, rest) = take_fit("short", 10, true);
665 assert_eq!(taken, "short");
666 assert!(rest.is_empty());
667 }
668
669 #[test]
670 fn wrap_line_empty_returns_single_blank() {
671 let f = MessageFormatter::new(20);
672 let out = f.wrap_line(vec![], 16);
673 assert_eq!(out.len(), 1);
674 }
675
676 #[test]
677 fn wrap_line_splits_at_whitespace() {
678 let f = MessageFormatter::new(20);
679 let spans = vec![Span::raw("hello world foo bar")];
680 let out = f.wrap_line(spans, 10);
681 assert!(out.len() >= 2);
682 for line in &out {
683 assert!(line.width() <= 10, "line too wide: {}", line.width());
684 }
685 }
686
687 #[test]
688 fn wrap_line_preserves_style_across_wraps() {
689 let f = MessageFormatter::new(20);
690 let styled = Style::default().add_modifier(Modifier::BOLD);
691 let spans = vec![Span::styled("alpha beta gamma delta", styled)];
692 let out = f.wrap_line(spans, 10);
693 for line in &out {
694 for span in &line.spans {
695 assert_eq!(span.style, styled);
696 }
697 }
698 }
699
700 #[test]
701 fn wrap_line_width_zero_is_noop() {
702 let f = MessageFormatter::new(20);
703 let spans = vec![Span::raw("anything")];
704 let out = f.wrap_line(spans, 0);
705 assert_eq!(out.len(), 1);
706 }
707
708 #[test]
709 fn math_display_block_is_boxed() {
710 let f = MessageFormatter::new(40);
711 let content = "Therefore:\n\\[\nP_sP_t=\\delta_{st}P_s\n\\]\nDone.";
712 let lines = f.format_content(content, "assistant");
713 let rendered: Vec<String> = lines
714 .iter()
715 .map(|l| {
716 l.spans
717 .iter()
718 .map(|s| s.content.as_ref())
719 .collect::<String>()
720 })
721 .collect();
722 assert!(rendered.iter().any(|l| l.contains("Math")));
724 assert!(rendered.iter().any(|l| l.contains("ÎŽ")));
725 assert!(rendered.iter().any(|l| l.starts_with("â")));
726 }
727
728 #[test]
729 fn math_block_dollar_dollar_delimiters() {
730 let f = MessageFormatter::new(40);
731 let content = "$$\nx = y + 1\n$$";
732 let lines = f.format_content(content, "assistant");
733 let rendered: Vec<String> = lines
734 .iter()
735 .map(|l| {
736 l.spans
737 .iter()
738 .map(|s| s.content.as_ref())
739 .collect::<String>()
740 })
741 .collect();
742 assert!(rendered.iter().any(|l| l.contains("Math")));
743 assert!(rendered.iter().any(|l| l.contains("x = y + 1")));
744 }
745
746 #[test]
747 fn inline_math_styled_separately() {
748 let f = MessageFormatter::new(80);
749 let content = "Let \\(x \\in \\mathbb C\\) be a number.";
750 let lines = f.format_content(content, "assistant");
751 let mut found = false;
753 for line in &lines {
754 for span in &line.spans {
755 if span.content.contains("â")
756 && span.style.fg == Some(Color::Magenta)
757 && span.style.add_modifier.contains(Modifier::ITALIC)
758 {
759 found = true;
760 }
761 }
762 }
763 assert!(found, "expected styled inline-math span with â glyph");
764 }
765
766 #[test]
767 fn prettify_math_substitutes_known_symbols() {
768 assert_eq!(prettify_math("\\sum_{i=1}^n"), "ÎŁ_{i=1}^n");
769 assert_eq!(prettify_math("\\delta_{st}"), "ÎŽ_{st}");
770 assert_eq!(
771 prettify_math("H_n=(\\mathbb C)^{\\otimes n}"),
772 "H_n=(â)^{â n}"
773 );
774 assert_eq!(prettify_math("\\unknownmacro x"), "\\unknownmacro x");
776 }
777
778 #[test]
779 fn unclosed_math_block_still_renders() {
780 let f = MessageFormatter::new(40);
781 let content = "\\[\nx = 1";
782 let lines = f.format_content(content, "assistant");
783 let rendered: String = lines
785 .iter()
786 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
787 .collect();
788 assert!(rendered.contains("x = 1"));
789 }
790
791 #[test]
792 fn same_line_math_block() {
793 let f = MessageFormatter::new(40);
794 let content = "\\[ x = 1 \\]";
795 let lines = f.format_content(content, "assistant");
796 let rendered: Vec<String> = lines
797 .iter()
798 .map(|l| {
799 l.spans
800 .iter()
801 .map(|s| s.content.as_ref())
802 .collect::<String>()
803 })
804 .collect();
805 assert!(rendered.iter().any(|l| l.contains("Math")));
806 assert!(rendered.iter().any(|l| l.contains("x = 1")));
807 }
808}