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        for line in content.lines() {
31            // Detect code blocks
32            if line.trim().starts_with("```") {
33                if in_code_block {
34                    // End of code block - render with syntax highlighting
35                    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                    // Start of code block - extract language
46                    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                    // First line after opening ``` might be language specifier
57                    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            // Handle regular text with enhanced formatting
70            if line.trim().is_empty() {
71                lines.push(Line::from(""));
72                continue;
73            }
74
75            // Handle markdown-like formatting
76            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        // Handle unclosed code blocks
81        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    /// Format an image as a simple placeholder line
93    pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
94        // Extract filename from URL for display
95        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    /// Render a code block with syntax highlighting and styling
115    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        // Header with language indicator
120        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        // Pass through code lines as-is
136        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    /// Format inline text with basic markdown-like formatting
164    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                        // Bold
186                        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(); // consume second '*'
198                        in_bold = !in_bold;
199                    } else {
200                        // Italic
201                        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    /// Greedy word-wrap a list of styled spans to `width` display columns.
246    ///
247    /// Preserves per-span [`Style`] as content splits across rows. Breaks on
248    /// whitespace when possible; for overlong tokens (URLs, code without
249    /// spaces) falls back to a hard char boundary. Uses [`UnicodeWidthStr`]
250    /// for display width so CJK and emoji count correctly.
251    ///
252    /// # Arguments
253    ///
254    /// * `spans` — styled input spans for a single logical line.
255    /// * `width` — target column width (display columns, not bytes).
256    ///
257    /// # Returns
258    ///
259    /// One or more [`Line<'static>`] values whose combined content equals
260    /// the input (modulo whitespace collapsed at wrap points) and each of
261    /// which has display width `<= width`. If `spans` is empty, returns a
262    /// single empty line. If `width == 0`, returns the input unsplit.
263    ///
264    /// Invoked via [`MessageFormatter::format_content`]; tested indirectly
265    /// by the unit tests in this module.
266    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                    // nothing fits on this row; flush and retry at col 0.
291                    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
314/// Take the longest prefix of `text` whose display width fits in `width`.
315///
316/// Prefers breaking after the last whitespace inside the fitting prefix.
317/// When no whitespace is available (e.g. a long URL), falls back to a hard
318/// char-boundary split at the last character that still fits.
319///
320/// # Arguments
321///
322/// * `text` — UTF-8 input, possibly wider than `width`.
323/// * `width` — maximum display columns the returned `taken` may occupy.
324/// * `at_start` — if `true`, leading whitespace is trimmed before measuring
325///   so wrapped continuation rows don't begin with a space.
326///
327/// # Returns
328///
329/// Tuple `(taken, rest)` where `taken` fits in `width` columns and
330/// `rest` is the remainder to wrap onto following rows. If the whole
331/// input fits, `rest` is empty.
332fn 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}