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