Skip to main content

wisp/components/
progress_indicator.rs

1use tui::BRAILLE_FRAMES as FRAMES;
2use tui::{Line, Style, ViewContext};
3
4const MESSAGES: &[&str] = &[
5    "Tip: Hit Tab to adjust reasoning level (off → low → medium → high)",
6    "Tip: Hit Shift+Tab to cycle through agent modes",
7    "Tip: Press @ to attach files to your prompt",
8    "Tip: Type / to open the command picker",
9    "Tip: Use /resume to pick up a previous session",
10    "Tip: Wisp supports custom themes — drop a .tmTheme in ~/.wisp/themes/",
11    "Tip: Open /settings to change your model, theme, or view MCP server status",
12    "Tip: The context gauge in the status bar shows how much context window remains",
13];
14
15/// Renders a spinner with "(esc to interrupt)" when the agent is busy.
16/// Visible whenever we're waiting for a response OR tools are actively running.
17#[derive(Default)]
18pub struct ProgressIndicator {
19    tools_running: bool,
20    waiting_for_response: bool,
21    tick: u16,
22    was_active: bool,
23    turn_count: usize,
24}
25
26impl ProgressIndicator {
27    pub fn update(&mut self, completed: usize, total: usize, waiting_for_response: bool) {
28        let previously_active = self.was_active;
29        self.tools_running = total > 0 && completed < total;
30        self.waiting_for_response = waiting_for_response;
31        let now_active = self.is_active();
32        self.was_active = now_active;
33        if !previously_active && now_active {
34            self.turn_count += 1;
35        }
36    }
37
38    #[cfg(test)]
39    pub fn set_tick(&mut self, tick: u16) {
40        self.tick = tick;
41    }
42
43    #[cfg(test)]
44    pub fn set_turn_count(&mut self, count: usize) {
45        self.turn_count = count;
46    }
47
48    fn is_active(&self) -> bool {
49        self.tools_running || self.waiting_for_response
50    }
51
52    fn current_message(&self) -> &'static str {
53        self.turn_count
54            .checked_sub(1)
55            .and_then(|i| MESSAGES.get(i))
56            .copied()
57            .unwrap_or("Working...")
58    }
59
60    /// Advance the animation state. Call this on tick events.
61    pub fn on_tick(&mut self) {
62        if self.is_active() {
63            self.tick = self.tick.wrapping_add(1);
64        }
65    }
66}
67
68impl ProgressIndicator {
69    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
70        if !self.is_active() {
71            return vec![];
72        }
73
74        let frame = FRAMES[self.tick as usize % FRAMES.len()];
75        let mut line = Line::default();
76        line.push_styled(frame.to_string(), context.theme.info());
77        line.push_styled(
78            format!(" {}", self.current_message()),
79            context.theme.text_secondary(),
80        );
81        line.push_with_style(
82            "  (esc to interrupt)".to_string(),
83            Style::fg(context.theme.muted()).italic(),
84        );
85
86        vec![line]
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn ctx() -> ViewContext {
95        ViewContext::new((80, 24))
96    }
97
98    #[test]
99    fn renders_nothing_when_idle() {
100        let indicator = ProgressIndicator::default();
101        assert!(indicator.render(&ctx()).is_empty());
102    }
103
104    #[test]
105    fn renders_nothing_when_all_complete_and_not_waiting() {
106        let mut indicator = ProgressIndicator::default();
107        indicator.update(3, 3, false);
108        assert!(indicator.render(&ctx()).is_empty());
109    }
110
111    #[test]
112    fn renders_when_tools_running() {
113        let mut indicator = ProgressIndicator::default();
114        indicator.update(1, 3, false);
115        let lines = indicator.render(&ctx());
116        assert_eq!(lines.len(), 1);
117        let text = lines[0].plain_text();
118        assert!(text.contains("esc to interrupt"));
119    }
120
121    #[test]
122    fn renders_when_waiting_for_response_without_tools() {
123        let mut indicator = ProgressIndicator::default();
124        indicator.update(0, 0, true);
125        let lines = indicator.render(&ctx());
126        assert_eq!(lines.len(), 1);
127        let text = lines[0].plain_text();
128        assert!(text.contains("esc to interrupt"));
129    }
130
131    #[test]
132    fn spinner_animates_with_tick() {
133        let mut a = ProgressIndicator::default();
134        a.update(0, 1, false);
135        let mut b = ProgressIndicator::default();
136        b.update(0, 1, false);
137        b.set_tick(1);
138        let text_a = a.render(&ctx())[0].plain_text();
139        let text_b = b.render(&ctx())[0].plain_text();
140        assert_ne!(text_a, text_b);
141    }
142
143    #[test]
144    fn on_tick_advances_when_running() {
145        let mut indicator = ProgressIndicator::default();
146        indicator.update(1, 3, false);
147        indicator.on_tick();
148        let lines = indicator.render(&ctx());
149        assert!(!lines.is_empty());
150    }
151
152    #[test]
153    fn on_tick_advances_when_waiting() {
154        let mut indicator = ProgressIndicator::default();
155        indicator.update(0, 0, true);
156        let frame_before = indicator.tick;
157        indicator.on_tick();
158        assert_ne!(indicator.tick, frame_before);
159    }
160
161    #[test]
162    fn on_tick_noop_when_idle() {
163        let mut indicator = ProgressIndicator::default();
164        indicator.update(3, 3, false);
165        indicator.on_tick();
166        assert!(indicator.render(&ctx()).is_empty());
167    }
168
169    #[test]
170    fn first_turn_shows_first_tip() {
171        let mut indicator = ProgressIndicator::default();
172        indicator.update(0, 0, true);
173        indicator.set_turn_count(1);
174        let lines = indicator.render(&ctx());
175        let text = lines[0].plain_text();
176        assert!(text.contains(MESSAGES[0]));
177    }
178
179    #[test]
180    fn tip_advances_each_turn() {
181        let mut indicator = ProgressIndicator::default();
182        // First turn: inactive → active
183        indicator.update(0, 0, true);
184        assert_eq!(indicator.turn_count, 1);
185        let tip_0 = indicator.render(&ctx())[0].plain_text();
186
187        // Go inactive
188        indicator.update(0, 0, false);
189
190        // Second turn: inactive → active
191        indicator.update(0, 0, true);
192        assert_eq!(indicator.turn_count, 2);
193        let tip_1 = indicator.render(&ctx())[0].plain_text();
194
195        assert_ne!(tip_0, tip_1);
196        assert!(tip_0.contains(MESSAGES[0]));
197        assert!(tip_1.contains(MESSAGES[1]));
198    }
199
200    #[test]
201    fn shows_working_after_tips_exhausted() {
202        let mut indicator = ProgressIndicator::default();
203        indicator.update(0, 0, true);
204        indicator.set_turn_count(MESSAGES.len() + 1);
205        let text = indicator.render(&ctx())[0].plain_text();
206        assert!(text.contains("Working..."));
207    }
208
209    #[test]
210    fn reset_restarts_tips() {
211        let mut indicator = ProgressIndicator::default();
212        indicator.update(0, 0, true);
213        assert_eq!(indicator.turn_count, 1);
214
215        let indicator = ProgressIndicator::default();
216        assert_eq!(indicator.turn_count, 0);
217    }
218
219    #[test]
220    fn staying_active_does_not_advance_tip() {
221        let mut indicator = ProgressIndicator::default();
222        indicator.update(0, 0, true);
223        assert_eq!(indicator.turn_count, 1);
224
225        // Multiple updates while staying active
226        indicator.update(1, 3, true);
227        indicator.update(2, 3, true);
228        indicator.update(3, 3, true);
229        // Still waiting_for_response so still active
230        assert_eq!(indicator.turn_count, 1);
231    }
232}