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 fn render_code_block(&self, lines: &[String], language: &str) -> Vec<Line<'static>> {
96 let mut result = Vec::new();
97 let block_width = self.max_width.saturating_sub(4);
98
99 let header = if language.is_empty() {
101 "┌─ Code ─".to_string() + &"─".repeat(block_width.saturating_sub(9))
102 } else {
103 let lang_header = format!("┌─ {} Code ─", language);
104 let header_len = lang_header.len();
105 lang_header + &"─".repeat(block_width.saturating_sub(header_len))
106 };
107
108 result.push(Line::from(Span::styled(
109 header,
110 Style::default()
111 .fg(Color::DarkGray)
112 .add_modifier(Modifier::BOLD),
113 )));
114
115 let highlighted_lines = self.highlight_code_block_syntect(lines, language);
117
118 for line in highlighted_lines {
119 let formatted_line = if line.trim().is_empty() {
120 "│".to_string()
121 } else {
122 format!("│ {}", line)
123 };
124
125 result.push(Line::from(Span::styled(
126 formatted_line,
127 Style::default().fg(Color::DarkGray),
128 )));
129 }
130
131 result.push(Line::from(Span::styled(
132 "└".to_string() + &"─".repeat(block_width.saturating_sub(1)),
133 Style::default().fg(Color::DarkGray),
134 )));
135
136 result
137 }
138
139 fn highlight_code_block_syntect(&self, lines: &[String], language: &str) -> Vec<String> {
141 let syntax_set = &*SYNTAX_SET;
142 let theme_set = &*THEME_SET;
143
144 let theme = &theme_set.themes["base16-ocean.dark"];
146
147 let syntax = if language.is_empty() {
149 syntax_set.find_syntax_plain_text()
150 } else {
151 syntax_set
152 .find_syntax_by_token(language)
153 .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
154 };
155
156 let mut highlighter = HighlightLines::new(syntax, theme);
157
158 let mut highlighted_lines = Vec::new();
159 let code = lines.join("\n");
160
161 for line in LinesWithEndings::from(&code) {
162 let ranges = match highlighter.highlight_line(line, syntax_set) {
163 Ok(r) => r,
164 Err(_) => {
165 highlighted_lines.push(line.trim_end().to_string());
167 continue;
168 }
169 };
170 let mut line_result = String::new();
171
172 for (style, text) in ranges {
173 let fg_color = style.foreground;
174 let _color = Color::Rgb(fg_color.r, fg_color.g, fg_color.b);
175
176 let _ratatui_color = self.map_syntect_color_to_ratatui(&fg_color);
180
181 line_result.push_str(text);
184 }
185
186 highlighted_lines.push(line_result.trim_end().to_string());
187 }
188
189 highlighted_lines
190 }
191
192 fn map_syntect_color_to_ratatui(&self, color: &syntect::highlighting::Color) -> Color {
194 Color::Rgb(color.r, color.g, color.b)
196 }
197
198 fn format_inline_text(&self, line: &str, role: &str) -> Vec<Span<'static>> {
200 let mut spans = Vec::new();
201 let mut current = String::new();
202 let mut in_bold = false;
203 let mut in_italic = false;
204 let mut in_code = false;
205
206 let role_color = match role {
207 "user" => Color::White,
208 "assistant" => Color::Cyan,
209 "system" => Color::Yellow,
210 "tool" => Color::Green,
211 _ => Color::White,
212 };
213
214 let mut chars = line.chars().peekable();
215
216 while let Some(c) = chars.next() {
217 match c {
218 '*' => {
219 if chars.peek() == Some(&'*') {
220 if !current.is_empty() {
222 spans.push(Span::styled(
223 current.clone(),
224 Style::default().fg(role_color).add_modifier(if in_bold {
225 Modifier::BOLD
226 } else {
227 Modifier::empty()
228 }),
229 ));
230 current.clear();
231 }
232 chars.next(); in_bold = !in_bold;
234 } else {
235 if !current.is_empty() {
237 spans.push(Span::styled(
238 current.clone(),
239 Style::default().fg(role_color).add_modifier(if in_italic {
240 Modifier::ITALIC
241 } else {
242 Modifier::empty()
243 }),
244 ));
245 current.clear();
246 }
247 in_italic = !in_italic;
248 }
249 }
250 '`' => {
251 if !current.is_empty() {
252 spans.push(Span::styled(
253 current.clone(),
254 Style::default().fg(role_color),
255 ));
256 current.clear();
257 }
258 in_code = !in_code;
259 }
260 _ => {
261 current.push(c);
262 }
263 }
264 }
265
266 if !current.is_empty() {
267 spans.push(Span::styled(current, Style::default().fg(role_color)));
268 }
269
270 if spans.is_empty() {
271 spans.push(Span::styled(
272 line.to_string(),
273 Style::default().fg(role_color),
274 ));
275 }
276
277 spans
278 }
279
280 fn wrap_line(&self, spans: Vec<Span<'static>>, _width: usize) -> Vec<Line<'static>> {
282 if spans.is_empty() {
283 return vec![Line::from("")];
284 }
285
286 vec![Line::from(spans)]
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_code_block_detection() {
297 let formatter = MessageFormatter::new(80);
298 let content = "```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```";
299 let lines = formatter.format_content(content, "assistant");
300 assert!(!lines.is_empty());
301 }
302
303 #[test]
304 fn test_syntax_highlighting() {
305 let formatter = MessageFormatter::new(80);
306 let lines = vec![
307 "fn main() {".to_string(),
308 " println!(\"Hello!\");".to_string(),
309 "}".to_string(),
310 ];
311 let highlighted = formatter.highlight_code_block_syntect(&lines, "rust");
312 assert_eq!(highlighted.len(), 3);
313 }
314}