collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use ratatui::prelude::*;

use crate::tui::theme::Theme;

/// Animated spinner for indicating "thinking" / "working" states.
///
/// Uses braille dot patterns that rotate smoothly, similar to
/// Claude Code and OpenCode thinking indicators.
#[derive(Debug, Clone)]
pub struct Spinner {
    frame: usize,
    style: SpinnerStyle,
}

/// Available spinner animation styles.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SpinnerStyle {
    /// Braille rotation: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
    Braille,
    /// Bouncing dots: ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷
    BouncingBall,
    /// Gradient bar: ░▒▓█▓▒░
    GradientBar,
    /// Pulsing dots: ·  ··  ···  ····
    Dots,
    /// Compaction: ◐ ◓ ◑ ◒ (quarter-circle rotation)
    Compact,
}

impl Spinner {
    pub fn new() -> Self {
        Self {
            frame: 0,
            style: SpinnerStyle::Braille,
        }
    }

    /// Advance the animation by one frame. Called on each Tick (~10 FPS, TICK_INTERVAL=100ms).
    /// Divisors in current_frame() are calibrated for this rate (~10 FPS visual output).
    pub fn tick(&mut self) {
        self.frame = self.frame.wrapping_add(1);
    }

    /// Reset the spinner to the first frame.
    pub fn reset(&mut self) {
        self.frame = 0;
    }

