Skip to main content

wisp/components/
progress_indicator.rs

1use tui::BRAILLE_FRAMES as FRAMES;
2use tui::{FitOptions, Frame, 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 current context usage against the model limit",
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.checked_sub(1).and_then(|i| MESSAGES.get(i)).copied().unwrap_or("Working...")
54    }
55
56    /// Advance the animation state. Call this on tick events.
57    pub fn on_tick(&mut self) {
58        if self.is_active() {
59            self.tick = self.tick.wrapping_add(1);
60        }
61    }
62}
63
64impl ProgressIndicator {
65    pub fn render(&self, context: &ViewContext) -> Frame {
66        if !self.is_active() {
67            return Frame::empty();
68        }
69
70        let frame_char = FRAMES[self.tick as usize % FRAMES.len()];
71        let mut line = Line::default();
72        line.push_styled(frame_char.to_string(), context.theme.info());
73        line.push_styled(format!(" {}", self.current_message()), context.theme.text_secondary());
74        line.push_with_style("  (esc to interrupt)".to_string(), Style::fg(context.theme.muted()).italic());
75
76        let lines = vec![Line::default(), line, Line::default()];
77        Frame::new(lines).fit(context.size.width, FitOptions::wrap())
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn ctx() -> ViewContext {
86        // Wide enough for the longest tip + " (esc to interrupt)" suffix to fit on one row.
87        ViewContext::new((200, 24))
88    }
89
90    #[test]
91    fn renders_nothing_when_idle() {
92        let indicator = ProgressIndicator::default();
93        assert!(indicator.render(&ctx()).lines().is_empty());
94    }
95
96    #[test]
97    fn renders_nothing_when_all_complete_and_not_waiting() {
98        let mut indicator = ProgressIndicator::default();
99        indicator.update(3, 3, false);
100        assert!(indicator.render(&ctx()).lines().is_empty());
101    }
102
103    #[test]
104    fn renders_when_tools_running() {
105        let mut indicator = ProgressIndicator::default();
106        indicator.update(1, 3, false);
107        let frame = indicator.render(&ctx());
108        let lines = frame.lines();
109        assert_eq!(lines.len(), 3);
110        let text = lines[1].plain_text();
111        assert!(text.contains("esc to interrupt"));
112    }
113
114    #[test]
115    fn renders_when_waiting_for_response_without_tools() {
116        let mut indicator = ProgressIndicator::default();
117        indicator.update(0, 0, true);
118        let frame = indicator.render(&ctx());
119        let lines = frame.lines();
120        assert_eq!(lines.len(), 3);
121        let text = lines[1].plain_text();
122        assert!(text.contains("esc to interrupt"));
123    }
124
125    #[test]
126    fn spinner_animates_with_tick() {
127        let mut a = ProgressIndicator::default();
128        a.update(0, 1, false);
129        let mut b = ProgressIndicator::default();
130        b.update(0, 1, false);
131        b.set_tick(1);
132        let text_a = a.render(&ctx()).lines()[1].plain_text();
133        let text_b = b.render(&ctx()).lines()[1].plain_text();
134        assert_ne!(text_a, text_b);
135    }
136
137    #[test]
138    fn on_tick_advances_when_running() {
139        let mut indicator = ProgressIndicator::default();
140        indicator.update(1, 3, false);
141        indicator.on_tick();
142        let frame = indicator.render(&ctx());
143        assert!(!frame.lines().is_empty());
144    }
145
146    #[test]
147    fn on_tick_advances_when_waiting() {
148        let mut indicator = ProgressIndicator::default();
149        indicator.update(0, 0, true);
150        let frame_before = indicator.tick;
151        indicator.on_tick();
152        assert_ne!(indicator.tick, frame_before);
153    }
154
155    #[test]
156    fn on_tick_noop_when_idle() {
157        let mut indicator = ProgressIndicator::default();
158        indicator.update(3, 3, false);
159        indicator.on_tick();
160        assert!(indicator.render(&ctx()).lines().is_empty());
161    }
162
163    #[test]
164    fn first_turn_shows_first_tip() {
165        let mut indicator = ProgressIndicator::default();
166        indicator.update(0, 0, true);
167        indicator.set_turn_count(1);
168        let frame = indicator.render(&ctx());
169        let text = frame.lines()[1].plain_text();
170        assert!(text.contains(MESSAGES[0]));
171    }
172
173    #[test]
174    fn tip_advances_each_turn() {
175        let mut indicator = ProgressIndicator::default();
176        // First turn: inactive → active
177        indicator.update(0, 0, true);
178        assert_eq!(indicator.turn_count, 1);
179        let tip_0 = indicator.render(&ctx()).lines()[1].plain_text();
180
181        // Go inactive
182        indicator.update(0, 0, false);
183
184        // Second turn: inactive → active
185        indicator.update(0, 0, true);
186        assert_eq!(indicator.turn_count, 2);
187        let tip_1 = indicator.render(&ctx()).lines()[1].plain_text();
188
189        assert_ne!(tip_0, tip_1);
190        assert!(tip_0.contains(MESSAGES[0]));
191        assert!(tip_1.contains(MESSAGES[1]));
192    }
193
194    #[test]
195    fn shows_working_after_tips_exhausted() {
196        let mut indicator = ProgressIndicator::default();
197        indicator.update(0, 0, true);
198        indicator.set_turn_count(MESSAGES.len() + 1);
199        let text = indicator.render(&ctx()).lines()[1].plain_text();
200        assert!(text.contains("Working..."));
201    }
202
203    #[test]
204    fn reset_restarts_tips() {
205        let mut indicator = ProgressIndicator::default();
206        indicator.update(0, 0, true);
207        assert_eq!(indicator.turn_count, 1);
208
209        let indicator = ProgressIndicator::default();
210        assert_eq!(indicator.turn_count, 0);
211    }
212
213    #[test]
214    fn staying_active_does_not_advance_tip() {
215        let mut indicator = ProgressIndicator::default();
216        indicator.update(0, 0, true);
217        assert_eq!(indicator.turn_count, 1);
218
219        // Multiple updates while staying active
220        indicator.update(1, 3, true);
221        indicator.update(2, 3, true);
222        indicator.update(3, 3, true);
223        // Still waiting_for_response so still active
224        assert_eq!(indicator.turn_count, 1);
225    }
226}