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 interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS));
158    let mut rng = StdRng::from_entropy();
159
160    // Hide cursor during spinner
161    print!("{}", ansi::HIDE_CURSOR);
162    let _ = io::stdout().flush();
163
164    loop {
165        tokio::select! {
166            _ = interval.tick() => {
167                if !is_running.load(Ordering::SeqCst) {
168                    break;
169                }
170
171                let elapsed = start_time.elapsed().as_secs();
172                let frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.len()];
173                frame_index += 1;
174
175                // Cycle phrases every PHRASE_CHANGE_INTERVAL_SECS if not showing tool activity
176                if current_tool.is_none() && last_phrase_change.elapsed().as_secs() >= PHRASE_CHANGE_INTERVAL_SECS {
177                    if rng.gen_bool(0.25) && !TIPS.is_empty() {
178                        let tip_idx = rng.gen_range(0..TIPS.len());
179                        current_text = TIPS[tip_idx].to_string();
180                    } else {
181                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
182                        current_text = WITTY_PHRASES[phrase_index].to_string();
183                    }
184                    last_phrase_change = Instant::now();
185                }
186
187                // Build compact single-line display
188                let display = if let Some(ref tool) = current_tool {
189                    // Currently executing a tool
190                    if tools_completed > 0 {
191                        format!("{}{}{} {}✓{}{} {}🔧 {}{} {}",
192                            ansi::CYAN, frame, ansi::RESET,
193                            ansi::SUCCESS, tools_completed, ansi::RESET,
194                            ansi::PURPLE, tool, ansi::RESET,
195                            current_text)
196                    } else {
197                        format!("{}{}{} {}🔧 {}{} {}",
198                            ansi::CYAN, frame, ansi::RESET,
199                            ansi::PURPLE, tool, ansi::RESET,
200                            current_text)
201                    }
202                } else if tools_completed > 0 {
203                    // Between tools, show completed count
204                    format!("{}{}{} {}✓{}{} {}",
205                        ansi::CYAN, frame, ansi::RESET,
206                        ansi::SUCCESS, tools_completed, ansi::RESET,
207                        current_text)
208                } else {
209                    // Initial state, just thinking
210                    format!("{}{}{} {}",
211                        ansi::CYAN, frame, ansi::RESET,
212                        current_text)
213                };
214
215                // Update the SAME line (no newlines!)
216                print!("\r{}{} {}{}({}){}",
217                    ansi::CLEAR_LINE,
218                    display,
219                    ansi::GRAY,
220                    ansi::DIM,
221                    format_elapsed(elapsed),
222                    ansi::RESET
223                );
224                let _ = io::stdout().flush();
225            }
226            Some(msg) = receiver.recv() => {
227                match msg {
228                    SpinnerMessage::UpdateText(text) => {
229                        current_text = text;
230                    }
231                    SpinnerMessage::ToolExecuting { name, description } => {
232                        current_tool = Some(name);
233                        current_text = description;
234                        last_phrase_change = Instant::now();
235                    }
236                    SpinnerMessage::ToolComplete { name: _ } => {
237                        tools_completed += 1;
238                        current_tool = None;
239                        phrase_index = (phrase_index + 1) % WITTY_PHRASES.len();
240                        current_text = WITTY_PHRASES[phrase_index].to_string();
241                    }
242                    SpinnerMessage::Thinking(subject) => {
243                        current_text = format!("💭 {}", subject);
244                        current_tool = None;
245                    }
246                    SpinnerMessage::Stop => {
247                        is_running.store(false, Ordering::SeqCst);
248                        break;
249                    }
250                }
251            }
252        }
253    }
254
255    // Clear the spinner line and show cursor
256    // Optionally print a summary if tools were used
257    print!("\r{}", ansi::CLEAR_LINE);
258    if tools_completed > 0 {
259        println!("  {}✓{} {} tool{} used",
260            ansi::SUCCESS, ansi::RESET,
261            tools_completed,
262            if tools_completed == 1 { "" } else { "s" }
263        );
264    }
265    print!("{}", ansi::SHOW_CURSOR);
266    let _ = io::stdout().flush();
267}
268
269/// A simple inline spinner for synchronous contexts
270pub struct InlineSpinner {
271    frames: Vec<&'static str>,
272    current: usize,
273}
274
275impl InlineSpinner {
276    pub fn new() -> Self {
277        Self {
278            frames: SPINNER_FRAMES.to_vec(),
279            current: 0,
280        }
281    }
282
283    /// Get the next frame
284    pub fn next_frame(&mut self) -> &'static str {
285        let frame = self.frames[self.current % self.frames.len()];
286        self.current += 1;
287        frame
288    }
289
290    /// Print a spinner update inline (clears and rewrites)
291    pub fn print(&mut self, message: &str) {
292        let frame = self.next_frame();
293        print!("{}{} {}", ansi::CLEAR_LINE, frame, message);
294        let _ = io::stdout().flush();
295    }
296}
297
298impl Default for InlineSpinner {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_inline_spinner() {
310        let mut spinner = InlineSpinner::new();
311        assert_eq!(spinner.next_frame(), "⠋");
312        assert_eq!(spinner.next_frame(), "⠙");
313        assert_eq!(spinner.next_frame(), "⠹");
314    }
315}