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;
#[derive(Clone)]
pub struct AnimationState {
pub cost: Arc<AtomicU64>, pub context_tokens: Arc<AtomicU64>,
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)),
}
}
pub fn update_cost(&self, cost: f64) {
self.cost.store((cost * 10000.0) as u64, Ordering::Relaxed);
}
pub fn get_cost(&self) -> f64 {
self.cost.load(Ordering::Relaxed) as f64 / 10000.0
}
pub fn update_context_tokens(&self, tokens: u64) {
self.context_tokens.store(tokens, Ordering::Relaxed);
}
pub fn get_context_tokens(&self) -> u64 {
self.context_tokens.load(Ordering::Relaxed)
}
pub fn update_max_threshold(&self, threshold: usize) {
self.max_threshold
.store(threshold as u64, Ordering::Relaxed);
}
pub fn get_max_threshold(&self) -> usize {
self.max_threshold.load(Ordering::Relaxed) as usize
}
}
impl Default for AnimationState {
fn default() -> Self {
Self::new()
}
}
pub struct AnimationManager {
current_task: Arc<std::sync::Mutex<Option<JoinHandle<()>>>>,
cancel_notify: Arc<std::sync::Mutex<Arc<Notify>>>,
state: AnimationState,
cancel_rx: Arc<std::sync::Mutex<Option<watch::Receiver<bool>>>>,
suspended: Arc<AtomicBool>,
spinner: Arc<std::sync::Mutex<Option<indicatif::ProgressBar>>>,
}
impl AnimationManager {
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)),
}
}
pub fn get_state(&self) -> AnimationState {
self.state.clone()
}
pub fn set_cancel_receiver(&self, rx: watch::Receiver<bool>) {
*self.cancel_rx.lock().unwrap() = Some(rx);
}
pub async fn suspend(&self) {
self.suspended.store(true, Ordering::SeqCst);
self.stop_current().await;
log_debug!("Animation suspended - user prompt imminent");
}
pub fn resume(&self) {
self.suspended.store(false, Ordering::SeqCst);
log_debug!("Animation resumed");
}
pub fn is_suspended(&self) -> bool {
self.suspended.load(Ordering::SeqCst)
}
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.suspend(f)
} else {
drop(spinner_guard);
f()
}
}
pub fn clear_cancel_receiver(&self) {
*self.cancel_rx.lock().unwrap() = None;
}
pub async fn stop_current(&self) {
self.cancel_notify.lock().unwrap().notify_one();
self.clear_cancel_receiver();
let task = {
let mut guard = self.current_task.lock().unwrap();
guard.take()
};
if let Some(task) = task {
match tokio::time::timeout(Duration::from_millis(500), task).await {
Ok(_) => {}
Err(_) => {
log_debug!("Animation cleanup timed out — aborting task");
}
}
}
}
pub async fn start_animation(&self, mode: &crate::session::output::OutputMode) {
if self.is_suspended() {
log_debug!("Animation start requested but manager is suspended (user prompt active)");
return;
}
self.stop_current().await;
if !mode.should_show_animations() {
return;
}
self.start_internal().await;
}
pub async fn start_with_params(&self, cost: f64, context_tokens: u64, max_threshold: usize) {
self.stop_current().await;
let output_mode = crate::config::with_thread_config(|config| config.output_mode())
.unwrap_or(crate::session::output::OutputMode::NonInteractive);
if !output_mode.should_show_animations() {
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;
}
self.state.update_cost(cost);
self.state.update_context_tokens(context_tokens);
self.state.update_max_threshold(max_threshold);
self.start_internal().await;
}
async fn start_internal(&self) {
let cancel_notify = Arc::new(Notify::new());
*self.cancel_notify.lock().unwrap() = cancel_notify.clone();
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 {
let mut spinner: Option<indicatif::ProgressBar> = None;
let start_time = std::time::Instant::now();
'animation: loop {
if let Some(ref rx) = cancel_rx {
if *rx.borrow() {
break 'animation;
}
}
let current_cost = state.get_cost();
let current_context_tokens = state.get_context_tokens();
let max_threshold = state.get_max_threshold();
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 {
let percentage =
(current_context_tokens as f64 / max_threshold as f64 * 100.0).min(100.0);
format!("[{:.1}%] Working …", percentage)
} else {
"Working …".to_string()
};
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));
*spinner_ref.lock().unwrap() = Some(s.clone());
spinner = Some(s);
}
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! {
_ = tokio::time::sleep(Duration::from_millis(100)) => {
}
_ = 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 {
std::future::pending::<()>().await;
}
} => {
log_debug!("Animation cancelled via session cancellation channel");
break 'animation;
}
_ = cancel_notify.notified() => {
log_debug!("Animation cancelled via stop_current()");
break 'animation;
}
}
}
*spinner_ref.lock().unwrap() = None;
if let Some(s) = spinner {
s.finish_and_clear();
drop(tokio::task::spawn_blocking(move || {
s.disable_steady_tick();
}));
}
});
*current_task.lock().unwrap() = Some(task);
}
pub fn is_running(&self) -> bool {
self.current_task.lock().unwrap().is_some()
}
}
impl Default for AnimationManager {
fn default() -> Self {
Self::new()
}
}
pub static GLOBAL_ANIMATION_MANAGER: std::sync::OnceLock<AnimationManager> =
std::sync::OnceLock::new();
pub fn get_animation_manager() -> &'static AnimationManager {
GLOBAL_ANIMATION_MANAGER.get_or_init(AnimationManager::new)
}