1use std::sync::Arc;
7use syntect::easy::HighlightLines;
8use syntect::highlighting::ThemeSet;
9use syntect::parsing::SyntaxSet;
10use syntect::util::as_24_bit_terminal_escaped;
11use termimad::crossterm::style::{Attribute, Color};
12use termimad::{CompoundStyle, LineStyle, MadSkin};
13
14pub mod brand {
16 pub const PURPLE: &str = "\x1b[38;5;141m";
18 pub const MAGENTA: &str = "\x1b[38;5;207m";
20 pub const LIGHT_PURPLE: &str = "\x1b[38;5;183m";
22 pub const CYAN: &str = "\x1b[38;5;51m";
24 pub const TEXT: &str = "\x1b[38;5;252m";
26 pub const DIM: &str = "\x1b[38;5;245m";
28 pub const SUCCESS: &str = "\x1b[38;5;114m";
30 pub const YELLOW: &str = "\x1b[38;5;221m";
32 pub const PEACH: &str = "\x1b[38;5;216m";
34 pub const LIGHT_PEACH: &str = "\x1b[38;5;223m";
36 pub const CORAL: &str = "\x1b[38;5;209m";
38 pub const RESET: &str = "\x1b[0m";
40 pub const BOLD: &str = "\x1b[1m";
42 pub const ITALIC: &str = "\x1b[3m";
44}
45
46#[derive(Clone)]
48pub struct SyntaxHighlighter {
49 syntax_set: Arc<SyntaxSet>,
50 theme_set: Arc<ThemeSet>,
51}
52
53impl Default for SyntaxHighlighter {
54 fn default() -> Self {
55 Self {
56 syntax_set: Arc::new(SyntaxSet::load_defaults_newlines()),
57 theme_set: Arc::new(ThemeSet::load_defaults()),
58 }
59 }
60}
61
62impl SyntaxHighlighter {
63 pub fn highlight(&self, code: &str, lang: &str) -> String {
65 let syntax = self
66 .syntax_set
67 .find_syntax_by_token(lang)
68 .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
69 let theme = &self.theme_set.themes["base16-ocean.dark"];
70 let mut hl = HighlightLines::new(syntax, theme);
71
72 code.lines()
73 .filter_map(|line| hl.highlight_line(line, &self.syntax_set).ok())
74 .map(|ranges| format!("{}\x1b[0m", as_24_bit_terminal_escaped(&ranges, false)))
75 .collect::<Vec<_>>()
76 .join("\n")
77 }
78}
79
80#[derive(Clone, Debug)]
82struct CodeBlock {
83 code: String,
84 lang: String,
85}
86
87struct CodeBlockParser {
89 markdown: String,
90 blocks: Vec<CodeBlock>,
91}
92
93impl CodeBlockParser {
94 fn parse(content: &str) -> Self {
96 let mut blocks = Vec::new();
97 let mut result = String::new();
98 let mut in_code_block = false;
99 let mut code_lines: Vec<&str> = Vec::new();
100 let mut current_lang = String::new();
101
102 for line in content.lines() {
103 if line.trim_start().starts_with("```") {
104 if in_code_block {
105 result.push_str(&format!("\x00{}\x00\n", blocks.len()));
107 blocks.push(CodeBlock {
108 code: code_lines.join("\n"),
109 lang: current_lang.clone(),
110 });
111 code_lines.clear();
112 current_lang.clear();
113 in_code_block = false;
114 } else {
115 current_lang = line
117 .trim_start()
118 .strip_prefix("```")
119 .unwrap_or("")
120 .to_string();
121 in_code_block = true;
122 }
123 } else if in_code_block {
124 code_lines.push(line);
125 } else {
126 result.push_str(line);
127 result.push('\n');
128 }
129 }
130
131 if in_code_block && !code_lines.is_empty() {
133 result.push_str(&format!("\x00{}\x00\n", blocks.len()));
134 blocks.push(CodeBlock {
135 code: code_lines.join("\n"),
136 lang: current_lang,
137 });
138 }
139
140 Self {
141 markdown: result,
142 blocks,
143 }
144 }
145
146 fn markdown(&self) -> &str {
148 &self.markdown
149 }
150
151 fn restore(&self, highlighter: &SyntaxHighlighter, mut rendered: String) -> String {
153 for (i, block) in self.blocks.iter().enumerate() {
154 let highlighted = highlighter.highlight(&block.code, &block.lang);
156 let code_block = format!("\n{}\n", highlighted);
157 rendered = rendered.replace(&format!("\x00{i}\x00"), &code_block);
158 }
159 rendered
160 }
161}
162
163pub struct MarkdownFormat {
165 skin: MadSkin,
166 highlighter: SyntaxHighlighter,
167}
168
169impl Default for MarkdownFormat {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175impl MarkdownFormat {
176 #[allow(clippy::field_reassign_with_default)]
178 pub fn new() -> Self {
179 let mut skin = MadSkin::default();
180
181 skin.inline_code = CompoundStyle::new(Some(Color::Cyan), None, Default::default());
183
184 skin.code_block = LineStyle::new(
186 CompoundStyle::new(None, None, Default::default()),
187 Default::default(),
188 );
189
190 let mut h1_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
192 h1_style.add_attr(Attribute::Bold);
193 skin.headers[0] = LineStyle::new(h1_style.clone(), Default::default());
194 skin.headers[1] = LineStyle::new(h1_style.clone(), Default::default());
195
196 let h3_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
197 skin.headers[2] = LineStyle::new(h3_style, Default::default());
198
199 let mut bold_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
201 bold_style.add_attr(Attribute::Bold);
202 skin.bold = bold_style;
203
204 skin.italic = CompoundStyle::with_attr(Attribute::Italic);
206
207 let mut strikethrough = CompoundStyle::with_attr(Attribute::CrossedOut);
209 strikethrough.add_attr(Attribute::Dim);
210 skin.strikeout = strikethrough;
211
212 Self {
213 skin,
214 highlighter: SyntaxHighlighter::default(),
215 }
216 }
217
218 pub fn render(&self, content: impl Into<String>) -> String {
220 let content = content.into();
221 let content = content.trim();
222
223 if content.is_empty() {
224 return String::new();
225 }
226
227 let parsed = CodeBlockParser::parse(content);
229
230 let rendered = self.skin.term_text(parsed.markdown()).to_string();
232
233 parsed
235 .restore(&self.highlighter, rendered)
236 .trim()
237 .to_string()
238 }
239}
240
241pub struct ResponseFormatter;
243
244impl ResponseFormatter {
245 pub fn print_response(text: &str) {
247 println!();
249 Self::print_header();
250 println!();
251
252 let formatter = MarkdownFormat::new();
254 let rendered = formatter.render(text);
255
256 for line in rendered.lines() {
258 println!(" {}", line);
259 }
260
261 println!();
263 Self::print_separator();
264 }
265
266 fn print_header() {
268 print!("{}{}╭─ 🤖 Syncable AI ", brand::PURPLE, brand::BOLD);
269 println!(
270 "{}─────────────────────────────────────────────────────╮{}",
271 brand::DIM,
272 brand::RESET
273 );
274 }
275
276 fn print_separator() {
278 println!(
279 "{}╰───────────────────────────────────────────────────────────────────╯{}",
280 brand::DIM,
281 brand::RESET
282 );
283 }
284}
285
286pub struct SimpleResponse;
288
289impl SimpleResponse {
290 pub fn print(text: &str) {
292 println!();
293 println!(
294 "{}{} Syncable AI:{}",
295 brand::PURPLE,
296 brand::BOLD,
297 brand::RESET
298 );
299 let formatter = MarkdownFormat::new();
300 println!("{}", formatter.render(text));
301 println!();
302 }
303}
304
305pub struct ToolProgress {
307 tools_executed: Vec<ToolExecution>,
308}
309
310#[derive(Clone)]
311struct ToolExecution {
312 name: String,
313 description: String,
314 status: ToolStatus,
315}
316
317#[derive(Clone, Copy)]
318enum ToolStatus {
319 Running,
320 Success,
321 Error,
322}
323
324impl ToolProgress {
325 pub fn new() -> Self {
326 Self {
327 tools_executed: Vec::new(),
328 }
329 }
330
331 pub fn tool_start(&mut self, name: &str, description: &str) {
333 self.tools_executed.push(ToolExecution {
334 name: name.to_string(),
335 description: description.to_string(),
336 status: ToolStatus::Running,
337 });
338 self.redraw();
339 }
340
341 pub fn tool_complete(&mut self, success: bool) {
343 if let Some(tool) = self.tools_executed.last_mut() {
344 tool.status = if success {
345 ToolStatus::Success
346 } else {
347 ToolStatus::Error
348 };
349 }
350 self.redraw();
351 }
352
353 fn redraw(&self) {
355 for tool in &self.tools_executed {
356 let (icon, color) = match tool.status {
357 ToolStatus::Running => ("", brand::YELLOW),
358 ToolStatus::Success => ("", brand::SUCCESS),
359 ToolStatus::Error => ("", "\x1b[38;5;196m"),
360 };
361 println!(
362 " {} {}{}{} {}{}{}",
363 icon,
364 color,
365 tool.name,
366 brand::RESET,
367 brand::DIM,
368 tool.description,
369 brand::RESET
370 );
371 }
372 }
373
374 pub fn print_summary(&self) {
376 if !self.tools_executed.is_empty() {
377 let success_count = self
378 .tools_executed
379 .iter()
380 .filter(|t| matches!(t.status, ToolStatus::Success))
381 .count();
382 println!(
383 "\n{} {} tools executed successfully{}",
384 brand::DIM,
385 success_count,
386 brand::RESET
387 );
388 }
389 }
390}
391
392impl Default for ToolProgress {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_markdown_render_empty() {
404 let formatter = MarkdownFormat::new();
405 assert!(formatter.render("").is_empty());
406 }
407
408 #[test]
409 fn test_markdown_render_simple() {
410 let formatter = MarkdownFormat::new();
411 let result = formatter.render("Hello world");
412 assert!(!result.is_empty());
413 }
414
415 #[test]
416 fn test_code_block_extraction() {
417 let parsed = CodeBlockParser::parse("Hello\n```rust\nfn main() {}\n```\nWorld");
418 assert_eq!(parsed.blocks.len(), 1);
419 assert_eq!(parsed.blocks[0].lang, "rust");
420 assert_eq!(parsed.blocks[0].code, "fn main() {}");
421 }
422
423 #[test]
424 fn test_syntax_highlighter() {
425 let hl = SyntaxHighlighter::default();
426 let result = hl.highlight("fn main() {}", "rust");
427 assert!(result.contains("\x1b["));
429 }
430}