octomind 0.22.0

Session-based AI development assistant with conversational codebase interaction, multimodal vision support, built-in MCP tools, and multi-provider AI integration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
// Copyright 2025 Muvon Un Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Global animation manager - ensures only one animation runs at a time
//!
//! This module provides a centralized animation management system that:
//! - Ensures only one animation runs at a time (prevents overlapping animations)
//! - Dynamically updates cost and context values in real-time
//! - Provides clean cancellation and cleanup
//! - Prevents animation stuck bugs
//! - Responds INSTANTLY to Ctrl+C cancellation (no delays)

use crate::log_debug;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{watch, Notify};
use tokio::task::JoinHandle;

/// Shared animation state for dynamic updates
#[derive(Clone)]
pub struct AnimationState {
	/// Current cost (updated dynamically)
	pub cost: Arc<AtomicU64>, // Store as u64 (multiply by 10000 for precision)
	/// Current context tokens (updated dynamically)
	pub context_tokens: Arc<AtomicU64>,
	/// Max threshold for percentage calculation
	pub max_threshold: Arc<AtomicU64>,
}

impl AnimationState {
	pub fn new() -> Self {
		Self {
			cost: Arc::new(AtomicU64::new(0)),
			context_tokens: Arc::new(AtomicU64::new(0)),
			max_threshold: Arc::new(AtomicU64::new(0)),
		}
	}

	/// Update cost (converts f64 to u64 with 4 decimal precision)
	pub fn update_cost(&self, cost: f64) {
		self.cost.store((cost * 10000.0) as u64, Ordering::Relaxed);
	}

	/// Get cost (converts u64 back to f64)
	pub fn get_cost(&self) -> f64 {
		self.cost.load(Ordering::Relaxed) as f64 / 10000.0
	}

	/// Update context tokens
	pub fn update_context_tokens(&self, tokens: u64) {
		self.context_tokens.store(tokens, Ordering::Relaxed);
	}

	/// Get context tokens
	pub fn get_context_tokens(&self) -> u64 {
		self.context_tokens.load(Ordering::Relaxed)
	}

	/// Update max threshold
	pub fn update_max_threshold(&self, threshold: usize) {
		self.max_threshold
			.store(threshold as u64, Ordering::Relaxed);
	}

	/// Get max threshold
	pub fn get_max_threshold(&self) -> usize {
		self.max_threshold.load(Ordering::Relaxed) as usize
	}
}

impl Default for AnimationState {
	fn default() -> Self {
		Self::new()
	}
}

/// Global animation manager - singleton pattern
pub struct AnimationManager {
	/// Current animation task (if any)
	current_task: Arc<std::sync::Mutex<Option<JoinHandle<()>>>>,
	/// Notify for the *current* animation task — replaced with a fresh one each start
	/// so a leftover notification from stop_current() never kills the next animation
	cancel_notify: Arc<std::sync::Mutex<Arc<Notify>>>,
	/// Shared animation state for dynamic updates
	state: AnimationState,
	/// Optional cancellation receiver from session (for instant Ctrl+C response)
	cancel_rx: Arc<std::sync::Mutex<Option<watch::Receiver<bool>>>>,
	/// Suspended flag - prevents animation from starting during user prompts
	suspended: Arc<AtomicBool>,
	/// Shared spinner reference for suspend/resume operations
	spinner: Arc<std::sync::Mutex<Option<indicatif::ProgressBar>>>,
}

impl AnimationManager {
	/// Create new animation manager
	pub fn new() -> Self {
		Self {
			current_task: Arc::new(std::sync::Mutex::new(None)),
			cancel_notify: Arc::new(std::sync::Mutex::new(Arc::new(Notify::new()))),
			state: AnimationState::new(),
			cancel_rx: Arc::new(std::sync::Mutex::new(None)),
			suspended: Arc::new(AtomicBool::new(false)),
			spinner: Arc::new(std::sync::Mutex::new(None)),
		}
	}

	/// Get shared animation state for external updates
	pub fn get_state(&self) -> AnimationState {
		self.state.clone()
	}

	/// Set cancellation receiver from session (for instant Ctrl+C response)
	/// This allows the animation to respond immediately to Ctrl+C without waiting for stop_current()
	pub fn set_cancel_receiver(&self, rx: watch::Receiver<bool>) {
		*self.cancel_rx.lock().unwrap() = Some(rx);
	}

	/// Clear cancellation receiver (call when animation stops)
	/// Suspend animation - stops current animation and prevents new ones from starting
	/// Use this before displaying user prompts to prevent animation from covering the prompt
	pub async fn suspend(&self) {
		// Set suspended flag FIRST to prevent any race conditions
		self.suspended.store(true, Ordering::SeqCst);
		// Then stop current animation
		self.stop_current().await;
		log_debug!("Animation suspended - user prompt imminent");
	}

	/// Resume animation - allows animation to start again
	/// Call this after user input is complete
	pub fn resume(&self) {
		self.suspended.store(false, Ordering::SeqCst);
		log_debug!("Animation resumed");
	}

