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