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