use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::display_format::{display_width, truncate_display_width};
use super::theme::{self, ActivityPhase, TuiPanel};
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_MS: u64 = 80;
const TRAIL_CHAR: &str = "─";
pub fn render_activity_strip(
state: &super::transcript::TranscriptState,
width: u16,
) -> Line<'static> {
let label = state.activity_banner_label();
let since = state.activity_anim_since();
let tick = since.elapsed().as_millis() as u64 / SPINNER_MS;
let phase = activity_phase(state);
build_strip_line(&label, width as usize, tick, phase)
}
fn activity_phase(state: &super::transcript::TranscriptState) -> ActivityPhase {
if state.is_thinking() {
ActivityPhase::Thinking
} else if state.is_tools_active() {
ActivityPhase::Tools
} else if state.streaming {
ActivityPhase::Streaming
} else {
ActivityPhase::Other
}
}
fn build_strip_line(label: &str, width: usize, tick: u64, phase: ActivityPhase) -> Line<'static> {
let width = width.max(8);
let spinner = SPINNER_FRAMES[tick as usize % SPINNER_FRAMES.len()];
let prefix = format!(" {spinner} ");
let prefix_w = 3usize;
let label_max = width.saturating_sub(prefix_w + 2);
let label_trimmed = truncate_display_width(label, label_max).to_string();
let label_w = display_width(&label_trimmed);
let fill_start_w = prefix_w + label_w + 1; let fill_char_w = width.saturating_sub(fill_start_w);
let trail = if fill_char_w > 0 {
format!(" {}", TRAIL_CHAR.repeat(fill_char_w))
} else {
String::new()
};
let phase_color = theme::activity_phase_color(phase);
let surface = theme::panel(TuiPanel::Activity).surface(false);
let bg = surface.bg.unwrap_or(ratatui::style::Color::Reset);
let dim_color = theme::panel(TuiPanel::Activity)
.hint()
.fg
.unwrap_or(ratatui::style::Color::DarkGray);
Line::from(vec![
Span::styled(
prefix,
Style::default()
.fg(phase_color)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(label_trimmed, Style::default().fg(phase_color).bg(bg)),
Span::styled(trail, Style::default().fg(dim_color).bg(bg)),
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::events::Event;
use crate::tui::transcript::{TranscriptState, apply_event};
fn strip_plain(label: &str, width: usize, tick: u64) -> String {
let line = build_strip_line(label, width, tick, ActivityPhase::Thinking);
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn strip_fits_width() {
crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
let text = strip_plain("推理中 · THK", 40, 0);
assert!(display_width(&text) <= 40, "width={}", display_width(&text));
}
#[test]
fn strip_fits_narrow_width() {
crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
let text = strip_plain("生成回复 · AI", 18, 0);
assert!(display_width(&text) <= 18, "width={}", display_width(&text));
}
#[test]
fn spinner_rotates_between_ticks() {
crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
let a = strip_plain("tools", 30, 0);
let b = strip_plain("tools", 30, 3);
assert_ne!(&a[..4], &b[..4]);
}
#[test]
fn strip_contains_no_marquee_chars() {
crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
let text = strip_plain("生成回复 · AI", 48, 0);
assert!(!text.contains('}'), "old marquee tile should not appear");
assert!(!text.contains('●'), "old dot style should not appear");
}
#[test]
fn strip_label_reflects_thinking() {
crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
let mut state = TranscriptState::default();
state.begin_turn("test".into());
apply_event(
&mut state,
Event::ThinkingDelta {
index: 0,
content: "plan".to_string(),
},
);
assert!(state.activity_banner_label().contains("THK"));
}
#[test]
fn anim_anchor_stable_during_streaming() {
let mut state = TranscriptState::default();
state.begin_turn("test".into());
state.streaming = true;
let t0 = state.activity_anim_since();
let t1 = state.activity_anim_since();
assert_eq!(t0, t1);
}
}