1use 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
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
100 .sender
101 .send(SpinnerMessage::UpdateText(text.to_string()))
102 .await;
103 }
104
105 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 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 pub async fn thinking(&self, subject: &str) {
128 let _ = self
129 .sender
130 .send(SpinnerMessage::Thinking(subject.to_string()))
131 .await;
132 }
133
134 pub async fn stop(&self) {
136 let _ = self.sender.send(SpinnerMessage::Stop).await;
137 tokio::time::sleep(Duration::from_millis(50)).await;
139 }
140
141 pub fn is_running(&self) -> bool {
143 self.is_running.load(Ordering::SeqCst)
144 }
145}
146
147async 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 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 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 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" );
205 }
206 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 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 print!("\r{} {}🔧 {}{}{}\n",
243 ansi::CLEAR_LINE,
244 ansi::PURPLE,
245 name,
246 ansi::RESET,
247 "" );
249 has_printed_tool_line = true;
250 }
251 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 if has_printed_tool_line {
276 print!("\r{}", ansi::CLEAR_LINE);
278 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
280 } else {
281 print!("\r{}", ansi::CLEAR_LINE);
282 }
283
284 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
298pub 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 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 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}