	/// Check if animation is suspended
	pub fn is_suspended(&self) -> bool {
		self.suspended.load(Ordering::SeqCst)
	}

	/// Execute a function while temporarily suspending the spinner
	/// This prevents output from interfering with the animation
	/// If no spinner is active, just executes the function normally
	pub fn with_suspended_spinner<F, R>(&self, f: F) -> R
	where
		F: FnOnce() -> R,
	{
		let spinner_guard = self.spinner.lock().unwrap();
		if let Some(ref spinner) = *spinner_guard {
			// Spinner is active - use indicatif's suspend to hide it temporarily
			spinner.suspend(f)
		} else {
			// No spinner active - just execute normally
			drop(spinner_guard);
			f()
		}
	}

	pub fn clear_cancel_receiver(&self) {
		*self.cancel_rx.lock().unwrap() = None;
	}

	/// Stop current animation (if any)
	pub async fn stop_current(&self) {
		// Wake the animation task instantly — zero CPU, no busy-poll
		self.cancel_notify.lock().unwrap().notify_one();

		// Clear the cancellation receiver
		self.clear_cancel_receiver();

		// Wait for task to finish gracefully (cleanup will run properly)
		let task = {
			let mut guard = self.current_task.lock().unwrap();
			guard.take()
		};

		if let Some(task) = task {
			// Wait for graceful shutdown with timeout — never block Ctrl+C forever
			// If indicatif's disable_steady_tick() hangs (thread deadlock), abort the task
			// to prevent leaked spawn_blocking threads from saturating the thread pool
			match tokio::time::timeout(Duration::from_millis(500), task).await {
				Ok(_) => {}
				Err(_) => {
					log_debug!("Animation cleanup timed out — aborting task");
					// The task is detached on drop. The spawn_blocking inside it will
					// eventually complete or be cleaned up when the runtime shuts down.
				}
			}
		}
	}
	/// Start new animation (stops any existing animation first)
	///
	/// This ensures only one animation runs at a time, preventing:
	/// - Overlapping animations
	/// - Animation stuck bugs
	/// - Stale cost/context values
	///
	/// **Pro-level feature**: Dynamically reads live cost/context from shared state
	/// during animation loop for real-time updates during long operations.
	/// Start new animation (stops any existing animation first)
	///
	/// This ensures only one animation runs at a time, preventing:
	/// - Overlapping animations
	/// - Animation stuck bugs
	/// - Stale cost/context values
	///
	/// **Pro-level feature**: Dynamically reads live cost/context from shared state
	/// during animation loop for real-time updates during long operations.
	pub async fn start_animation(&self, mode: &crate::session::output::OutputMode) {
		// Check if suspended - don't start animation during user prompts
		if self.is_suspended() {
			log_debug!("Animation start requested but manager is suspended (user prompt active)");
			return;
		}

		// Stop any existing animation first
		self.stop_current().await;

		// Only show animation in interactive mode
		if !mode.should_show_animations() {
			return;
		}

		self.start_internal().await;
	}
	///
	/// Use this for standalone animations where you have specific cost/context values.
	/// Automatically detects interactive vs non-interactive mode.
	pub async fn start_with_params(&self, cost: f64, context_tokens: u64, max_threshold: usize) {
		// Stop any existing animation first
		self.stop_current().await;

		// Resolve output mode from thread config
		let output_mode = crate::config::with_thread_config(|config| config.output_mode())
			.unwrap_or(crate::session::output::OutputMode::NonInteractive);

		// Only show animated spinner in interactive mode
		if !output_mode.should_show_animations() {
			// Show static line for non-interactive terminal modes (not jsonl/websocket)
			if output_mode.is_terminal_mode() {
				if cost > 0.0 {
					println!(
						" ── cost: ${:.5} ────────────────────────────────────────",
						cost
					);
				} else if max_threshold > 0 {
					let percentage =
						(context_tokens as f64 / max_threshold as f64 * 100.0).min(100.0);
					println!(
						" ── context: {:.1}% ────────────────────────────────────────",
						percentage
					);
				}
			}
			return;
		}

		// Update state with provided values
		self.state.update_cost(cost);
		self.state.update_context_tokens(context_tokens);
		self.state.update_max_threshold(max_threshold);

		self.start_internal().await;
	}

