albatross-cli 2.2.0

Open source, terminal-first AI coding agent with fully transparent multi-model routing. Local (Ollama, LM Studio, MLX, llama.cpp) or cloud, your keys, one TUI.
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use crate::config::LoaderStyle;
use crate::theme::{ascii_enabled, colors_enabled, MUTED, RESET};

const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const ASCII_SPINNER_FRAMES: &[&str] = &["-", "\\", "|", "/"];
const GRADIENT_COLORS: &[&str] = &[
    "\x1b[38;5;240m",
    "\x1b[38;5;245m",
    "\x1b[38;5;250m",
    "\x1b[38;5;255m",
    "\x1b[38;5;250m",
    "\x1b[38;5;245m",
];

pub struct Loader {
    stop: Arc<AtomicBool>,
    handle: Option<tokio::task::JoinHandle<()>>,
    text: Arc<std::sync::Mutex<String>>,
}

impl Loader {
    pub fn start(text: String, style: LoaderStyle) -> Self {
        Self::start_with_shared(text, style, Arc::new(AtomicBool::new(false)))
    }

    fn start_with_shared(text: String, style: LoaderStyle, stop: Arc<AtomicBool>) -> Self {
        let stop_inner = stop.clone();
        let text_shared = Arc::new(std::sync::Mutex::new(text));
        let text_for_task = text_shared.clone();
        let interval_ms: u64 = match style {
            LoaderStyle::Gradient => 150,
            LoaderStyle::Spinner => 80,
            LoaderStyle::Minimal => 300,
        };
        let handle = tokio::spawn(async move {
            let mut frame: usize = 0;
            draw(frame, &text_for_task.lock().unwrap(), style);
            loop {
                tokio::time::sleep(Duration::from_millis(interval_ms)).await;
                if stop_inner.load(Ordering::Relaxed) {
                    break;
                }
                frame = frame.wrapping_add(1);
                draw(frame, &text_for_task.lock().unwrap(), style);
            }
        });
        Self {
            stop,
            handle: Some(handle),
            text: text_shared,
        }
    }

    pub fn set_text(&self, text: String) {
        if let Ok(mut guard) = self.text.lock() {
            *guard = text;
        }
    }

    pub fn stop(self) {
        self.stop.store(true, Ordering::Relaxed);
        let mut out = std::io::stdout();
        let _ = write!(out, "\r\x1b[K");
        let _ = out.flush();
    }
}

impl Drop for Loader {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.handle.take() {
            h.abort();
        }
    }
}

fn draw(frame: usize, text: &str, style: LoaderStyle) {
    let mut out = std::io::stdout();
    // Gradient builds raw 256-color codes directly (not via `Style`), so it
    // needs an explicit NO_COLOR fallback: render as the Minimal style instead.
    let style = if style == LoaderStyle::Gradient && !colors_enabled() {
        LoaderStyle::Minimal
    } else {
        style
    };
    match style {
        LoaderStyle::Minimal => {
            let dots = ["·", "··", "···"];
            let _ = write!(out, "\r{MUTED}{text}{}{RESET}", dots[frame % 3]);
        }
        LoaderStyle::Spinner => {
            let frames = if ascii_enabled() {
                ASCII_SPINNER_FRAMES
            } else {
                SPINNER_FRAMES
            };
            let ch = frames[frame % frames.len()];
            let _ = write!(out, "\r{MUTED}{ch} {text}{RESET}");
        }
        LoaderStyle::Gradient => {
            let len = GRADIENT_COLORS.len();
            let mut s = String::from("\r");
            for (i, ch) in text.chars().enumerate() {
                s.push_str(GRADIENT_COLORS[(frame + i) % len]);
                s.push(ch);
            }
            s.push_str(&RESET.to_string());
            let _ = out.write_all(s.as_bytes());
        }
    }
    let _ = out.flush();
}