use ratatui::prelude::*;
use crate::tui::theme::Theme;
#[derive(Debug, Clone)]
pub struct Spinner {
frame: usize,
style: SpinnerStyle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SpinnerStyle {
Braille,
BouncingBall,
GradientBar,
Dots,
Compact,
}
impl Spinner {
pub fn new() -> Self {
Self {
frame: 0,
style: SpinnerStyle::Braille,
}
}
pub fn tick(&mut self) {
self.frame = self.frame.wrapping_add(1);
}
pub fn reset(&mut self) {
self.frame = 0;
}
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))
}
pub fn thinking_verb<'a>(&self, theme: &'a Theme) -> &'a str {
let verbs = theme.thinking_verbs();
let step = self.frame / 40; verbs[step % verbs.len()]
}
pub fn thinking_label(&self, status_msg: &str, theme: &Theme) -> Vec<Span<'static>> {
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),
]
}
pub fn status_indicator(&self, theme: &Theme) -> String {
let mut bar = Self {
frame: self.frame,
style: SpinnerStyle::GradientBar,
};
bar.tick(); let (text, _) = bar.current_frame();
let _ = theme; text.to_string()
}
fn current_frame(&self) -> (&'static str, bool) {
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; 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] {
&["◐", "◓", "◑", "◒"]
}
pub fn set_style(&mut self, style: SpinnerStyle) {
self.style = style;
self.frame = 0;
}
pub fn set_compact(&mut self) {
self.set_style(SpinnerStyle::Compact);
}
pub fn set_default(&mut self) {
self.set_style(SpinnerStyle::Braille);
}
pub fn is_compact(&self) -> bool {
self.style == SpinnerStyle::Compact
}
pub fn frame_count(&self) -> usize {
self.frame
}
}
impl Default for Spinner {
fn default() -> Self {
Self::new()
}
}
fn derive_spinner_label(status_msg: &str, fallback_verb: &str) -> String {
if let Some(tool_name) = status_msg.strip_prefix("Running tool: ") {
return format!("Running {tool_name}");
}
if status_msg.starts_with("Thinking") {
return "Thinking".to_string();
}
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();
}
if status_msg.contains("Stopping") || status_msg.contains("stop") {
return "Stopping".to_string();
}
if status_msg.contains("Retrying") || status_msg.contains("retry") {
return "Retrying".to_string();
}
if status_msg == "Generating" {
return "Generating".to_string();
}
if status_msg.contains("Compact") || status_msg.contains("compact") {
return "Compacting".to_string();
}
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(); 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"
);
}
}