	/// Internal animation start logic
	async fn start_internal(&self) {
		// Create a FRESH Notify for this animation cycle — prevents a leftover
		// notify_one() from stop_current() firing immediately on the new task
		let cancel_notify = Arc::new(Notify::new());
		*self.cancel_notify.lock().unwrap() = cancel_notify.clone();

		// Clone references for animation task

		let current_task = self.current_task.clone();
		let state = self.state.clone();
		let cancel_rx = self.cancel_rx.lock().unwrap().clone();
		let spinner_ref = self.spinner.clone();

		let task = tokio::spawn(async move {
			// Animation loop with truly dynamic cost/context updates
			let mut spinner: Option<indicatif::ProgressBar> = None;
			let start_time = std::time::Instant::now();

			'animation: loop {
				// Check session cancellation receiver if available (INSTANT Ctrl+C response)
				if let Some(ref rx) = cancel_rx {
					if *rx.borrow() {
						break 'animation;
					}
				}

				// Read live cost/context from shared state (dynamic updates!)
				let current_cost = state.get_cost();
				let current_context_tokens = state.get_context_tokens();
				let max_threshold = state.get_max_threshold();

				// Calculate dynamic base message with live cost/context
				let base_message = if current_cost > 0.0 && max_threshold > 0 {
					let percentage =
						(current_context_tokens as f64 / max_threshold as f64 * 100.0).min(100.0);
					format!("[${:.2}|{:.1}%] Working …", current_cost, percentage)
				} else if current_cost > 0.0 {
					format!("[${:.2}|∞] Working …", current_cost)
				} else if max_threshold > 0 {
					// No cost but still show context percentage
					let percentage =
						(current_context_tokens as f64 / max_threshold as f64 * 100.0).min(100.0);
					format!("[{:.1}%] Working …", percentage)
				} else {
					"Working …".to_string()
				};

				// Create spinner on first iteration
				if spinner.is_none() {
					use indicatif::{ProgressBar, ProgressStyle};
					use std::time::Duration;

					let s = ProgressBar::new_spinner();
					s.set_style(
						ProgressStyle::default_spinner()
							.template(" {spinner:.cyan} {msg:.cyan}")
							.unwrap()
							.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧"),
					);
					s.set_message(base_message.clone());
					s.enable_steady_tick(Duration::from_millis(50));

					// Store spinner reference for suspend operations
					*spinner_ref.lock().unwrap() = Some(s.clone());
					spinner = Some(s);
				}

				// Update message with elapsed time and dynamic cost/context
				if let Some(ref s) = spinner {
					let elapsed = start_time.elapsed();
					let elapsed_secs = elapsed.as_secs();
					let message = if elapsed_secs > 0 {
						use colored::Colorize;
						let time_and_hint = format!(
							"({} • Ctrl+C to interrupt)",
							crate::session::chat::animation::format_elapsed_time(elapsed)
						);
						format!("{} {}", base_message, time_and_hint.dimmed())
					} else {
						use colored::Colorize;
						format!("{} {}", base_message, "(Ctrl+C to interrupt)".dimmed())
					};
					s.set_message(message);
				}

				tokio::select! {
					// Sleep for animation update interval
					_ = tokio::time::sleep(Duration::from_millis(100)) => {
						// Normal sleep completed, continue loop
					}
					// INSTANT cancellation from session's watch channel (Ctrl+C)
					_ = async {
						if let Some(ref rx) = cancel_rx {
							let mut rx_clone = rx.clone();
							while !*rx_clone.borrow() {
								if rx_clone.changed().await.is_err() {
									break;
								}
							}
						} else {
							// No receiver - wait forever
							std::future::pending::<()>().await;
						}
					} => {
						log_debug!("Animation cancelled via session cancellation channel");
						break 'animation;
					}
					// INSTANT cancellation from stop_current() — zero CPU, event-driven
					_ = cancel_notify.notified() => {
						log_debug!("Animation cancelled via stop_current()");
						break 'animation;
					}
				}
			}

			// Clear shared spinner reference first so no other code can use it during cleanup.
			*spinner_ref.lock().unwrap() = None;

			if let Some(s) = spinner {
				// `finish_and_clear()` is non-blocking — call it immediately so the spinner
				// line is erased from the terminal BEFORE stop_current() returns and any
				// subsequent println! output is written.  Without this, the fire-and-forget
				// spawn_blocking races with the next println! and clears a line of real output.
				s.finish_and_clear();

				// `disable_steady_tick()` joins indicatif's internal tick thread — it IS a
				// blocking call and must NOT run on the async executor.  Fire-and-forget it
				// so the animation task completes instantly and stop_current() never hangs.
				drop(tokio::task::spawn_blocking(move || {
					s.disable_steady_tick();
				}));
				// Intentionally NOT awaited — prevents stop_current() timeout cascade
			}
		});

		// Store task reference
		*current_task.lock().unwrap() = Some(task);
	}

	/// Check if animation is currently running
	pub fn is_running(&self) -> bool {
		self.current_task.lock().unwrap().is_some()
	}
}

impl Default for AnimationManager {
	fn default() -> Self {
		Self::new()
	}
}

/// Global animation manager instance
/// Made public so terminal_output module can access it for spinner suspension
pub static GLOBAL_ANIMATION_MANAGER: std::sync::OnceLock<AnimationManager> =
	std::sync::OnceLock::new();

/// Get global animation manager instance
pub fn get_animation_manager() -> &'static AnimationManager {
	GLOBAL_ANIMATION_MANAGER.get_or_init(AnimationManager::new)
}