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(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 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 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 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 indicator.update(0, 0, false, WorkspaceProgress::None);
211
212 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 indicator.update(1, 3, true, WorkspaceProgress::None);
276 indicator.update(2, 3, true, WorkspaceProgress::None);
277 indicator.update(3, 3, true, WorkspaceProgress::None);
278 assert_eq!(indicator.turn_count, 1);
280 }
281}