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