syncable_cli/agent/ui/
spinner.rs

1//! Animated spinner for terminal UI
2//!
3//! Provides a Gemini-style spinner that updates in place with elapsed time
4//! and cycles through witty/informative phrases.
5
6use crate::agent::ui::colors::{ansi, format_elapsed};
7use std::io::{self, Write};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11use tokio::sync::mpsc;
12
13/// Spinner animation frames (dots pattern like Gemini CLI)
14const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
16/// Animation interval in milliseconds
17const ANIMATION_INTERVAL_MS: u64 = 80;
18
19/// Phrase change interval in seconds (like Gemini's 15 seconds)
20const PHRASE_CHANGE_INTERVAL_SECS: u64 = 8;
21
22/// Witty loading phrases inspired by Gemini CLI
23const WITTY_PHRASES: &[&str] = &[
24    "Analyzing your codebase...",
25    "Consulting the digital spirits...",
26    "Warming up the AI hamsters...",
27    "Polishing the algorithms...",
28    "Brewing fresh bytes...",
29    "Engaging cognitive processors...",
30    "Compiling brilliance...",
31    "Untangling neural nets...",
32    "Converting coffee into insights...",
33    "Scanning for patterns...",
34    "Traversing the AST...",
35    "Checking dependencies...",
36    "Looking for security issues...",
37    "Mapping the architecture...",
38    "Detecting frameworks...",
39    "Parsing configurations...",
40    "Analyzing code patterns...",
41    "Deep diving into your code...",
42    "Searching for vulnerabilities...",
43    "Exploring the codebase...",
44    "Processing your request...",
45    "Thinking deeply about this...",
46    "Gathering context...",
47    "Reading documentation...",
48    "Inspecting files...",
49];
50
51/// Informative tips shown occasionally
52const TIPS: &[&str] = &[
53    "Tip: Use /model to switch AI models...",
54    "Tip: Use /provider to change providers...",
55    "Tip: Type /help for available commands...",
56    "Tip: Use /clear to reset conversation...",
57    "Tip: Try 'sync-ctl analyze' for full analysis...",
58    "Tip: Security scans support 5 modes (lightning to paranoid)...",
59];
60
61/// Message types for spinner control
62#[derive(Debug)]
63pub enum SpinnerMessage {
64    /// Update the spinner text
65    UpdateText(String),
66    /// Update to show a tool is executing
67    ToolExecuting { name: String, description: String },
68    /// Tool completed successfully
69    ToolComplete { name: String },
70    /// Show thinking/reasoning
71    Thinking(String),
72    /// Stop the spinner
73    Stop,
74}
75
76/// An animated spinner that runs in the background
77pub struct Spinner {
78    sender: mpsc::Sender<SpinnerMessage>,
79    is_running: Arc<AtomicBool>,
80}
81
82impl Spinner {
83    /// Create and start a new spinner with initial text
84    pub fn new(initial_text: &str) -> Self {
85        let (sender, receiver) = mpsc::channel(32);
86        let is_running = Arc::new(AtomicBool::new(true));
87        let is_running_clone = is_running.clone();
88        let initial = initial_text.to_string();
89
90        tokio::spawn(async move {
91            run_spinner(receiver, is_running_clone, initial).await;
92        });
93
94        Self { sender, is_running }
95    }
96
97    /// Update the spinner text
98    pub async fn set_text(&self, text: &str) {
99        let _ = self.sender.send(SpinnerMessage::UpdateText(text.to_string())).await;
100    }
101
102    /// Show tool executing status
103    pub async fn tool_executing(&self, name: &str, description: &str) {
104        let _ = self
105            .sender
106            .send(SpinnerMessage::ToolExecuting {
107                name: name.to_string(),
108                description: description.to_string(),
109            })
110            .await;
111    }
112
113    /// Mark a tool as complete (will be shown in the completed list)
114    pub async fn tool_complete(&self, name: &str) {
115        let _ = self
116            .sender
117            .send(SpinnerMessage::ToolComplete {
118                name: name.to_string(),
119            })
120            .await;
121    }
122
123    /// Show thinking status
124    pub async fn thinking(&self, subject: &str) {
125        let _ = self.sender.send(SpinnerMessage::Thinking(subject.to_string())).await;
126    }
127
128    /// Stop the spinner and clear the line
129    pub async fn stop(&self) {
130        let _ = self.sender.send(SpinnerMessage::Stop).await;
131        // Give the spinner task time to clean up
132        tokio::time::sleep(Duration::from_millis(50)).await;
133    }
134
135    /// Check if spinner is still running
136    pub fn is_running(&self) -> bool {
137        self.is_running.load(Ordering::SeqCst)
138    }
139}
140
141/// Internal spinner loop with phrase cycling
142async fn run_spinner(
143    mut receiver: mpsc::Receiver<SpinnerMessage>,
144    is_running: Arc<AtomicBool>,
145    initial_text: String,
146) {
147    use rand::{Rng, SeedableRng};
148    use rand::rngs::StdRng;
149    
150    let start_time = Instant::now();
151    let mut frame_index = 0;
152    let mut current_text = initial_text;
153    let mut last_phrase_change = Instant::now();
154    let mut phrase_index = 0;
155    let mut current_tool: Option<String> = None;
156    let mut tools_completed: usize = 0;
157    let mut has_printed_tool_line = false;
158    let mut interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS));
159    let mut rng = StdRng::from_entropy();
160
161    // Hide cursor during spinner
162    print!("{}", ansi::HIDE_CURSOR);
163    let _ = io::stdout().flush();
164
165    loop {
166        tokio::select! {
167            _ = interval.tick() => {
168                if !is_running.load(Ordering::SeqCst) {
169                    break;
170                }
171
172                let elapsed = start_time.elapsed().as_secs();
173                let frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.len()];
174                frame_index += 1;
175
176                // Cycle phrases if idle
177                if current_tool.is_none() && last_phrase_change.elapsed().as_secs() >= PHRASE_CHANGE_INTERVAL_SECS {
178                    if rng.gen_bool(0.25) && !TIPS.is_empty() {
179                        let tip_idx = rng.gen_range(0..TIPS.len());
180                        current_text = TIPS[tip_idx].to_string();
181                    } else {
182                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
183                        current_text = WITTY_PHRASES[phrase_index].to_string();
184                    }
185                    last_phrase_change = Instant::now();
186                }
187
188                if has_printed_tool_line {
189                    // Move up to tool line, update it, move back down to spinner line
190                    if let Some(ref tool) = current_tool {
191                        print!("{}{}  {}🔧 {}{}{}",
192                            ansi::CURSOR_UP,
193                            ansi::CLEAR_LINE,
194                            ansi::PURPLE,
195                            tool,
196                            ansi::RESET,
197                            "\n" // Move back down
198                        );
199                    }
200                    // Now update spinner line
201                    print!("\r{}  {}{}{} {} {}{}({}){}",
202                        ansi::CLEAR_LINE,
203                        ansi::CYAN,
204                        frame,
205                        ansi::RESET,
206                        current_text,
207                        ansi::GRAY,
208                        ansi::DIM,
209                        format_elapsed(elapsed),
210                        ansi::RESET
211                    );
212                } else {
213                    // Single line mode (no tool yet)
214                    print!("\r{}  {}{}{} {} {}{}({}){}",
215                        ansi::CLEAR_LINE,
216                        ansi::CYAN,
217                        frame,
218                        ansi::RESET,
219                        current_text,
220                        ansi::GRAY,
221                        ansi::DIM,
222                        format_elapsed(elapsed),
223                        ansi::RESET
224                    );
225                }
226                let _ = io::stdout().flush();
227            }
228            Some(msg) = receiver.recv() => {
229                match msg {
230                    SpinnerMessage::UpdateText(text) => {
231                        current_text = text;
232                    }
233                    SpinnerMessage::ToolExecuting { name, description } => {
234                        if !has_printed_tool_line {
235                            // First tool - print tool line then newline for spinner
236                            print!("\r{}  {}🔧 {}{}{}\n",
237                                ansi::CLEAR_LINE,
238                                ansi::PURPLE,
239                                name,
240                                ansi::RESET,
241                                "" // Spinner will be on next line
242                            );
243                            has_printed_tool_line = true;
244                        }
245                        // Tool line will be updated on next tick
246                        current_tool = Some(name);
247                        current_text = description;
248                        last_phrase_change = Instant::now();
249                    }
250                    SpinnerMessage::ToolComplete { name: _ } => {
251                        tools_completed += 1;
252                        current_tool = None;
253                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
254                        current_text = WITTY_PHRASES[phrase_index].to_string();
255                    }
256                    SpinnerMessage::Thinking(subject) => {
257                        current_text = format!("💭 {}", subject);
258                    }
259                    SpinnerMessage::Stop => {
260                        is_running.store(false, Ordering::SeqCst);
261                        break;
262                    }
263                }
264            }
265        }
266    }
267
268    // Clear both lines and show summary
269    if has_printed_tool_line {
270        // Clear spinner line
271        print!("\r{}", ansi::CLEAR_LINE);
272        // Move up and clear tool line
273        print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
274    } else {
275        print!("\r{}", ansi::CLEAR_LINE);
276    }
277    
278    // Print summary
279    if tools_completed > 0 {
280        println!("  {}✓{} {} tool{} used",
281            ansi::SUCCESS, ansi::RESET,
282            tools_completed,
283            if tools_completed == 1 { "" } else { "s" }
284        );
285    }
286    print!("{}", ansi::SHOW_CURSOR);
287    let _ = io::stdout().flush();
288}
289
290/// A simple inline spinner for synchronous contexts
291pub struct InlineSpinner {
292    frames: Vec<&'static str>,
293    current: usize,
294}
295
296impl InlineSpinner {
297    pub fn new() -> Self {
298        Self {
299            frames: SPINNER_FRAMES.to_vec(),
300            current: 0,
301        }
302    }
303
304    /// Get the next frame
305    pub fn next_frame(&mut self) -> &'static str {
306        let frame = self.frames[self.current % self.frames.len()];
307        self.current += 1;
308        frame
309    }
310
311    /// Print a spinner update inline (clears and rewrites)
312    pub fn print(&mut self, message: &str) {
313        let frame = self.next_frame();
314        print!("{}{} {}", ansi::CLEAR_LINE, frame, message);
315        let _ = io::stdout().flush();
316    }
317}
318
319impl Default for InlineSpinner {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_inline_spinner() {
331        let mut spinner = InlineSpinner::new();
332        assert_eq!(spinner.next_frame(), "⠋");
333        assert_eq!(spinner.next_frame(), "⠙");
334        assert_eq!(spinner.next_frame(), "⠹");
335    }
336}