Skip to main content

codetether_agent/tui/
message_formatter.rs

1use ratatui::{
2    style::{Color, Modifier, Style},
3    text::{Line, Span},
4};
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7/// Enhanced message formatter with syntax highlighting and improved styling
8pub 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    /// Configured maximum wrap width for this formatter.
18    pub fn max_width(&self) -> usize {
19        self.max_width
20    }
21
22    /// Format message content with enhanced features
23    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            // Math block handling takes priority outside code blocks.
38            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                    // Same-line close: \[ x = 1 \]
64                    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            // Detect code blocks
89            if line.trim().starts_with("```") {
90                if in_code_block {
91                    // End of code block - render with syntax highlighting
92                    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                    // Start of code block - extract language
103                    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                    // First line after opening ``` might be language specifier
114                    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            // Handle regular text with enhanced formatting
127            if line.trim().is_empty() {
128                lines.push(Line::from(""));
129                continue;
130            }
131
132            // Handle markdown-like formatting
133            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        // Handle unclosed code blocks
138        if !code_block_lines.is_empty() {
139            lines.extend(self.render_code_block(&code_block_lines, &code_block_language));
140        }
141
142        // Handle unclosed math blocks (render what we have so the user still sees it)
143        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    /// Format an image as a simple placeholder line
155    pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
156        // Extract filename from URL for display
157        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    /// Render a code block with syntax highlighting and styling
177    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        // Header with language indicator
182        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        // Pass through code lines as-is
198        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    /// Render a LaTeX/math display block with a boxed border.
226    ///
227    /// LaTeX cannot be typeset in a terminal, so we preserve the source
228    /// verbatim inside a magenta-bordered block to visually delimit it
229    /// from prose. Common symbols are passed through as Unicode where
230    /// available (`\sum` → Σ, `\delta` → ή, etc.).
231    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    /// Format inline text with basic markdown-like formatting
265    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                        // Bold
287                        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(); // consume second '*'
299                        in_bold = !in_bold;
300                    } else {
301                        // Italic
302                        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(); // consume '('
328                    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(); // consume ')'
340                            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                        // Unclosed: render literally so the source is visible.
354                        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    /// Greedy word-wrap a list of styled spans to `width` display columns.
379    ///
380    /// Preserves per-span [`Style`] as content splits across rows. Breaks on
381    /// whitespace when possible; for overlong tokens (URLs, code without
382    /// spaces) falls back to a hard char boundary. Uses [`UnicodeWidthStr`]
383    /// for display width so CJK and emoji count correctly.
384    ///
385    /// # Arguments
386    ///
387    /// * `spans` — styled input spans for a single logical line.
388    /// * `width` — target column width (display columns, not bytes).
389    ///
390    /// # Returns
391    ///
392    /// One or more [`Line<'static>`] values whose combined content equals
393    /// the input (modulo whitespace collapsed at wrap points) and each of
394    /// which has display width `<= width`. If `spans` is empty, returns a
395    /// single empty line. If `width == 0`, returns the input unsplit.
396    ///
397    /// Invoked via [`MessageFormatter::format_content`]; tested indirectly
398    /// by the unit tests in this module.
399    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                    // nothing fits on this row; flush and retry at col 0.
424                    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
447/// Replace common LaTeX commands with Unicode glyphs for terminal display.
448///
449/// This is best-effort: we substitute well-known math symbols and Greek
450/// letters so that math blocks read closer to typeset notation. Anything
451/// we don't recognize is passed through unchanged, so the original source
452/// remains visible.
453fn prettify_math(input: &str) -> String {
454    // Pairs are applied longest-first to avoid e.g. `\delta` matching `\d`.
455    const REPLACEMENTS: &[(&str, &str)] = &[
456        // Multi-char commands first
457        ("\\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        // Greek lowercase
523        ("\\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        // Greek uppercase
553        ("\\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
575/// Take the longest prefix of `text` whose display width fits in `width`.
576///
577/// Prefers breaking after the last whitespace inside the fitting prefix.
578/// When no whitespace is available (e.g. a long URL), falls back to a hard
579/// char-boundary split at the last character that still fits.
580///
581/// # Arguments
582///
583/// * `text` — UTF-8 input, possibly wider than `width`.
584/// * `width` — maximum display columns the returned `taken` may occupy.
585/// * `at_start` — if `true`, leading whitespace is trimmed before measuring
586///   so wrapped continuation rows don't begin with a space.
587///
588/// # Returns
589///
590/// Tuple `(taken, rest)` where `taken` fits in `width` columns and
591/// `rest` is the remainder to wrap onto following rows. If the whole
592/// input fits, `rest` is empty.
593fn 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        // Header, body, footer present.
723        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        // Inline math should produce a magenta italic span containing prettified math.
752        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        // Unknown commands pass through unchanged.
775        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        // Should not panic and should produce some output containing the content.
784        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}