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.trim_start().strip_prefix("```").unwrap_or("").to_string();
117                    in_code_block = true;
118                }
119            } else if in_code_block {
120                code_lines.push(line);
121            } else {
122                result.push_str(line);
123                result.push('\n');
124            }
125        }
126
127        // Handle unclosed code block
128        if in_code_block && !code_lines.is_empty() {
129            result.push_str(&format!("\x00{}\x00\n", blocks.len()));
130            blocks.push(CodeBlock {
131                code: code_lines.join("\n"),
132                lang: current_lang,
133            });
134        }
135
136        Self { markdown: result, blocks }
137    }
138
139    /// Get the processed markdown with placeholders
140    fn markdown(&self) -> &str {
141        &self.markdown
142    }
143
144    /// Replace placeholders with highlighted code blocks
145    fn restore(&self, highlighter: &SyntaxHighlighter, mut rendered: String) -> String {
146        for (i, block) in self.blocks.iter().enumerate() {
147            // Just show syntax-highlighted code without language header
148            let highlighted = highlighter.highlight(&block.code, &block.lang);
149            let code_block = format!("\n{}\n", highlighted);
150            rendered = rendered.replace(&format!("\x00{i}\x00"), &code_block);
151        }
152        rendered
153    }
154}
155
156/// Markdown formatter with Syncable branding
157pub struct MarkdownFormat {
158    skin: MadSkin,
159    highlighter: SyntaxHighlighter,
160}
161
162impl Default for MarkdownFormat {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl MarkdownFormat {
169    /// Create a new MarkdownFormat with Syncable brand colors
170    pub fn new() -> Self {
171        let mut skin = MadSkin::default();
172
173        // Inline code - cyan
174        skin.inline_code = CompoundStyle::new(Some(Color::Cyan), None, Default::default());
175
176        // Code blocks - will be replaced with syntax highlighted version
177        skin.code_block = LineStyle::new(
178            CompoundStyle::new(None, None, Default::default()),
179            Default::default(),
180        );
181
182        // Headers - purple theme with bold
183        let mut h1_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
184        h1_style.add_attr(Attribute::Bold);
185        skin.headers[0] = LineStyle::new(h1_style.clone(), Default::default());
186        skin.headers[1] = LineStyle::new(h1_style.clone(), Default::default());
187
188        let h3_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
189        skin.headers[2] = LineStyle::new(h3_style, Default::default());
190
191        // Bold - light purple with bold attribute
192        let mut bold_style = CompoundStyle::new(Some(Color::Magenta), None, Default::default());
193        bold_style.add_attr(Attribute::Bold);
194        skin.bold = bold_style;
195
196        // Italic
197        skin.italic = CompoundStyle::with_attr(Attribute::Italic);
198
199        // Strikethrough
200        let mut strikethrough = CompoundStyle::with_attr(Attribute::CrossedOut);
201        strikethrough.add_attr(Attribute::Dim);
202        skin.strikeout = strikethrough;
203
204        Self {
205            skin,
206            highlighter: SyntaxHighlighter::default(),
207        }
208    }
209
210    /// Render markdown content to a styled string for terminal display
211    pub fn render(&self, content: impl Into<String>) -> String {
212        let content = content.into();
213        let content = content.trim();
214
215        if content.is_empty() {
216            return String::new();
217        }
218
219        // Extract code blocks for separate highlighting
220        let parsed = CodeBlockParser::parse(content);
221
222        // Render with termimad
223        let rendered = self.skin.term_text(parsed.markdown()).to_string();
224
225        // Restore highlighted code blocks
226        parsed.restore(&self.highlighter, rendered).trim().to_string()
227    }
228}
229
230/// Response formatter with beautiful rendering
231pub struct ResponseFormatter;
232
233impl ResponseFormatter {
234    /// Format and print a complete AI response with nice styling
235    pub fn print_response(text: &str) {
236        // Print the response header
237        println!();
238        Self::print_header();
239        println!();
240
241        // Render markdown with proper formatting (tables, code blocks, etc.)
242        let formatter = MarkdownFormat::new();
243        let rendered = formatter.render(text);
244
245        // Add indentation for all lines to fit within box
246        for line in rendered.lines() {
247            println!("  {}", line);
248        }
249
250        // Print footer separator
251        println!();
252        Self::print_separator();
253    }
254
255    /// Print the response header with Syncable styling
256    fn print_header() {
257        print!(
258            "{}{}╭─ 🤖 Syncable AI ",
259            brand::PURPLE,
260            brand::BOLD
261        );
262        println!(
263            "{}─────────────────────────────────────────────────────╮{}",
264            brand::DIM,
265            brand::RESET
266        );
267    }
268
269    /// Print a separator line
270    fn print_separator() {
271        println!(
272            "{}╰───────────────────────────────────────────────────────────────────╯{}",
273            brand::DIM,
274            brand::RESET
275        );
276    }
277}
278
279/// Simple response printer for when we just want colored output
280pub struct SimpleResponse;
281
282impl SimpleResponse {
283    /// Print a simple AI response with minimal formatting
284    pub fn print(text: &str) {
285        println!();
286        println!("{}{} Syncable AI:{}", brand::PURPLE, brand::BOLD, brand::RESET);
287        let formatter = MarkdownFormat::new();
288        println!("{}", formatter.render(text));
289        println!();
290    }
291}
292
293/// Tool execution display during processing
294pub struct ToolProgress {
295    tools_executed: Vec<ToolExecution>,
296}
297
298#[derive(Clone)]
299struct ToolExecution {
300    name: String,
301    description: String,
302    status: ToolStatus,
303}
304
305#[derive(Clone, Copy)]
306enum ToolStatus {
307    Running,
308    Success,
309    Error,
310}
311
312impl ToolProgress {
313    pub fn new() -> Self {
314        Self {
315            tools_executed: Vec::new(),
316        }
317    }
318
319    /// Mark a tool as starting execution
320    pub fn tool_start(&mut self, name: &str, description: &str) {
321        self.tools_executed.push(ToolExecution {
322            name: name.to_string(),
323            description: description.to_string(),
324            status: ToolStatus::Running,
325        });
326        self.redraw();
327    }
328
329    /// Mark the last tool as complete
330    pub fn tool_complete(&mut self, success: bool) {
331        if let Some(tool) = self.tools_executed.last_mut() {
332            tool.status = if success { ToolStatus::Success } else { ToolStatus::Error };
333        }
334        self.redraw();
335    }
336
337    /// Redraw the tool progress display
338    fn redraw(&self) {
339        for tool in &self.tools_executed {
340            let (icon, color) = match tool.status {
341                ToolStatus::Running => ("", brand::YELLOW),
342                ToolStatus::Success => ("", brand::SUCCESS),
343                ToolStatus::Error => ("", "\x1b[38;5;196m"),
344            };
345            println!(
346                "  {} {}{}{} {}{}{}",
347                icon,
348                color,
349                tool.name,
350                brand::RESET,
351                brand::DIM,
352                tool.description,
353                brand::RESET
354            );
355        }
356    }
357
358    /// Print final summary after all tools complete
359    pub fn print_summary(&self) {
360        if !self.tools_executed.is_empty() {
361            let success_count = self.tools_executed
362                .iter()
363                .filter(|t| matches!(t.status, ToolStatus::Success))
364                .count();
365            println!(
366                "\n{}  {} tools executed successfully{}",
367                brand::DIM,
368                success_count,
369                brand::RESET
370            );
371        }
372    }
373}
374
375impl Default for ToolProgress {
376    fn default() -> Self {
377        Self::new()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_markdown_render_empty() {
387        let formatter = MarkdownFormat::new();
388        assert!(formatter.render("").is_empty());
389    }
390
391    #[test]
392    fn test_markdown_render_simple() {
393        let formatter = MarkdownFormat::new();
394        let result = formatter.render("Hello world");
395        assert!(!result.is_empty());
396    }
397
398    #[test]
399    fn test_code_block_extraction() {
400        let parsed = CodeBlockParser::parse("Hello\n```rust\nfn main() {}\n```\nWorld");
401        assert_eq!(parsed.blocks.len(), 1);
402        assert_eq!(parsed.blocks[0].lang, "rust");
403        assert_eq!(parsed.blocks[0].code, "fn main() {}");
404    }
405
406    #[test]
407    fn test_syntax_highlighter() {
408        let hl = SyntaxHighlighter::default();
409        let result = hl.highlight("fn main() {}", "rust");
410        // Should contain ANSI codes
411        assert!(result.contains("\x1b["));
412    }
413}