syncable_cli/agent/ui/
response.rs

1//! Beautiful response formatting for AI outputs
2//!
3//! Renders AI responses with proper markdown support using termimad
4//! and syntax highlighting using syntect.
5
6use 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
14/// Syncable brand colors using ANSI 256-color codes
15pub mod brand {
16    /// Primary purple (like the S in logo)
17    pub const PURPLE: &str = "\x1b[38;5;141m";
18    /// Accent magenta
19    pub const MAGENTA: &str = "\x1b[38;5;207m";
20    /// Light purple for headers
21    pub const LIGHT_PURPLE: &str = "\x1b[38;5;183m";
22    /// Cyan for code/technical
23    pub const CYAN: &str = "\x1b[38;5;51m";
24    /// Soft white for body text
25    pub const TEXT: &str = "\x1b[38;5;252m";
26    /// Dim gray for secondary info
27    pub const DIM: &str = "\x1b[38;5;245m";
28    /// Green for success
29    pub const SUCCESS: &str = "\x1b[38;5;114m";
30    /// Yellow for warnings
31    pub const YELLOW: &str = "\x1b[38;5;221m";
32    /// Peach/light orange for thinking (like N in logo)
33    pub const PEACH: &str = "\x1b[38;5;216m";
34    /// Lighter peach for thinking secondary
35    pub const LIGHT_PEACH: &str = "\x1b[38;5;223m";
36    /// Coral/salmon for thinking accents
37    pub const CORAL: &str = "\x1b[38;5;209m";
38    /// Reset
39    pub const RESET: &str = "\x1b[0m";
40    /// Bold
41    pub const BOLD: &str = "\x1b[1m";
42    /// Italic
43    pub const ITALIC: &str = "\x1b[3m";
44}
45
46/// Syntax highlighter with cached resources
47#[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    /// Highlight code with the given language
64    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/// A code block extracted from markdown
81#[derive(Clone, Debug)]
82struct CodeBlock {
83    code: String,
84    lang: String,
85}
86
87/// Parses markdown and extracts code blocks for separate highlighting
88struct CodeBlockParser {
89    markdown: String,
90    blocks: Vec<CodeBlock>,
91}
92
93impl CodeBlockParser {
94    /// Extract code blocks from markdown content
95    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                    // End of code block - store and add placeholder
106                    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                    // Start of code block
116                    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        // Handle unclosed code block
132        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    /// Get the processed markdown with placeholders
147    fn markdown(&self) -> &str {
148        &self.markdown
149    }
150
151    /// Replace placeholders with highlighted code blocks
152    fn restore(&self, highlighter: &SyntaxHighlighter, mut rendered: String) -> String {
153        for (i, block) in self.blocks.iter().enumerate() {
154            // Just show syntax-highlighted code without language header
155            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
163/// Markdown formatter with Syncable branding
164pub 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    /// Create a new MarkdownFormat with Syncable brand colors
177    #[allow(clippy::field_reassign_with_default)]
178    pub fn new() -> Self {
179        let mut skin = MadSkin::default();
180
181        // Inline code - cyan
182        skin.inline_code = CompoundStyle::new(Some(Color::Cyan), None, Default::default());
183
184        // Code blocks - will be replaced with syntax highlighted version
185        skin.code_block = LineStyle::new(
186            CompoundStyle::new(None, None, Default::default()),
187            Default::default(),
188        );
189
190        // Headers - purple theme with bold
191        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        // Bold - light purple with bold attribute
200        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        // Italic
205        skin.italic = CompoundStyle::with_attr(Attribute::Italic);
206
207        // Strikethrough
208        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    /// Render markdown content to a styled string for terminal display
219    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        // Extract code blocks for separate highlighting
228        let parsed = CodeBlockParser::parse(content);
229
230        // Render with termimad
231        let rendered = self.skin.term_text(parsed.markdown()).to_string();
232
233        // Restore highlighted code blocks
234        parsed
235            .restore(&self.highlighter, rendered)
236            .trim()
237            .to_string()
238    }
239}
240
241/// Response formatter with beautiful rendering
242pub struct ResponseFormatter;
243
244impl ResponseFormatter {
245    /// Format and print a complete AI response with nice styling
246    pub fn print_response(text: &str) {
247        // Print the response header
248        println!();
249        Self::print_header();
250        println!();
251
252        // Render markdown with proper formatting (tables, code blocks, etc.)
253        let formatter = MarkdownFormat::new();
254        let rendered = formatter.render(text);
255
256        // Add indentation for all lines to fit within box
257        for line in rendered.lines() {
258            println!("  {}", line);
259        }
260
261        // Print footer separator
262        println!();
263        Self::print_separator();
264    }
265
266    /// Print the response header with Syncable styling
267    fn print_header() {
268        print!("{}{}╭─ 🤖 Syncable AI ", brand::PURPLE, brand::BOLD);
269        println!(
270            "{}─────────────────────────────────────────────────────╮{}",
271            brand::DIM,
272            brand::RESET
273        );
274    }
275
276    /// Print a separator line
277    fn print_separator() {
278        println!(
279            "{}╰───────────────────────────────────────────────────────────────────╯{}",
280            brand::DIM,
281            brand::RESET
282        );
283    }
284}
285
286/// Simple response printer for when we just want colored output
287pub struct SimpleResponse;
288
289impl SimpleResponse {
290    /// Print a simple AI response with minimal formatting
291    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
305/// Tool execution display during processing
306pub 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    /// Mark a tool as starting execution
332    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    /// Mark the last tool as complete
342    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    /// Redraw the tool progress display
354    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    /// Print final summary after all tools complete
375    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        // Should contain ANSI codes
428        assert!(result.contains("\x1b["));
429    }
430}