1use 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
13const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
16const ANIMATION_INTERVAL_MS: u64 = 80;
18
19const PHRASE_CHANGE_INTERVAL_SECS: u64 = 8;
21
22const 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
51const 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#[derive(Debug)]
63pub enum SpinnerMessage {
64 UpdateText(String),
66 ToolExecuting { name: String, description: String },
68 ToolComplete { name: String },
70 Thinking(String),
72 Stop,
74}
75
76pub struct Spinner {
78 sender: mpsc::Sender<SpinnerMessage>,
79 is_running: Arc<AtomicBool>,
80}
81
82impl Spinner {
83 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 pub async fn set_text(&self, text: &str) {
99 let _ = self.sender.send(SpinnerMessage::UpdateText(text.to_string())).await;
100 }
101
102 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 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 pub async fn thinking(&self, subject: &str) {
125 let _ = self.sender.send(SpinnerMessage::Thinking(subject.to_string())).await;
126 }
127
128 pub async fn stop(&self) {
130 let _ = self.sender.send(SpinnerMessage::Stop).await;
131 tokio::time::sleep(Duration::from_millis(50)).await;
133 }
134
135 pub fn is_running(&self) -> bool {
137 self.is_running.load(Ordering::SeqCst)
138 }
139}
140
141async 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 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 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 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" );
199 }
200 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 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 print!("\r{} {}🔧 {}{}{}\n",
237 ansi::CLEAR_LINE,
238 ansi::PURPLE,
239 name,
240 ansi::RESET,
241 "" );
243 has_printed_tool_line = true;
244 }
245 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 if has_printed_tool_line {
270 print!("\r{}", ansi::CLEAR_LINE);
272 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
274 } else {
275 print!("\r{}", ansi::CLEAR_LINE);
276 }
277
278 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
290pub 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 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 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}