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::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
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
100            .sender
101            .send(SpinnerMessage::UpdateText(text.to_string()))
102            .await;
103    }
104
105    /// Show tool executing status
106    pub async fn tool_executing(&self, name: &str, description: &str) {
107        let _ = self
108            .sender
109            .send(SpinnerMessage::ToolExecuting {
110                name: name.to_string(),
111                description: description.to_string(),
112            })
113            .await;
114    }
115
116    /// Mark a tool as complete (will be shown in the completed list)
117    pub async fn tool_complete(&self, name: &str) {
118        let _ = self
119            .sender
120            .send(SpinnerMessage::ToolComplete {
121                name: name.to_string(),
122            })
123            .await;
124    }
125
126    /// Show thinking status
127    pub async fn thinking(&self, subject: &str) {
128        let _ = self
129            .sender
130            .send(SpinnerMessage::Thinking(subject.to_string()))
131            .await;
132    }
133
134    /// Stop the spinner and clear the line
135    pub async fn stop(&self) {
136        let _ = self.sender.send(SpinnerMessage::Stop).await;
137        // Give the spinner task time to clean up
138        tokio::time::sleep(Duration::from_millis(50)).await;
139    }
140
141    /// Check if spinner is still running
142    pub fn is_running(&self) -> bool {
143        self.is_running.load(Ordering::SeqCst)
144    }
145}
146
147/// Internal spinner loop with phrase cycling
148async fn run_spinner(
149    mut receiver: mpsc::Receiver<SpinnerMessage>,
150    is_running: Arc<AtomicBool>,
151    initial_text: String,
152) {
153    use rand::rngs::StdRng;
154    use rand::{Rng, SeedableRng};
155
156    let start_time = Instant::now();
157    let mut frame_index = 0;
158    let mut current_text = initial_text;
159    let mut last_phrase_change = Instant::now();
160    let mut phrase_index = 0;
161    let mut current_tool: Option<String> = None;
162    let mut tools_completed: usize = 0;
163    let mut has_printed_tool_line = false;
164    let mut interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS));
165    let mut rng = StdRng::from_os_rng();
166
167    // Hide cursor during spinner
168    print!("{}", ansi::HIDE_CURSOR);
169    let _ = io::stdout().flush();
170
171    loop {
172        tokio::select! {
173            _ = interval.tick() => {
174                if !is_running.load(Ordering::SeqCst) {
175                    break;
176                }
177
178                let elapsed = start_time.elapsed().as_secs();
179                let frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.len()];
180                frame_index += 1;
181
182                // Cycle phrases if idle
183                if current_tool.is_none() && last_phrase_change.elapsed().as_secs() >= PHRASE_CHANGE_INTERVAL_SECS {
184                    if rng.random_bool(0.25) {
185                        let tip_idx = rng.random_range(0..TIPS.len());
186                        current_text = TIPS[tip_idx].to_string();
187                    } else {
188                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
189                        current_text = WITTY_PHRASES[phrase_index].to_string();
190                    }
191                    last_phrase_change = Instant::now();
192                }
193
194                if has_printed_tool_line {
195                    // Move up to tool line, update it, move back down to spinner line
196                    if let Some(ref tool) = current_tool {
197                        println!("{}{}  {}🔧 {}{}", // Move back down
198                            ansi::CURSOR_UP,
199                            ansi::CLEAR_LINE,
200                            ansi::PURPLE,
201                            tool,
202                            ansi::RESET,
203                        );
204                    }
205                    // Now update spinner line
206                    print!("\r{}  {}{}{} {} {}{}({}){}",
207                        ansi::CLEAR_LINE,
208                        ansi::CYAN,
209                        frame,
210                        ansi::RESET,
211                        current_text,
212                        ansi::GRAY,
213                        ansi::DIM,
214                        format_elapsed(elapsed),
215                        ansi::RESET
216                    );
217                } else {
218                    // Single line mode (no tool yet)
219                    print!("\r{}  {}{}{} {} {}{}({}){}",
220                        ansi::CLEAR_LINE,
221                        ansi::CYAN,
222                        frame,
223                        ansi::RESET,
224                        current_text,
225                        ansi::GRAY,
226                        ansi::DIM,
227                        format_elapsed(elapsed),
228                        ansi::RESET
229                    );
230                }
231                let _ = io::stdout().flush();
232            }
233            Some(msg) = receiver.recv() => {
234                match msg {
235                    SpinnerMessage::UpdateText(text) => {
236                        current_text = text;
237                    }
238                    SpinnerMessage::ToolExecuting { name, description } => {
239                        if !has_printed_tool_line {
240                            // First tool - print tool line then newline for spinner
241                            // Spinner will be on next line
242                            print!("\r{}  {}🔧 {}{}\n",
243                                ansi::CLEAR_LINE,
244                                ansi::PURPLE,
245                                name,
246                                ansi::RESET,
247                            );
248                            has_printed_tool_line = true;
249                        }
250                        // Tool line will be updated on next tick
251                        current_tool = Some(name);
252                        current_text = description;
253                        last_phrase_change = Instant::now();
254                    }
255                    SpinnerMessage::ToolComplete { name: _ } => {
256                        tools_completed += 1;
257                        current_tool = None;
258                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
259                        current_text = WITTY_PHRASES[phrase_index].to_string();
260                    }
261                    SpinnerMessage::Thinking(subject) => {
262                        current_text = format!("💭 {}", subject);
263                    }
264                    SpinnerMessage::Stop => {
265                        is_running.store(false, Ordering::SeqCst);
266                        break;
267                    }
268                }
269            }
270        }
271    }
272
273    // Clear both lines and show summary
274    if has_printed_tool_line {
275        // Clear spinner line
276        print!("\r{}", ansi::CLEAR_LINE);
277        // Move up and clear tool line
278        print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
279    } else {
280        print!("\r{}", ansi::CLEAR_LINE);
281    }
282
283    // Print summary
284    if tools_completed > 0 {
285        println!(
286            "  {}✓{} {} tool{} used",
287            ansi::SUCCESS,
288            ansi::RESET,
289            tools_completed,
290            if tools_completed == 1 { "" } else { "s" }
291        );
292    }
293    print!("{}", ansi::SHOW_CURSOR);
294    let _ = io::stdout().flush();
295}
296
297/// A simple inline spinner for synchronous contexts
298pub struct InlineSpinner {
299    frames: Vec<&'static str>,
300    current: usize,
301}
302
303impl InlineSpinner {
304    pub fn new() -> Self {
305        Self {
306            frames: SPINNER_FRAMES.to_vec(),
307            current: 0,
308        }
309    }
310
311    /// Get the next frame
312    pub fn next_frame(&mut self) -> &'static str {
313        let frame = self.frames[self.current % self.frames.len()];
314        self.current += 1;
315        frame
316    }
317
318    /// Print a spinner update inline (clears and rewrites)
319    pub fn print(&mut self, message: &str) {
320        let frame = self.next_frame();
321        print!("{}{} {}", ansi::CLEAR_LINE, frame, message);
322        let _ = io::stdout().flush();
323    }
324}
325
326impl Default for InlineSpinner {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_inline_spinner() {
338        let mut spinner = InlineSpinner::new();
339        assert_eq!(spinner.next_frame(), "⠋");
340        assert_eq!(spinner.next_frame(), "⠙");
341        assert_eq!(spinner.next_frame(), "⠹");
342    }
343}