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
52pub fn spinner_frame(tick: u64) -> &'static str {
54 const FRAMES: &[&str] = &["⣠", "⡴", "⠞", "⠋", "⠙", "⠳", "⢦", "⣄"];
55 FRAMES[(tick / 3) as usize % FRAMES.len()]
56}
57
58pub fn title_spinner_frame(tick: u64) -> &'static str {
60 const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61 FRAMES[(tick / 4) as usize % FRAMES.len()]
62}
63
64pub fn title_working_glyph() -> &'static str {
66 "•"
67}
68
69pub fn thinking_frame(tick: u64) -> &'static str {
71 spinner_frame(tick)
72}
73
74pub fn responding_frame(tick: u64) -> &'static str {
76 spinner_frame(tick)
77}
78
79pub fn tool_frame(tick: u64) -> &'static str {
81 spinner_frame(tick)
82}
83
84pub fn static_working_glyph() -> &'static str {
86 "•"
87}
88
89pub fn queued_glyph() -> &'static str {
91 "◌"
92}
93
94pub 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}