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 agents defined in your settings.json file",
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(Clone, Copy, Debug, Default, Eq, PartialEq)]
18pub enum WorkspaceProgress {
19    #[default]
20    None,
21    Moving,
22    LoadingSession,
23}
24
25#[derive(Default)]
26pub struct ProgressIndicator {
27    tools_running: bool,
28    waiting_for_response: bool,
29    workspace_progress: WorkspaceProgress,
30    tick: u16,
31    was_active: bool,
32    turn_count: usize,
33}
34
35impl ProgressIndicator {
36    pub fn update(
37        &mut self,
38        completed: usize,
39        total: usize,
40        waiting_for_response: bool,
41        workspace_progress: WorkspaceProgress,
42    ) {
43        let previously_active = self.was_active;
44        self.tools_running = total > 0 && completed < total;
45        self.waiting_for_response = waiting_for_response;
46        self.workspace_progress = workspace_progress;
47        let now_active = self.is_active();
48        self.was_active = now_active;
49        if !previously_active && now_active {
50            self.turn_count += 1;
51        }
52    }
53
54    #[cfg(test)]
55    pub fn set_tick(&mut self, tick: u16) {
56        self.tick = tick;
57    }
58
59    #[cfg(test)]
60    pub fn set_turn_count(&mut self, count: usize) {
61        self.turn_count = count;
62    }
63
64    fn is_active(&self) -> bool {
65        self.tools_running || self.waiting_for_response || self.workspace_progress != WorkspaceProgress::None
66    }
67
68    fn current_message(&self) -> &'static str {
69        match self.workspace_progress {
70            WorkspaceProgress::Moving => "Moving workspace...",
71            WorkspaceProgress::LoadingSession => "Loading session in new workspace...",
72            WorkspaceProgress::None => {
73                self.turn_count.checked_sub(1).and_then(|i| MESSAGES.get(i)).copied().unwrap_or("Working...")
74            }
75        }
76    }
77
78    fn agent_is_busy(&self) -> bool {
79        self.tools_running || self.waiting_for_response
80    }
81
82    /// Advance the animation state. Call this on tick events.
83    pub fn on_tick(&mut self) {
84        if self.is_active() {
85            self.tick = self.tick.wrapping_add(1);
86        }
87    }
88}
89
90impl ProgressIndicator {
91    pub fn render(&self, context: &ViewContext) -> Frame {
92        if !self.is_active() {
93            return Frame::empty();
94        }
95
96        let frame_char = FRAMES[self.tick as usize % FRAMES.len()];
97        let mut line = Line::default();
98        line.push_styled(frame_char.to_string(), context.theme.info());
99        line.push_styled(format!(" {}", self.current_message()), context.theme.text_secondary());
100        if self.agent_is_busy() {
101            line.push_with_style("  (esc to interrupt)".to_string(), Style::fg(context.theme.muted()).italic());
102        }
103
104        let lines = vec![Line::default(), line, Line::default()];
105        Frame::new(lines).fit(context.size.width, FitOptions::wrap())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn ctx() -> ViewContext {
114        // Wide enough for the longest tip + " (esc to interrupt)" suffix to fit on one row.
115        ViewContext::new((200, 24))
116    }
117
118    #[test]
119    fn renders_nothing_when_idle() {
120        let indicator = ProgressIndicator::default();
121        assert!(indicator.render(&ctx()).lines().is_empty());
122    }
123
124    #[test]
125    fn renders_nothing_when_all_complete_and_not_waiting() {
126        let mut indicator = ProgressIndicator::default();
127        indicator.update(3, 3, false, WorkspaceProgress::None);
128        assert!(indicator.render(&ctx()).lines().is_empty());
129    }
130
131    #[test]
132    fn renders_when_tools_running() {
133        let mut indicator = ProgressIndicator::default();
134        indicator.update(1, 3, false, WorkspaceProgress::None);
135        let frame = indicator.render(&ctx());
136        let lines = frame.lines();
137        assert_eq!(lines.len(), 3);
138        let text = lines[1].plain_text();
139        assert!(text.contains("esc to interrupt"));
140    }
141
142    #[test]
143    fn renders_when_waiting_for_response_without_tools() {
144        let mut indicator = ProgressIndicator::default();
145        indicator.update(0, 0, true, WorkspaceProgress::None);
146        let frame = indicator.render(&ctx());
147        let lines = frame.lines();
148        assert_eq!(lines.len(), 3);
149        let text = lines[1].plain_text();
150        assert!(text.contains("esc to interrupt"));
151    }
152
153    #[test]
154    fn spinner_animates_with_tick() {
155        let mut a = ProgressIndicator::default();
156        a.update(0, 1, false, WorkspaceProgress::None);
157        let mut b = ProgressIndicator::default();
158        b.update(0, 1, false, WorkspaceProgress::None);
159        b.set_tick(1);
160        let text_a = a.render(&ctx()).lines()[1].plain_text();
161        let text_b = b.render(&ctx()).lines()[1].plain_text();
162        assert_ne!(text_a, text_b);
163    }
164
165    #[test]
166    fn on_tick_advances_when_running() {
167        let mut indicator = ProgressIndicator::default();
168        indicator.update(1, 3, false, WorkspaceProgress::None);
169        indicator.on_tick();
170        let frame = indicator.render(&ctx());
171        assert!(!frame.lines().is_empty());
172    }
173
174    #[test]
175    fn on_tick_advances_when_waiting() {
176        let mut indicator = ProgressIndicator::default();
177        indicator.update(0, 0, true, WorkspaceProgress::None);
178        let frame_before = indicator.tick;
179        indicator.on_tick();
180        assert_ne!(indicator.tick, frame_before);
181    }
182
183    #[test]
184    fn on_tick_noop_when_idle() {
185        let mut indicator = ProgressIndicator::default();
186        indicator.update(3, 3, false, WorkspaceProgress::None);
187        indicator.on_tick();
188        assert!(indicator.render(&ctx()).lines().is_empty());
189    }
190
191    #[test]
192    fn first_turn_shows_first_tip() {
193        let mut indicator = ProgressIndicator::default();
194        indicator.update(0, 0, true, WorkspaceProgress::None);
195        indicator.set_turn_count(1);
196        let frame = indicator.render(&ctx());
197        let text = frame.lines()[1].plain_text();
198        assert!(text.contains(MESSAGES[0]));
199    }
200
201    #[test]
202    fn tip_advances_each_turn() {
203        let mut indicator = ProgressIndicator::default();
204        // First turn: inactive → active
205        indicator.update(0, 0, true, WorkspaceProgress::None);
206        assert_eq!(indicator.turn_count, 1);
207        let tip_0 = indicator.render(&ctx()).lines()[1].plain_text();
208
209        // Go inactive
210        indicator.update(0, 0, false, WorkspaceProgress::None);
211
212        // Second turn: inactive → active
213        indicator.update(0, 0, true, WorkspaceProgress::None);
214        assert_eq!(indicator.turn_count, 2);
215        let tip_1 = indicator.render(&ctx()).lines()[1].plain_text();
216
217        assert_ne!(tip_0, tip_1);
218        assert!(tip_0.contains(MESSAGES[0]));
219        assert!(tip_1.contains(MESSAGES[1]));
220    }
221
222    #[test]
223    fn shows_working_after_tips_exhausted() {
224        let mut indicator = ProgressIndicator::default();
225        indicator.update(0, 0, true, WorkspaceProgress::None);
226        indicator.set_turn_count(MESSAGES.len() + 1);
227        let text = indicator.render(&ctx()).lines()[1].plain_text();
228        assert!(text.contains("Working..."));
229    }
230
231    #[test]
232    fn reset_restarts_tips() {
233        let mut indicator = ProgressIndicator::default();
234        indicator.update(0, 0, true, WorkspaceProgress::None);
235        assert_eq!(indicator.turn_count, 1);
236
237        let indicator = ProgressIndicator::default();
238        assert_eq!(indicator.turn_count, 0);
239    }
240
241    #[test]
242    fn renders_workspace_move_without_interrupt_hint() {
243        let mut indicator = ProgressIndicator::default();
244        indicator.update(0, 0, false, WorkspaceProgress::Moving);
245        let text = indicator.render(&ctx()).lines()[1].plain_text();
246        assert!(text.contains("Moving workspace..."));
247        assert!(!text.contains("esc to interrupt"));
248    }
249
250    #[test]
251    fn workspace_move_message_takes_precedence_when_agent_is_busy() {
252        let mut indicator = ProgressIndicator::default();
253        indicator.update(0, 0, true, WorkspaceProgress::Moving);
254        let text = indicator.render(&ctx()).lines()[1].plain_text();
255        assert!(text.contains("Moving workspace..."));
256        assert!(text.contains("esc to interrupt"));
257    }
258
259    #[test]
260    fn renders_workspace_session_load_without_interrupt_hint() {
261        let mut indicator = ProgressIndicator::default();
262        indicator.update(0, 0, false, WorkspaceProgress::LoadingSession);
263        let text = indicator.render(&ctx()).lines()[1].plain_text();
264        assert!(text.contains("Loading session in new workspace..."));
265        assert!(!text.contains("esc to interrupt"));
266    }
267
268    #[test]
269    fn staying_active_does_not_advance_tip() {
270        let mut indicator = ProgressIndicator::default();
271        indicator.update(0, 0, true, WorkspaceProgress::None);
272        assert_eq!(indicator.turn_count, 1);
273
274        // Multiple updates while staying active
275        indicator.update(1, 3, true, WorkspaceProgress::None);
276        indicator.update(2, 3, true, WorkspaceProgress::None);
277        indicator.update(3, 3, true, WorkspaceProgress::None);
278        // Still waiting_for_response so still active
279        assert_eq!(indicator.turn_count, 1);
280    }
281}