codetether_agent/tui/
message_formatter.rs1use 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
10static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
12static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
13
14pub 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 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 if line.trim().starts_with("```") {
35 if in_code_block {
36 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 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 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 if line.trim().is_empty() {
73 lines.push(Line::from(""));
74 continue;
75 }
76
77 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 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 pub fn format_image(&self, url: &str, _mime_type: Option<&str>) -> Line<'static> {
96 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 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 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 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 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 let theme = &theme_set.themes["base16-ocean.dark"];
168
169 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 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 let _ratatui_color = self.map_syntect_color_to_ratatui(&fg_color);
202
203 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 fn map_syntect_color_to_ratatui(&self, color: &syntect::highlighting::Color) -> Color {
216 Color::Rgb(color.r, color.g, color.b)
218 }
219
220 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 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(); in_bold = !in_bold;
256 } else {
257 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 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 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}