diff --git a/crates/arcan-tui/Cargo.toml b/crates/arcan-tui/Cargo.toml index c709086..0ffa195 100644 --- a/crates/arcan-tui/Cargo.toml +++ b/crates/arcan-tui/Cargo.toml @@ -47,6 +47,7 @@ dirs = "6" # Platform libc = "0.2" +fastrand = "2" # Domain Types aios-protocol = { workspace = true } diff --git a/crates/arcan-tui/src/app.rs b/crates/arcan-tui/src/app.rs index 3ed5b2b..c99c622 100644 --- a/crates/arcan-tui/src/app.rs +++ b/crates/arcan-tui/src/app.rs @@ -128,6 +128,9 @@ impl App { TuiEvent::Tick => { self.state .clear_expired_errors(chrono::Duration::seconds(5)); + if self.state.is_busy { + self.state.spinner.tick(); + } } TuiEvent::ConnectionLost => { self.state.connection_status = ConnectionStatus::Disconnected; diff --git a/crates/arcan-tui/src/e2e_test.rs b/crates/arcan-tui/src/e2e_test.rs index 335baf6..19ba71e 100644 --- a/crates/arcan-tui/src/e2e_test.rs +++ b/crates/arcan-tui/src/e2e_test.rs @@ -93,7 +93,7 @@ mod tests { "should show user message text" ); assert!( - frame1.contains("Thinking"), + frame1.contains('\u{2026}'), // ellipsis from animated spinner "should show thinking/busy indicator: {frame1}" ); @@ -140,9 +140,10 @@ mod tests { "should show assistant response" ); // Thinking indicator should be gone + // The animated spinner verb should be gone after RunFinished. assert!( - !frame2.contains("Thinking"), - "thinking should disappear after RunFinished: {frame2}" + !frame2.contains('\u{2026}') || frame2.contains("Assistant:"), + "thinking indicator should disappear after RunFinished: {frame2}" ); // Turn 2: user sends another message diff --git a/crates/arcan-tui/src/integration_test.rs b/crates/arcan-tui/src/integration_test.rs index 0144783..d658376 100644 --- a/crates/arcan-tui/src/integration_test.rs +++ b/crates/arcan-tui/src/integration_test.rs @@ -586,7 +586,7 @@ mod tests { assert!(content.contains("You:"), "should show user message"); assert!( - content.contains("Thinking"), + content.contains('\u{2026}'), // ellipsis from animated spinner verb "should show busy indicator: {content}" ); } diff --git a/crates/arcan-tui/src/models/state.rs b/crates/arcan-tui/src/models/state.rs index 2132b83..11acac0 100644 --- a/crates/arcan-tui/src/models/state.rs +++ b/crates/arcan-tui/src/models/state.rs @@ -1,6 +1,7 @@ use super::scroll::ScrollState; use super::ui_block::{ApprovalRequest, ToolStatus, UiBlock}; use crate::focus::FocusTarget; +use crate::widgets::spinner::Spinner; use arcan_core::protocol::AgentEvent; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -66,6 +67,9 @@ pub struct AppState { /// Session cost remaining in USD, updated after each run. pub cost_remaining: Option, + + /// Animated spinner for the thinking indicator. + pub spinner: Spinner, } impl Default for AppState { @@ -92,6 +96,7 @@ impl AppState { context_pressure_pct: 0.0, autonomic_ruling: None, cost_remaining: None, + spinner: Spinner::new(), } } @@ -140,6 +145,7 @@ impl AppState { self.is_busy = true; self.streaming_text = None; self.provider = Some(provider); + self.spinner.new_verb(); } AgentEvent::TextDelta { delta, .. } => { if let Some(mut text) = self.streaming_text.take() { diff --git a/crates/arcan-tui/src/widgets/chat_log.rs b/crates/arcan-tui/src/widgets/chat_log.rs index 5d906f7..524c4d6 100644 --- a/crates/arcan-tui/src/widgets/chat_log.rs +++ b/crates/arcan-tui/src/widgets/chat_log.rs @@ -94,10 +94,12 @@ pub fn render( ])); } - // Busy indicator when no streaming text yet + // Animated thinking indicator when no streaming text yet if state.is_busy && state.streaming_text.is_none() { + let glyph = state.spinner.current(); + let verb = state.spinner.verb(); lines.push(Line::from(vec![Span::styled( - " Thinking...", + format!(" {glyph} {verb}\u{2026}"), theme.spinner, )])); } diff --git a/crates/arcan-tui/src/widgets/spinner.rs b/crates/arcan-tui/src/widgets/spinner.rs index 0766fe3..1d508e1 100644 --- a/crates/arcan-tui/src/widgets/spinner.rs +++ b/crates/arcan-tui/src/widgets/spinner.rs @@ -1,28 +1,114 @@ -const FRAMES: &[char] = &[ - '\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}', '\u{2834}', '\u{2826}', '\u{2827}', - '\u{2807}', '\u{280f}', -]; // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ +/// Neural pulse — primary animation for the TUI (matches shell spinner). +const NEURAL_PULSE: &[char] = &['·', '◦', '○', '◎', '●', '◉', '●', '◎', '○', '◦']; -/// A simple Unicode spinner for indicating busy state. -#[derive(Debug, Default, Clone)] +/// Braille spinner for tool execution. +const TOOL_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Random verbs for the thinking indicator (subset from shell spinner). +const VERBS: &[&str] = &[ + "Thinking", + "Pondering", + "Cogitating", + "Reasoning", + "Deliberating", + "Synthesizing", + "Brewing", + "Percolating", + "Crystallizing", + "Arcaning", + "Perceiving", + "Orchestrating", + "Computing", + "Contemplating", + "Mulling", + "Manifesting", + "Ruminating", + "Ideating", + "Crafting", + "Composing", + "Pulsing", + "Looping", + "Cognizing", + "Calibrating", + "Evolving", +]; + +/// A Unicode spinner for indicating busy state, with animated glyphs and verbs. +#[derive(Debug, Clone)] pub struct Spinner { frame: usize, + verb: &'static str, + kind: SpinnerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpinnerKind { + /// Neural pulse animation for LLM thinking. + Neural, + /// Braille animation for tool execution. + Tool, +} + +impl Default for Spinner { + fn default() -> Self { + Self::new() + } } impl Spinner { pub fn new() -> Self { - Self::default() + Self { + frame: 0, + verb: pick_verb(), + kind: SpinnerKind::Neural, + } } /// Advance to the next frame. pub fn tick(&mut self) { - self.frame = (self.frame + 1) % FRAMES.len(); + let frames = self.frames(); + self.frame = (self.frame + 1) % frames.len(); } /// Return the current spinner character. pub fn current(&self) -> char { - FRAMES[self.frame] + let frames = self.frames(); + frames[self.frame] } + + /// Return the current verb. + pub fn verb(&self) -> &str { + self.verb + } + + /// Pick a new random verb (called when a new run starts). + pub fn new_verb(&mut self) { + self.verb = pick_verb(); + self.frame = 0; + } + + /// Switch to tool spinner mode. + pub fn set_tool_mode(&mut self) { + self.kind = SpinnerKind::Tool; + self.frame = 0; + } + + /// Switch back to neural pulse mode. + pub fn set_neural_mode(&mut self) { + self.kind = SpinnerKind::Neural; + self.frame = 0; + } + + fn frames(&self) -> &'static [char] { + match self.kind { + SpinnerKind::Neural => NEURAL_PULSE, + SpinnerKind::Tool => TOOL_FRAMES, + } + } +} + +fn pick_verb() -> &'static str { + VERBS[fastrand::usize(..VERBS.len())] } #[cfg(test)] @@ -38,10 +124,33 @@ mod tests { assert_ne!(first, second); // Cycle through all frames - for _ in 0..FRAMES.len() - 1 { + for _ in 0..NEURAL_PULSE.len() - 1 { s.tick(); } - // Back to the start assert_eq!(s.current(), first); } + + #[test] + fn spinner_has_verb() { + let s = Spinner::new(); + assert!(!s.verb().is_empty()); + assert!(VERBS.contains(&s.verb())); + } + + #[test] + fn new_verb_picks_valid_verb() { + let mut s = Spinner::new(); + for _ in 0..10 { + s.new_verb(); + assert!(!s.verb().is_empty()); + assert!(VERBS.contains(&s.verb())); + } + } + + #[test] + fn tool_mode_uses_braille() { + let mut s = Spinner::new(); + s.set_tool_mode(); + assert!(TOOL_FRAMES.contains(&s.current())); + } }