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_os_rng();
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.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 if let Some(ref tool) = current_tool {
197 println!("{}{} {}🔧 {}{}", ansi::CURSOR_UP,
199 ansi::CLEAR_LINE,
200 ansi::PURPLE,
201 tool,
202 ansi::RESET,
203 );
204 }
205 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 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 print!("\r{} {}🔧 {}{}\n",
243 ansi::CLEAR_LINE,
244 ansi::PURPLE,
245 name,
246 ansi::RESET,
247 );
248 has_printed_tool_line = true;
249 }
250 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 if has_printed_tool_line {
275 print!("\r{}", ansi::CLEAR_LINE);
277 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
279 } else {
280 print!("\r{}", ansi::CLEAR_LINE);
281 }
282
283 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
297pub 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 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 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}