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 interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS));
158 let mut rng = StdRng::from_entropy();
159
160 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 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 let display = if let Some(ref tool) = current_tool {
189 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 format!("{}{}{} {}✓{}{} {}",
205 ansi::CYAN, frame, ansi::RESET,
206 ansi::SUCCESS, tools_completed, ansi::RESET,
207 current_text)
208 } else {
209 format!("{}{}{} {}",
211 ansi::CYAN, frame, ansi::RESET,
212 current_text)
213 };
214
215 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 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
269pub 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 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 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}