Skip to main content

codetether_agent/tui/
message_formatter.rs

1use ratatui::{
2    style::{Color, Modifier, Style},
3    text::{Line, Span},
4};
5use std::sync::LazyLock;
6use syntect::{
7    easy::HighlightLines, highlighting::ThemeSet, parsing::SyntaxSet, util::LinesWithEndings,
8};
9
10/// Global syntax set and theme set for syntax highlighting
11static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
12static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
13
14/// Enhanced message formatter with syntax highlighting and improved styling
15pub struct MessageFormatter {
16    max_width: usize,
17}
18
19impl MessageFormatter {
20    pub fn new(max_width: usize) -> Self {
21        Self { max_width }
22    }
23
24    /// Format message content with enhanced features
25    pub fn format_content(&self, content: &str, role: &str) -> Vec<Line<'static>> {
26        let mut lines = Vec::new();
27        let mut in_code_block = false;
28        let mut code_block_start = false;
29        let mut code_block_language = String::new();
30        let mut code_block_lines = Vec::new();
31
32        for line in content.lines() {
33            // Detect code blocks
34            if line.trim().starts_with("```") {
35                if in_code_block {
36                    // End of code block - render with syntax highlighting
37                    if !code_block_lines.is_empty() {
38                        lines.extend(
39                            self.render_code_block(&code_block_lines, &code_block_language),
40                        );
41                        code_block_lines.clear();
42                        code_block_language.clear();
43                    }
44                    in_code_block = false;
45                    code_block_start = false;
46                } else {
47                    // Start of code block - extract language
48                    in_code_block = true;
49                    code_block_start = true;
50                    let lang = line.trim().trim_start_matches('`').trim();
51                    code_block_language = lang.to_string();
52                }
53                continue;
54            }
55
56            if in_code_block {
57                if code_block_start {
58                    // First line after opening ``` might be language specifier
59                    code_block_start = false;
60                    if !line.trim().is_empty() && code_block_language.is_empty() {
61                        code_block_language = line.trim().to_string();
62                    } else {
63                        code_block_lines.push(line.to_string());
64                    }
65                } else {
66                    code_block_lines.push(line.to_string());
67                }
68                continue;
69            }
70
71            // Handle regular text with enhanced formatting
72            if line.trim().is_empty() {
73                lines.push(Line::from(""));
74                continue;
75            }
76
77            // Handle markdown-like formatting
78            let formatted_line = self.format_inline_text(line, role);
79            lines.extend(self.wrap_line(formatted_line, self.max_width.saturating_sub(4)));
80        }
81
82        // Handle unclosed code blocks
83        if !code_block_lines.is_empty() {
84            lines.extend(self.render_code_block(&code_block_lines, &code_block_language));
85        }
86
87        if lines.is_empty() {
88            lines.push(Line::from(""));
89        }
90
91        lines
92    }
93
94    /// Format an image as a simple placeholder line
95    pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
96        // Extract filename from URL for display
97        let filename = url
98            .split('/')
99            .last()
100            .unwrap_or("image")
101            .split('?')
102            .next()
103            .unwrap_or("image");
104
105        Line::from(vec![
106            Span::styled("  🖼️  ", Style::default().fg(Color::Cyan)),
107            Span::styled(
108                format!("[Image: {}]", filename),
109                Style::default()
110                    .fg(Color::Cyan)
111                    .add_modifier(Modifier::ITALIC),
112            ),
113        ])
114    }
115
116    /// Render a code block with syntax highlighting and styling
117    fn render_code_block(&self, lines: &[String], language: &str) -> Vec<Line<'static>> {
118        let mut result = Vec::new();
119        let block_width = self.max_width.saturating_sub(4);
120
121        // Header with language indicator
122        let header = if language.is_empty() {
123            "┌─ Code ─".to_string() + &"─".repeat(block_width.saturating_sub(9))
124        } else {
125            let lang_header = format!("┌─ {} Code ─", language);
126            let header_len = lang_header.len();
127            lang_header + &"─".repeat(block_width.saturating_sub(header_len))
128        };
129
130        result.push(Line::from(Span::styled(
131            header,
132            Style::default()
133                .fg(Color::DarkGray)
134                .add_modifier(Modifier::BOLD),
135        )));
136
137        // Use syntect for syntax highlighting
138        let highlighted_lines = self.highlight_code_block_syntect(lines, language);
139
140        for line in highlighted_lines {
141            let formatted_line = if line.trim().is_empty() {
142                "│".to_string()
143            } else {
144                format!("│ {}", line)
145            };
146
147            result.push(Line::from(Span::styled(
148                formatted_line,
149                Style::default().fg(Color::DarkGray),
150            )));
151        }
152
153        result.push(Line::from(Span::styled(
154            "└".to_string() + &"─".repeat(block_width.saturating_sub(1)),
155            Style::default().fg(Color::DarkGray),
156        )));
157
158        result
159    }
160
161    /// Advanced syntax highlighting using syntect
162    fn highlight_code_block_syntect(&self, lines: &[String], language: &str) -> Vec<String> {
163        let syntax_set = &*SYNTAX_SET;
164        let theme_set = &*THEME_SET;
165
166        // Use a dark theme suitable for terminal
167        let theme = &theme_set.themes["base16-ocean.dark"];
168
169        // Find the appropriate syntax
170        let syntax = if language.is_empty() {
171            syntax_set.find_syntax_plain_text()
172        } else {
173            syntax_set
174                .find_syntax_by_token(language)
175                .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
176        };
177
178        let mut highlighter = HighlightLines::new(syntax, theme);
179
180        let mut highlighted_lines = Vec::new();
181        let code = lines.join("\n");
182
183        for line in LinesWithEndings::from(&code) {
184            let ranges = match highlighter.highlight_line(line, syntax_set) {
185                Ok(r) => r,
186                Err(_) => {
187                    // On error, just return plain text
188                    highlighted_lines.push(line.trim_end().to_string());
189                    continue;
190                }
191            };
192            let mut line_result = String::new();
193
194            for (style, text) in ranges {
195                let fg_color = style.foreground;
196                let _color = Color::Rgb(fg_color.r, fg_color.g, fg_color.b);
197
198                // Convert the styled text to a ratatui-compatible string
199                // Since we can't use actual terminal colors in ratatui spans easily,
200                // we'll use the closest ratatui colors
201                let _ratatui_color = self.map_syntect_color_to_ratatui(&fg_color);
202
203                // For now, we'll just return the text without ANSI codes
204                // since ratatui handles styling through its own Span system
205                line_result.push_str(text);
206            }
207
208            highlighted_lines.push(line_result.trim_end().to_string());
209        }
210
211        highlighted_lines
212    }
213
214    /// Map syntect colors to ratatui colors (simplified for now)
215    fn map_syntect_color_to_ratatui(&self, color: &syntect::highlighting::Color) -> Color {
216        // Simple mapping - we'll use the closest ratatui color
217        Color::Rgb(color.r, color.g, color.b)
218    }
219
220    /// Format inline text with basic markdown-like formatting
221    fn format_inline_text(&self, line: &str, role: &str) -> Vec<Span<'static>> {
222        let mut spans = Vec::new();
223        let mut current = String::new();
224        let mut in_bold = false;
225        let mut in_italic = false;
226        let mut in_code = false;
227
228        let role_color = match role {
229            "user" => Color::White,
230            "assistant" => Color::Cyan,
231            "system" => Color::Yellow,
232            "tool" => Color::Green,
233            _ => Color::White,
234        };
235
236        let mut chars = line.chars().peekable();
237
238        while let Some(c) = chars.next() {
239            match c {
240                '*' => {
241                    if chars.peek() == Some(&'*') {
242                        // Bold
243                        if !current.is_empty() {
244                            spans.push(Span::styled(
245                                current.clone(),
246                                Style::default().fg(role_color).add_modifier(if in_bold {
247                                    Modifier::BOLD
248                                } else {
249                                    Modifier::empty()
250                                }),
251                            ));
252                            current.clear();
253                        }
254                        chars.next(); // consume second '*'
255                        in_bold = !in_bold;
256                    } else {
257                        // Italic
258                        if !current.is_empty() {
259                            spans.push(Span::styled(
260                                current.clone(),
261                                Style::default().fg(role_color).add_modifier(if in_italic {
262                                    Modifier::ITALIC
263                                } else {
264                                    Modifier::empty()
265                                }),
266                            ));
267                            current.clear();
268                        }
269                        in_italic = !in_italic;
270                    }
271                }
272                '`' => {
273                    if !current.is_empty() {
274                        spans.push(Span::styled(
275                            current.clone(),
276                            Style::default().fg(role_color),
277                        ));
278                        current.clear();
279                    }
280                    in_code = !in_code;
281                }
282                _ => {
283                    current.push(c);
284                }
285            }
286        }
287
288        if !current.is_empty() {
289            spans.push(Span::styled(current, Style::default().fg(role_color)));
290        }
291
292        if spans.is_empty() {
293            spans.push(Span::styled(
294                line.to_string(),
295                Style::default().fg(role_color),
296            ));
297        }
298
299        spans
300    }
301
302    /// Wrap text to fit within width
303    fn wrap_line(&self, spans: Vec<Span<'static>>, _width: usize) -> Vec<Line<'static>> {
304        if spans.is_empty() {
305            return vec![Line::from("")];
306        }
307
308        // Simple wrapping - for now, just return as single line
309        vec![Line::from(spans)]
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_code_block_detection() {
319        let formatter = MessageFormatter::new(80);
320        let content = "```rust\nfn main() {\n    println!(\"Hello, world!\");\n}\n```";
321        let lines = formatter.format_content(content, "assistant");
322        assert!(!lines.is_empty());
323    }
324
325    #[test]
326    fn test_syntax_highlighting() {
327        let formatter = MessageFormatter::new(80);
328        let lines = vec![
329            "fn main() {".to_string(),
330            "    println!(\"Hello!\");".to_string(),
331            "}".to_string(),
332        ];
333        let highlighted = formatter.highlight_code_block_syntect(&lines, "rust");
334        assert_eq!(highlighted.len(), 3);
335    }
336}