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#[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 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 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}