    /// Render the spinner as a styled `Span` for embedding in lines.
    pub fn span(&self, theme: &Theme) -> Span<'static> {
        let (text, is_dim) = self.current_frame();
        let color = if is_dim {
            theme.spinner_dim
        } else {
            theme.spinner
        };
        Span::styled(text.to_string(), Style::default().fg(color))
    }

    /// Pick a theme-specific thinking verb that slowly rotates while working.
    ///
    /// Changes every ~4 seconds (TICK_INTERVAL=100ms → 40 frames per step).
    pub fn thinking_verb<'a>(&self, theme: &'a Theme) -> &'a str {
        let verbs = theme.thinking_verbs();
        let step = self.frame / 40; // change verb every ~4 seconds at 100ms/tick
        verbs[step % verbs.len()]
    }

    /// Render a context-aware "thinking" label based on the current status.
    ///
    /// When `status_msg` indicates a tool call (e.g. "Running tool: bash"),
    /// the label shows that tool name. Otherwise falls back to the theme's
    /// rotating vocabulary (`⠹ Concocting…`).
    pub fn thinking_label(&self, status_msg: &str, theme: &Theme) -> Vec<Span<'static>> {
        // Use BouncingBall for tool execution, Dots for thinking/waiting states.
        let display_style = if status_msg.starts_with("Running tool:") {
            SpinnerStyle::BouncingBall
        } else if status_msg.starts_with("Thinking") || status_msg.is_empty() {
            SpinnerStyle::Dots
        } else {
            self.style
        };
        let display_spinner = Self {
            frame: self.frame,
            style: display_style,
        };
        let spinner_span = display_spinner.span(theme);
        let label = derive_spinner_label(status_msg, self.thinking_verb(theme));
        vec![
            Span::styled(
                format!("  {} ", spinner_span.content),
                Style::default().fg(theme.spinner),
            ),
            Span::styled(
                label,
                Style::default()
                    .fg(theme.spinner)
                    .add_modifier(Modifier::BOLD),
            ),
            self.trailing_dots(theme),
        ]
    }

    /// Render an animated progress bar segment for the status line.
    ///
    /// Always uses the GradientBar animation regardless of the current spinner style.
    pub fn status_indicator(&self, theme: &Theme) -> String {
        let mut bar = Self {
            frame: self.frame,
            style: SpinnerStyle::GradientBar,
        };
        bar.tick(); // advance one frame so the bar animation differs from the main spinner
        let (text, _) = bar.current_frame();
        let _ = theme; // theme reserved for future colorization
        text.to_string()
    }

    // ── Internal ──

    fn current_frame(&self) -> (&'static str, bool) {
        // All divisors calibrated for TICK_INTERVAL=100ms:
        //   divisor 1 → 100ms/frame → ~10 FPS  (spinner, compact)
        //   divisor 2 → 200ms/frame →  ~5 FPS  (dots, trailing dots — intentionally slower)
        match self.style {
            SpinnerStyle::Braille => {
                let frames = Self::braille_frames();
                let idx = self.frame % frames.len();
                (frames[idx], false)
            }
            SpinnerStyle::BouncingBall => {
                let frames = Self::bouncing_frames();
                let idx = self.frame % frames.len();
                (frames[idx], false)
            }
            SpinnerStyle::GradientBar => {
                let frames = Self::gradient_frames();
                let idx = self.frame % frames.len();
                (frames[idx], false)
            }
            SpinnerStyle::Dots => {
                let frames = Self::dot_frames();
                let idx = (self.frame / 2) % frames.len();
                (frames[idx], idx == 0)
            }
            SpinnerStyle::Compact => {
                let frames = Self::compact_frames();
                let idx = self.frame % frames.len();
                (frames[idx], false)
            }
        }
    }

    fn trailing_dots(&self, theme: &Theme) -> Span<'static> {
        let count = ((self.frame / 2) % 4) + 1; // 200ms per dot step
        let dots: String = ".".repeat(count);
        let padding: String = " ".repeat(4 - count);
        Span::styled(
            format!("{dots}{padding}"),
            Style::default().fg(theme.spinner_dim),
        )
    }

    fn braille_frames() -> &'static [&'static str] {
        &["", "", "", "", "", "", "", "", "", ""]
    }

    fn bouncing_frames() -> &'static [&'static str] {
        &["", "", "", "", "", "", "", ""]
    }

    fn gradient_frames() -> &'static [&'static str] {
        &["░▒▓█", "▒▓█▓", "▓█▓▒", "█▓▒░", "▓▒░▒", "▒░▒▓"]
    }

    fn dot_frames() -> &'static [&'static str] {
        &["·   ", "··  ", "··· ", "····"]
    }

    fn compact_frames() -> &'static [&'static str] {
        &["", "", "", ""]
    }

    /// Switch to a specific animation style.
    pub fn set_style(&mut self, style: SpinnerStyle) {
        self.style = style;
        self.frame = 0;
    }

    /// Switch to compaction animation style.
    pub fn set_compact(&mut self) {
        self.set_style(SpinnerStyle::Compact);
    }

    /// Restore default animation style.
    pub fn set_default(&mut self) {
        self.set_style(SpinnerStyle::Braille);
    }

    pub fn is_compact(&self) -> bool {
        self.style == SpinnerStyle::Compact
    }

    /// Current frame counter (for auto-restore timing).
    pub fn frame_count(&self) -> usize {
        self.frame
    }
}

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

