syncable_cli/agent/ui/
response.rs

1//! Beautiful response formatting for AI outputs
2//!
3//! Renders AI responses with Syncable's brand colors (purple/magenta theme)
4//! and nice markdown-like formatting.
5
6// Note: colored crate is used in other modules, here we use custom ANSI codes
7
8/// Syncable brand colors using ANSI 256-color codes
9pub mod brand {
10    /// Primary purple (like the S in logo)
11    pub const PURPLE: &str = "\x1b[38;5;141m";
12    /// Accent magenta
13    pub const MAGENTA: &str = "\x1b[38;5;207m";
14    /// Light purple for headers
15    pub const LIGHT_PURPLE: &str = "\x1b[38;5;183m";
16    /// Cyan for code/technical
17    pub const CYAN: &str = "\x1b[38;5;51m";
18    /// Soft white for body text
19    pub const TEXT: &str = "\x1b[38;5;252m";
20    /// Dim gray for secondary info
21    pub const DIM: &str = "\x1b[38;5;245m";
22    /// Green for success
23    pub const SUCCESS: &str = "\x1b[38;5;114m";
24    /// Yellow for warnings
25    pub const YELLOW: &str = "\x1b[38;5;221m";
26    /// Reset
27    pub const RESET: &str = "\x1b[0m";
28    /// Bold
29    pub const BOLD: &str = "\x1b[1m";
30    /// Italic
31    pub const ITALIC: &str = "\x1b[3m";
32}
33
34/// Response formatter with beautiful rendering
35pub struct ResponseFormatter;
36
37impl ResponseFormatter {
38    /// Format and print a complete AI response with nice styling
39    pub fn print_response(text: &str) {
40        // Print the response header
41        println!();
42        Self::print_header();
43        println!();
44
45        // Parse and format the markdown content
46        Self::format_markdown(text);
47
48        // Print footer separator
49        println!();
50        Self::print_separator();
51    }
52
53    /// Print the response header with Syncable styling
54    fn print_header() {
55        print!(
56            "{}{}╭─ {} Syncable AI {}{}",
57            brand::PURPLE,
58            brand::BOLD,
59            "🤖",
60            brand::RESET,
61            brand::DIM
62        );
63        println!("─────────────────────────────────────────────────────╮{}", brand::RESET);
64    }
65
66    /// Print a separator line
67    fn print_separator() {
68        println!(
69            "{}╰───────────────────────────────────────────────────────────────────╯{}",
70            brand::DIM,
71            brand::RESET
72        );
73    }
74
75    /// Format and print markdown content with nice styling
76    fn format_markdown(text: &str) {
77        let mut in_code_block = false;
78        let mut code_lang = String::new();
79        let mut list_depth = 0;
80
81        for line in text.lines() {
82            let trimmed = line.trim();
83
84            // Handle code blocks
85            if trimmed.starts_with("```") {
86                if in_code_block {
87                    // End code block
88                    println!(
89                        "{}  └────────────────────────────────────────────────────────────┘{}",
90                        brand::DIM,
91                        brand::RESET
92                    );
93                    in_code_block = false;
94                    code_lang.clear();
95                } else {
96                    // Start code block
97                    code_lang = trimmed.strip_prefix("```").unwrap_or("").to_string();
98                    let lang_display = if code_lang.is_empty() {
99                        "code".to_string()
100                    } else {
101                        code_lang.clone()
102                    };
103                    println!(
104                        "{}  ┌─ {}{}{} ──────────────────────────────────────────────────────┐{}",
105                        brand::DIM,
106                        brand::CYAN,
107                        lang_display,
108                        brand::DIM,
109                        brand::RESET
110                    );
111                    in_code_block = true;
112                }
113                continue;
114            }
115
116            if in_code_block {
117                // Code content with syntax highlighting hint
118                println!("{}  │ {}{}{}  │", brand::DIM, brand::CYAN, line, brand::RESET);
119                continue;
120            }
121
122            // Handle headers
123            if let Some(header) = Self::parse_header(trimmed) {
124                Self::print_formatted_header(header.0, header.1);
125                continue;
126            }
127
128            // Handle bullet points
129            if let Some(bullet) = Self::parse_bullet(trimmed) {
130                Self::print_bullet(bullet.0, bullet.1, &mut list_depth);
131                continue;
132            }
133
134            // Handle bold and inline code in regular text
135            Self::print_formatted_text(line);
136        }
137    }
138
139    /// Parse header level and content
140    fn parse_header(line: &str) -> Option<(usize, &str)> {
141        if line.starts_with("### ") {
142            Some((3, line.strip_prefix("### ").unwrap()))
143        } else if line.starts_with("## ") {
144            Some((2, line.strip_prefix("## ").unwrap()))
145        } else if line.starts_with("# ") {
146            Some((1, line.strip_prefix("# ").unwrap()))
147        } else {
148            None
149        }
150    }
151
152    /// Print a formatted header
153    fn print_formatted_header(level: usize, content: &str) {
154        match level {
155            1 => {
156                println!();
157                println!(
158                    "{}{}  ▓▓ {} {}",
159                    brand::PURPLE,
160                    brand::BOLD,
161                    content.to_uppercase(),
162                    brand::RESET
163                );
164                println!(
165                    "{}  ════════════════════════════════════════════════════════{}",
166                    brand::PURPLE,
167                    brand::RESET
168                );
169            }
170            2 => {
171                println!();
172                println!(
173                    "{}{}  ▸ {} {}",
174                    brand::LIGHT_PURPLE,
175                    brand::BOLD,
176                    content,
177                    brand::RESET
178                );
179                println!(
180                    "{}  ────────────────────────────────────────────────────────{}",
181                    brand::DIM,
182                    brand::RESET
183                );
184            }
185            _ => {
186                println!();
187                println!(
188                    "{}{}  ◦ {} {}",
189                    brand::MAGENTA,
190                    brand::BOLD,
191                    content,
192                    brand::RESET
193                );
194            }
195        }
196    }
197
198    /// Parse bullet point
199    fn parse_bullet(line: &str) -> Option<(usize, &str)> {
200        let trimmed = line.trim_start();
201        let indent = line.len() - trimmed.len();
202        let depth = indent / 2;
203
204        if trimmed.starts_with("- ") {
205            Some((depth, trimmed.strip_prefix("- ").unwrap()))
206        } else if trimmed.starts_with("* ") {
207            Some((depth, trimmed.strip_prefix("* ").unwrap()))
208        } else if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) 
209            && trimmed.chars().nth(1) == Some('.') 
210        {
211            Some((depth, trimmed.split_once(". ").map(|(_, rest)| rest).unwrap_or(trimmed)))
212        } else {
213            None
214        }
215    }
216
217    /// Print a bullet point with proper indentation
218    fn print_bullet(depth: usize, content: &str, _list_depth: &mut usize) {
219        let indent = "  ".repeat(depth + 1);
220        let bullet_char = match depth {
221            0 => "●",
222            1 => "○",
223            _ => "◦",
224        };
225        let bullet_color = match depth {
226            0 => brand::PURPLE,
227            1 => brand::MAGENTA,
228            _ => brand::DIM,
229        };
230
231        // Format the content with inline styles
232        let formatted = Self::format_inline(content);
233        println!("{}{}{} {}{}", indent, bullet_color, bullet_char, brand::TEXT, formatted);
234        print!("{}", brand::RESET);
235    }
236
237    /// Print formatted text with inline styles
238    fn print_formatted_text(line: &str) {
239        if line.trim().is_empty() {
240            println!();
241            return;
242        }
243
244        let formatted = Self::format_inline(line);
245        println!("{}  {}{}", brand::TEXT, formatted, brand::RESET);
246    }
247
248    /// Format inline markdown (bold, italic, code)
249    fn format_inline(text: &str) -> String {
250        let mut result = String::new();
251        let chars: Vec<char> = text.chars().collect();
252        let mut i = 0;
253
254        while i < chars.len() {
255            // Handle **bold**
256            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
257                if let Some(end) = Self::find_closing(&chars, i + 2, "**") {
258                    let bold_text: String = chars[i + 2..end].iter().collect();
259                    result.push_str(brand::BOLD);
260                    result.push_str(brand::LIGHT_PURPLE);
261                    result.push_str(&bold_text);
262                    result.push_str(brand::RESET);
263                    result.push_str(brand::TEXT);
264                    i = end + 2;
265                    continue;
266                }
267            }
268
269            // Handle `code`
270            if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
271                if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
272                    let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
273                    result.push_str(brand::CYAN);
274                    result.push_str("`");
275                    result.push_str(&code_text);
276                    result.push_str("`");
277                    result.push_str(brand::RESET);
278                    result.push_str(brand::TEXT);
279                    i = i + 2 + end;
280                    continue;
281                }
282            }
283
284            result.push(chars[i]);
285            i += 1;
286        }
287
288        result
289    }
290
291    /// Find closing marker
292    fn find_closing(chars: &[char], start: usize, marker: &str) -> Option<usize> {
293        let marker_chars: Vec<char> = marker.chars().collect();
294        let marker_len = marker_chars.len();
295
296        for i in start..=chars.len() - marker_len {
297            let matches = (0..marker_len).all(|j| chars[i + j] == marker_chars[j]);
298            if matches {
299                return Some(i);
300            }
301        }
302        None
303    }
304}
305
306/// Simple response printer for when we just want colored output
307pub struct SimpleResponse;
308
309impl SimpleResponse {
310    /// Print a simple AI response with minimal formatting
311    pub fn print(text: &str) {
312        println!();
313        println!("{}{}🤖 Syncable AI:{}", brand::PURPLE, brand::BOLD, brand::RESET);
314        println!("{}{}{}", brand::TEXT, text, brand::RESET);
315        println!();
316    }
317}
318
319/// Tool execution display during processing
320pub struct ToolProgress {
321    tools_executed: Vec<ToolExecution>,
322}
323
324#[derive(Clone)]
325struct ToolExecution {
326    name: String,
327    description: String,
328    status: ToolStatus,
329}
330
331#[derive(Clone, Copy)]
332enum ToolStatus {
333    Running,
334    Success,
335    Error,
336}
337
338impl ToolProgress {
339    pub fn new() -> Self {
340        Self {
341            tools_executed: Vec::new(),
342        }
343    }
344
345    /// Mark a tool as starting execution
346    pub fn tool_start(&mut self, name: &str, description: &str) {
347        self.tools_executed.push(ToolExecution {
348            name: name.to_string(),
349            description: description.to_string(),
350            status: ToolStatus::Running,
351        });
352        self.redraw();
353    }
354
355    /// Mark the last tool as complete
356    pub fn tool_complete(&mut self, success: bool) {
357        if let Some(tool) = self.tools_executed.last_mut() {
358            tool.status = if success { ToolStatus::Success } else { ToolStatus::Error };
359        }
360        self.redraw();
361    }
362
363    /// Redraw the tool progress display
364    fn redraw(&self) {
365        // Clear previous lines and redraw
366        for tool in &self.tools_executed {
367            let (icon, color) = match tool.status {
368                ToolStatus::Running => ("◐", brand::YELLOW),
369                ToolStatus::Success => ("✓", brand::SUCCESS),
370                ToolStatus::Error => ("✗", "\x1b[38;5;196m"),
371            };
372            println!(
373                "  {} {}{}{} {}{}{}",
374                icon,
375                color,
376                tool.name,
377                brand::RESET,
378                brand::DIM,
379                tool.description,
380                brand::RESET
381            );
382        }
383    }
384
385    /// Print final summary after all tools complete
386    pub fn print_summary(&self) {
387        if !self.tools_executed.is_empty() {
388            let success_count = self.tools_executed
389                .iter()
390                .filter(|t| matches!(t.status, ToolStatus::Success))
391                .count();
392            println!(
393                "\n{}  {} tools executed successfully{}",
394                brand::DIM,
395                success_count,
396                brand::RESET
397            );
398        }
399    }
400}
401
402impl Default for ToolProgress {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_parse_header() {
414        assert_eq!(ResponseFormatter::parse_header("# Hello"), Some((1, "Hello")));
415        assert_eq!(ResponseFormatter::parse_header("## World"), Some((2, "World")));
416        assert_eq!(ResponseFormatter::parse_header("### Test"), Some((3, "Test")));
417        assert_eq!(ResponseFormatter::parse_header("Not a header"), None);
418    }
419
420    #[test]
421    fn test_format_inline_bold() {
422        let result = ResponseFormatter::format_inline("This is **bold** text");
423        assert!(result.contains("bold"));
424    }
425}