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 runner_frame(tick: u64) -> &'static str {
60 const FRAMES: &[&str] = &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"];
61 FRAMES[(tick / 3) as usize % FRAMES.len()]
62}
63
64pub fn waiting_badge(tick: u64, level: AnimationLevel) -> String {
65 match level {
66 AnimationLevel::None => String::new(),
67 AnimationLevel::Spinner => spinner_frame(tick).to_string(),
68 AnimationLevel::Minimal => runner_frame(tick).to_string(),
69 }
70}
71
72pub fn activity_label(
73 state: AnimationState,
74 tick: u64,
75 level: AnimationLevel,
76 surface: ActivitySurface,
77) -> String {
78 match state {
79 AnimationState::Idle => String::new(),
80 AnimationState::WaitingForResponse => match level {
81 AnimationLevel::None => "waiting".into(),
82 AnimationLevel::Spinner => format!("{} waiting", spinner_frame(tick)),
83 AnimationLevel::Minimal => match surface {
84 ActivitySurface::TopBar => {
85 format!("{} waiting for response", waiting_badge(tick, level))
86 }
87 ActivitySurface::Chat => {
88 format!("{} waiting", waiting_badge(tick, level))
89 }
90 ActivitySurface::Editor => String::new(),
91 },
92 },
93 AnimationState::Thinking => match level {
94 AnimationLevel::None => "thinking".into(),
95 AnimationLevel::Spinner => format!("{} thinking", spinner_frame(tick)),
96 AnimationLevel::Minimal => {
97 format!("{} thinking", waiting_badge(tick, level))
98 }
99 },
100 AnimationState::ExecutingTools { active_tools } => match level {
101 AnimationLevel::None => {
102 format!(
103 "working · {active_tools} tool{}",
104 if active_tools == 1 { "" } else { "s" }
105 )
106 }
107 AnimationLevel::Spinner | AnimationLevel::Minimal => format!(
108 "{} working · {active_tools} tool{}",
109 spinner_frame(tick),
110 if active_tools == 1 { "" } else { "s" }
111 ),
112 },
113 AnimationState::Streaming => match surface {
114 ActivitySurface::TopBar => match level {
115 AnimationLevel::None => "responding".into(),
116 AnimationLevel::Spinner | AnimationLevel::Minimal => {
117 format!("{} responding", spinner_frame(tick))
118 }
119 },
120 ActivitySurface::Chat => match level {
121 AnimationLevel::None => "responding".into(),
122 AnimationLevel::Spinner | AnimationLevel::Minimal => {
123 format!("{} responding", spinner_frame(tick))
124 }
125 },
126 ActivitySurface::Editor => String::new(),
127 },
128 AnimationState::Queued => match level {
129 AnimationLevel::None => "queued".into(),
130 AnimationLevel::Spinner => format!("{} queued", spinner_frame(tick)),
131 AnimationLevel::Minimal => {
132 format!("{} queued", waiting_badge(tick, level))
133 }
134 },
135 }
136}
137
138pub fn format_elapsed(duration: Duration) -> String {
139 let secs = duration.as_secs();
140 if secs >= 60 {
141 format!("{}m{:02}s", secs / 60, secs % 60)
142 } else {
143 format!("{}s", secs)
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn elapsed_formats_seconds_and_minutes() {
153 assert_eq!(format_elapsed(Duration::from_secs(7)), "7s");
154 assert_eq!(format_elapsed(Duration::from_secs(75)), "1m15s");
155 }
156}