Skip to main content

imp_tui/
animation.rs

1use std::time::Duration;
2
3use imp_core::config::AnimationLevel;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum AnimationState {
7    #[default]
8    Idle,
9    WaitingForResponse,
10    Thinking,
11    ExecutingTools {
12        active_tools: u32,
13    },
14    Streaming,
15    Queued,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ActivitySurface {
20    TopBar,
21    Editor,
22    Chat,
23}
24
25impl AnimationState {
26    pub fn from_streaming(
27        is_streaming: bool,
28        has_content: bool,
29        has_tools: bool,
30        active_tools: u32,
31        has_queued: bool,
32    ) -> Self {
33        if !is_streaming {
34            return Self::Idle;
35        }
36        if has_queued {
37            return Self::Queued;
38        }
39        if active_tools > 0 {
40            return Self::ExecutingTools { active_tools };
41        }
42        if !has_content && has_tools {
43            return Self::Thinking;
44        }
45        if !has_content {
46            return Self::WaitingForResponse;
47        }
48        Self::Streaming
49    }
50}
51
52/// Main working/thinking spinner.
53pub fn spinner_frame(tick: u64) -> &'static str {
54    const FRAMES: &[&str] = &["⣠", "⡴", "⠞", "⠋", "⠙", "⠳", "⢦", "⣄"];
55    FRAMES[(tick / 3) as usize % FRAMES.len()]
56}
57
58/// Braille dot spinner for global agent work in the terminal title.
59pub fn title_spinner_frame(tick: u64) -> &'static str {
60    const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61    FRAMES[(tick / 4) as usize % FRAMES.len()]
62}
63
64/// Static title glyph for active work when animated motion is disabled.
65pub fn title_working_glyph() -> &'static str {
66    "•"
67}
68
69/// Main working/thinking spinner.
70pub fn thinking_frame(tick: u64) -> &'static str {
71    spinner_frame(tick)
72}
73
74/// Main working/thinking spinner used while streaming responses.
75pub fn responding_frame(tick: u64) -> &'static str {
76    spinner_frame(tick)
77}
78
79/// Main working/thinking spinner used for concrete tool execution.
80pub fn tool_frame(tick: u64) -> &'static str {
81    spinner_frame(tick)
82}
83
84/// Static glyph for running states when animated motion is disabled.
85pub fn static_working_glyph() -> &'static str {
86    "•"
87}
88
89/// Static glyph for queued work.
90pub fn queued_glyph() -> &'static str {
91    "◌"
92}
93
94/// Backward-compatible alias for the response runner.
95pub fn runner_frame(tick: u64) -> &'static str {
96    responding_frame(tick)
97}
98
99pub fn waiting_badge(tick: u64, level: AnimationLevel) -> String {
100    match level {
101        AnimationLevel::None => static_working_glyph().to_string(),
102        AnimationLevel::Spinner | AnimationLevel::Minimal => thinking_frame(tick).to_string(),
103    }
104}
105
106pub fn activity_label(
107    state: AnimationState,
108    tick: u64,
109    level: AnimationLevel,
110    surface: ActivitySurface,
111) -> String {
112    let animated = level != AnimationLevel::None;
113    match state {
114        AnimationState::Idle => String::new(),
115        AnimationState::WaitingForResponse => {
116            let glyph = if animated {
117                thinking_frame(tick)
118            } else {
119                static_working_glyph()
120            };
121            match surface {
122                ActivitySurface::TopBar => format!("{glyph} waiting for response"),
123                ActivitySurface::Chat => format!("{glyph} waiting"),
124                ActivitySurface::Editor => String::new(),
125            }
126        }
127        AnimationState::Thinking => {
128            let glyph = if animated {
129                thinking_frame(tick)
130            } else {
131                static_working_glyph()
132            };
133            format!("{glyph} thinking")
134        }
135        AnimationState::ExecutingTools { active_tools } => {
136            let glyph = if animated {
137                tool_frame(tick)
138            } else {
139                static_working_glyph()
140            };
141            format!(
142                "{glyph} working · {active_tools} tool{}",
143                if active_tools == 1 { "" } else { "s" }
144            )
145        }
146        AnimationState::Streaming => match surface {
147            ActivitySurface::TopBar | ActivitySurface::Chat => {
148                let glyph = if animated {
149                    responding_frame(tick)
150                } else {
151                    static_working_glyph()
152                };
153                format!("{glyph} responding")
154            }
155            ActivitySurface::Editor => String::new(),
156        },
157        AnimationState::Queued => format!("{} queued", queued_glyph()),
158    }
159}
160
161pub fn format_elapsed(duration: Duration) -> String {
162    let secs = duration.as_secs();
163    if secs >= 60 {
164        format!("{}m{:02}s", secs / 60, secs % 60)
165    } else {
166        format!("{}s", secs)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn elapsed_formats_seconds_and_minutes() {
176        assert_eq!(format_elapsed(Duration::from_secs(7)), "7s");
177        assert_eq!(format_elapsed(Duration::from_secs(75)), "1m15s");
178    }
179
180    #[test]
181    fn title_spinner_uses_braille_dot_cycle() {
182        assert_eq!(title_spinner_frame(0), "⠋");
183        assert_eq!(title_spinner_frame(3), "⠋");
184        assert_eq!(title_spinner_frame(4), "⠙");
185        assert_eq!(title_spinner_frame(8), "⠹");
186        assert_eq!(title_spinner_frame(16), "⠼");
187        assert_eq!(title_spinner_frame(36), "⠏");
188        assert_eq!(title_spinner_frame(40), "⠋");
189    }
190
191    #[test]
192    fn activity_labels_use_state_specific_glyphs() {
193        assert_eq!(
194            activity_label(
195                AnimationState::Thinking,
196                0,
197                AnimationLevel::Minimal,
198                ActivitySurface::Chat,
199            ),
200            "⣠ thinking"
201        );
202        assert_eq!(
203            activity_label(
204                AnimationState::Streaming,
205                0,
206                AnimationLevel::Minimal,
207                ActivitySurface::Chat,
208            ),
209            "⣠ responding"
210        );
211        assert_eq!(
212            activity_label(
213                AnimationState::ExecutingTools { active_tools: 2 },
214                0,
215                AnimationLevel::Minimal,
216                ActivitySurface::Chat,
217            ),
218            "⣠ working · 2 tools"
219        );
220        assert_eq!(
221            activity_label(
222                AnimationState::Queued,
223                0,
224                AnimationLevel::None,
225                ActivitySurface::Chat,
226            ),
227            "◌ queued"
228        );
229    }
230
231    #[test]
232    fn activity_labels_keep_static_glyphs_when_motion_disabled() {
233        assert_eq!(
234            activity_label(
235                AnimationState::Thinking,
236                99,
237                AnimationLevel::None,
238                ActivitySurface::Chat,
239            ),
240            "• thinking"
241        );
242        assert_eq!(
243            activity_label(
244                AnimationState::Streaming,
245                99,
246                AnimationLevel::None,
247                ActivitySurface::Chat,
248            ),
249            "• responding"
250        );
251    }
252}