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.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) -> Vec<Line> {
66        if !self.is_active() {
67            return vec![];
68        }
69
70        let frame = FRAMES[self.tick as usize % FRAMES.len()];
71        let mut line = Line::default();
72        line.push_styled(frame.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        vec![line]
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn ctx() -> ViewContext {
85        ViewContext::new((80, 24))
86    }
87
88    #[test]
89    fn renders_nothing_when_idle() {
90        let indicator = ProgressIndicator::default();
91        assert!(indicator.render(&ctx()).is_empty());
92    }
93
94    #[test]
95    fn renders_nothing_when_all_complete_and_not_waiting() {
96        let mut indicator = ProgressIndicator::default();
97        indicator.update(3, 3, false);
98        assert!(indicator.render(&ctx()).is_empty());
99    }
100
101    #[test]
102    fn renders_when_tools_running() {
103        let mut indicator = ProgressIndicator::default();
104        indicator.update(1, 3, false);
105        let lines = indicator.render(&ctx());
106        assert_eq!(lines.len(), 1);
107        let text = lines[0].plain_text();
108        assert!(text.contains("esc to interrupt"));
109    }
110
111    #[test]
112    fn renders_when_waiting_for_response_without_tools() {
113        let mut indicator = ProgressIndicator::default();
114        indicator.update(0, 0, true);
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 spinner_animates_with_tick() {
123        let mut a = ProgressIndicator::default();
124        a.update(0, 1, false);
125        let mut b = ProgressIndicator::default();
126        b.update(0, 1, false);
127        b.set_tick(1);
128        let text_a = a.render(&ctx())[0].plain_text();
129        let text_b = b.render(&ctx())[0].plain_text();
130        assert_ne!(text_a, text_b);
131    }
132
133    #[test]
134    fn on_tick_advances_when_running() {
135        let mut indicator = ProgressIndicator::default();
136        indicator.update(1, 3, false);
137        indicator.on_tick();
138        let lines = indicator.render(&ctx());
139        assert!(!lines.is_empty());
140    }
141
142    #[test]
143    fn on_tick_advances_when_waiting() {
144        let mut indicator = ProgressIndicator::default();
145        indicator.update(0, 0, true);
146        let frame_before = indicator.tick;
147        indicator.on_tick();
148        assert_ne!(indicator.tick, frame_before);
149    }
150
151    #[test]
152    fn on_tick_noop_when_idle() {
153        let mut indicator = ProgressIndicator::default();
154        indicator.update(3, 3, false);
155        indicator.on_tick();
156        assert!(indicator.render(&ctx()).is_empty());
157    }
158
159    #[test]
160    fn first_turn_shows_first_tip() {
161        let mut indicator = ProgressIndicator::default();
162        indicator.update(0, 0, true);
163        indicator.set_turn_count(1);
164        let lines = indicator.render(&ctx());
165        let text = lines[0].plain_text();
166        assert!(text.contains(MESSAGES[0]));
167    }
168
169    #[test]
170    fn tip_advances_each_turn() {
171        let mut indicator = ProgressIndicator::default();
172        // First turn: inactive → active
173        indicator.update(0, 0, true);
174        assert_eq!(indicator.turn_count, 1);
175        let tip_0 = indicator.render(&ctx())[0].plain_text();
176
177        // Go inactive
178        indicator.update(0, 0, false);
179
180        // Second turn: inactive → active
181        indicator.update(0, 0, true);
182        assert_eq!(indicator.turn_count, 2);
183        let tip_1 = indicator.render(&ctx())[0].plain_text();
184
185        assert_ne!(tip_0, tip_1);
186        assert!(tip_0.contains(MESSAGES[0]));
187        assert!(tip_1.contains(MESSAGES[1]));
188    }
189
190    #[test]
191    fn shows_working_after_tips_exhausted() {
192        let mut indicator = ProgressIndicator::default();
193        indicator.update(0, 0, true);
194        indicator.set_turn_count(MESSAGES.len() + 1);
195        let text = indicator.render(&ctx())[0].plain_text();
196        assert!(text.contains("Working..."));
197    }
198
199    #[test]
200    fn reset_restarts_tips() {
201        let mut indicator = ProgressIndicator::default();
202        indicator.update(0, 0, true);
203        assert_eq!(indicator.turn_count, 1);
204
205        let indicator = ProgressIndicator::default();
206        assert_eq!(indicator.turn_count, 0);
207    }
208
209    #[test]
210    fn staying_active_does_not_advance_tip() {
211        let mut indicator = ProgressIndicator::default();
212        indicator.update(0, 0, true);
213        assert_eq!(indicator.turn_count, 1);
214
215        // Multiple updates while staying active
216        indicator.update(1, 3, true);
217        indicator.update(2, 3, true);
218        indicator.update(3, 3, true);
219        // Still waiting_for_response so still active
220        assert_eq!(indicator.turn_count, 1);
221    }
222}