Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/arcan-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dirs = "6"

# Platform
libc = "0.2"
fastrand = "2"

# Domain Types
aios-protocol = { workspace = true }
Expand Down
3 changes: 3 additions & 0 deletions crates/arcan-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions crates/arcan-tui/src/e2e_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/arcan-tui/src/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Expand Down
6 changes: 6 additions & 0 deletions crates/arcan-tui/src/models/state.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -66,6 +67,9 @@ pub struct AppState {

/// Session cost remaining in USD, updated after each run.
pub cost_remaining: Option<f64>,

/// Animated spinner for the thinking indicator.
pub spinner: Spinner,
}

impl Default for AppState {
Expand All @@ -92,6 +96,7 @@ impl AppState {
context_pressure_pct: 0.0,
autonomic_ruling: None,
cost_remaining: None,
spinner: Spinner::new(),
}
}

Expand Down Expand Up @@ -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() {
Expand Down
6 changes: 4 additions & 2 deletions crates/arcan-tui/src/widgets/chat_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)]));
}
Expand Down
131 changes: 120 additions & 11 deletions crates/arcan-tui/src/widgets/spinner.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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()));
}
}
Loading