/// Derive a meaningful spinner label from the current `status_msg`.
///
/// Maps status messages to concise, action-oriented labels:
/// - `"Running tool: bash"` → `"Running bash"`
/// - `"Thinking (code)..."` → `"Thinking"`
/// - `"Resolving hive conflicts..."` → `"Resolving conflicts"`
/// - Empty / unknown → falls back to the theme's rotating verb.
fn derive_spinner_label(status_msg: &str, fallback_verb: &str) -> String {
    // Tool execution: "Running tool: <name>"
    if let Some(tool_name) = status_msg.strip_prefix("Running tool: ") {
        return format!("Running {tool_name}");
    }

    // Thinking with mode: "Thinking (code)..."
    if status_msg.starts_with("Thinking") {
        return "Thinking".to_string();
    }

    // Collaboration mode status (hive/flock/fork)
    if status_msg.contains("Hive")
        || status_msg.contains("hive")
        || status_msg.contains("Flock")
        || status_msg.contains("flock")
        || status_msg.contains("Fork")
        || status_msg.contains("fork")
    {
        if status_msg.contains("conflict") {
            return "Resolving conflicts".to_string();
        }
        return "Coordinating agents".to_string();
    }

    // Stopping
    if status_msg.contains("Stopping") || status_msg.contains("stop") {
        return "Stopping".to_string();
    }

    // Retry
    if status_msg.contains("Retrying") || status_msg.contains("retry") {
        return "Retrying".to_string();
    }

    // Generating (streaming active)
    if status_msg == "Generating" {
        return "Generating".to_string();
    }

    // Compacting context
    if status_msg.contains("Compact") || status_msg.contains("compact") {
        return "Compacting".to_string();
    }

    // Fallback: use the theme's rotating verb
    fallback_verb.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_spinner_tick_advances() {
        let mut s = Spinner::new();
        assert_eq!(s.frame, 0);
        s.tick();
        assert_eq!(s.frame, 1);
    }

    #[test]
    fn test_spinner_reset() {
        let mut s = Spinner::new();
        for _ in 0..100 {
            s.tick();
        }
        s.reset();
        assert_eq!(s.frame, 0);
    }

    #[test]
    fn test_spinner_wrapping() {
        let mut s = Spinner::new();
        s.frame = usize::MAX;
        s.tick(); // should wrap, not panic
        assert_eq!(s.frame, 0);
    }

    #[test]
    fn test_spinner_span_produces_output() {
        let s = Spinner::new();
        let theme = Theme::default_theme();
        let span = s.span(&theme);
        assert!(!span.content.is_empty());
    }

    #[test]
    fn test_all_styles_produce_output() {
        let theme = Theme::default_theme();
        for style in [
            SpinnerStyle::Braille,
            SpinnerStyle::BouncingBall,
            SpinnerStyle::GradientBar,
            SpinnerStyle::Dots,
            SpinnerStyle::Compact,
        ] {
            let mut s = Spinner::new();
            s.set_style(style);
            let span = s.span(&theme);
            assert!(
                !span.content.is_empty(),
                "style {style:?} produced empty span"
            );
        }
    }

    #[test]
    fn test_thinking_label() {
        let s = Spinner::new();
        let theme = Theme::default_theme();
        let spans = s.thinking_label("Thinking", &theme);
        assert_eq!(spans.len(), 3);
    }

    #[test]
    fn test_derive_spinner_label_tool_call() {
        assert_eq!(
            derive_spinner_label("Running tool: bash", "Brewing"),
            "Running bash"
        );
        assert_eq!(
            derive_spinner_label("Running tool: file_read", "Brewing"),
            "Running file_read"
        );
    }

    #[test]
    fn test_derive_spinner_label_thinking() {
        assert_eq!(
            derive_spinner_label("Thinking (code)...", "Brewing"),
            "Thinking"
        );
        assert_eq!(derive_spinner_label("Thinking", "Brewing"), "Thinking");
    }

    #[test]
    fn test_derive_spinner_label_fallback() {
        assert_eq!(derive_spinner_label("Ready", "Conjuring"), "Conjuring");
        assert_eq!(derive_spinner_label("", "Brewing"), "Brewing");
    }

    #[test]
    fn test_derive_spinner_label_special_states() {
        assert_eq!(
            derive_spinner_label("Resolving hive conflicts...", "X"),
            "Resolving conflicts"
        );
        assert_eq!(
            derive_spinner_label("Hive agent 'planner' started", "X"),
            "Coordinating agents"
        );
        assert_eq!(derive_spinner_label("⏹ Stopping...", "X"), "Stopping");
        assert_eq!(derive_spinner_label("Retrying (2/5)...", "X"), "Retrying");
        assert_eq!(derive_spinner_label("Generating", "X"), "Generating");
        assert_eq!(
            derive_spinner_label("Compacting context...", "X"),
            "Compacting"
        );
    }
}