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_entropy();
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.gen_bool(0.25) && !TIPS.is_empty() {
185                        let tip_idx = rng.gen_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                        print!("{}{}  {}🔧 {}{}{}",
198                            ansi::CURSOR_UP,
199                            ansi::CLEAR_LINE,
200                            ansi::PURPLE,
201                            tool,
202                            ansi::RESET,
203                            "\n" // Move back down
204                        );
205                    }
206                    // Now update spinner line
207                    print!("\r{}  {}{}{} {} {}{}({}){}",
208                        ansi::CLEAR_LINE,
209                        ansi::CYAN,
210                        frame,
211                        ansi::RESET,
212                        current_text,
213                        ansi::GRAY,
214                        ansi::DIM,
215                        format_elapsed(elapsed),
216                        ansi::RESET
217                    );
218                } else {
219                    // Single line mode (no tool yet)
220                    print!("\r{}  {}{}{} {} {}{}({}){}",
221                        ansi::CLEAR_LINE,
222                        ansi::CYAN,
223                        frame,
224                        ansi::RESET,
225                        current_text,
226                        ansi::GRAY,
227                        ansi::DIM,
228                        format_elapsed(elapsed),
229                        ansi::RESET
230                    );
231                }
232                let _ = io::stdout().flush();
233            }
234            Some(msg) = receiver.recv() => {
235                match msg {
236                    SpinnerMessage::UpdateText(text) => {
237                        current_text = text;
238                    }
239                    SpinnerMessage::ToolExecuting { name, description } => {
240                        if !has_printed_tool_line {
241                            // First tool - print tool line then newline for spinner
242                            print!("\r{}  {}🔧 {}{}{}\n",
243                                ansi::CLEAR_LINE,
244                                ansi::PURPLE,
245                                name,
246                                ansi::RESET,
247                                "" // Spinner will be on next line
248                            );
249                            has_printed_tool_line = true;
250                        }
251                        // Tool line will be updated on next tick
252                        current_tool = Some(name);
253                        current_text = description;
254                        last_phrase_change = Instant::now();
255                    }
256                    SpinnerMessage::ToolComplete { name: _ } => {
257                        tools_completed += 1;
258                        current_tool = None;
259                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
260                        current_text = WITTY_PHRASES[phrase_index].to_string();
261                    }
262                    SpinnerMessage::Thinking(subject) => {
263                        current_text = format!("💭 {}", subject);
264                    }
265                    SpinnerMessage::Stop => {
266                        is_running.store(false, Ordering::SeqCst);
267                        break;
268                    }
269                }
270            }
271        }
272    }
273
274    // Clear both lines and show summary
275    if has_printed_tool_line {
276        // Clear spinner line
277        print!("\r{}", ansi::CLEAR_LINE);
278        // Move up and clear tool line
279        print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
280    } else {
281        print!("\r{}", ansi::CLEAR_LINE);
282    }
283
284    // Print summary
285    if tools_completed > 0 {
286        println!(
287            "  {}✓{} {} tool{} used",
288            ansi::SUCCESS,
289            ansi::RESET,
290            tools_completed,
291            if tools_completed == 1 { "" } else { "s" }
292        );
293    }
294    print!("{}", ansi::SHOW_CURSOR);
295    let _ = io::stdout().flush();
296}
297
298/// A simple inline spinner for synchronous contexts
299pub struct InlineSpinner {
300    frames: Vec<&'static str>,
301    current: usize,
302}
303
304impl InlineSpinner {
305    pub fn new() -> Self {
306        Self {
307            frames: SPINNER_FRAMES.to_vec(),
308            current: 0,
309        }
310    }
311
312    /// Get the next frame
313    pub fn next_frame(&mut self) -> &'static str {
314        let frame = self.frames[self.current % self.frames.len()];
315        self.current += 1;
316        frame
317    }
318
319    /// Print a spinner update inline (clears and rewrites)
320    pub fn print(&mut self, message: &str) {
321        let frame = self.next_frame();
322        print!("{}{} {}", ansi::CLEAR_LINE, frame, message);
323        let _ = io::stdout().flush();
324    }
325}
326
327impl Default for InlineSpinner {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_inline_spinner() {
339        let mut spinner = InlineSpinner::new();
340        assert_eq!(spinner.next_frame(), "⠋");
341        assert_eq!(spinner.next_frame(), "⠙");
342        assert_eq!(spinner.next_frame(), "⠹");
343    }
344}