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/// Classic braille spinner, but slowed slightly so it feels less jittery.
53pub fn spinner_frame(tick: u64) -> &'static str {
54    const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
55    FRAMES[(tick / 3) as usize % FRAMES.len()]
56}
57
58/// A softer, more directional runner used for "waiting for response" states.
59pub 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}