wisp/components/
progress_indicator.rs1use tui::BRAILLE_FRAMES as FRAMES;
2use tui::{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 how much context window remains",
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
54 .checked_sub(1)
55 .and_then(|i| MESSAGES.get(i))
56 .copied()
57 .unwrap_or("Working...")
58 }
59
60 pub fn on_tick(&mut self) {
62 if self.is_active() {
63 self.tick = self.tick.wrapping_add(1);
64 }
65 }
66}
67
68impl ProgressIndicator {
69 pub fn render(&self, context: &ViewContext) -> Vec<Line> {
70 if !self.is_active() {
71 return vec![];
72 }
73
74 let frame = FRAMES[self.tick as usize % FRAMES.len()];
75 let mut line = Line::default();
76 line.push_styled(frame.to_string(), context.theme.info());
77 line.push_styled(
78 format!(" {}", self.current_message()),
79 context.theme.text_secondary(),
80 );
81 line.push_with_style(
82 " (esc to interrupt)".to_string(),
83 Style::fg(context.theme.muted()).italic(),
84 );
85
86 vec![line]
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn ctx() -> ViewContext {
95 ViewContext::new((80, 24))
96 }
97
98 #[test]
99 fn renders_nothing_when_idle() {
100 let indicator = ProgressIndicator::default();
101 assert!(indicator.render(&ctx()).is_empty());
102 }
103
104 #[test]
105 fn renders_nothing_when_all_complete_and_not_waiting() {
106 let mut indicator = ProgressIndicator::default();
107 indicator.update(3, 3, false);
108 assert!(indicator.render(&ctx()).is_empty());
109 }
110
111 #[test]
112 fn renders_when_tools_running() {
113 let mut indicator = ProgressIndicator::default();
114 indicator.update(1, 3, false);
115 let lines = indicator.render(&ctx());
116 assert_eq!(lines.len(), 1);
117 let text = lines[0].plain_text();
118 assert!(text.contains("esc to interrupt"));
119 }
120
121 #[test]
122 fn renders_when_waiting_for_response_without_tools() {
123 let mut indicator = ProgressIndicator::default();
124 indicator.update(0, 0, true);
125 let lines = indicator.render(&ctx());
126 assert_eq!(lines.len(), 1);
127 let text = lines[0].plain_text();
128 assert!(text.contains("esc to interrupt"));
129 }
130
131 #[test]
132 fn spinner_animates_with_tick() {
133 let mut a = ProgressIndicator::default();
134 a.update(0, 1, false);
135 let mut b = ProgressIndicator::default();
136 b.update(0, 1, false);
137 b.set_tick(1);
138 let text_a = a.render(&ctx())[0].plain_text();
139 let text_b = b.render(&ctx())[0].plain_text();
140 assert_ne!(text_a, text_b);
141 }
142
143 #[test]
144 fn on_tick_advances_when_running() {
145 let mut indicator = ProgressIndicator::default();
146 indicator.update(1, 3, false);
147 indicator.on_tick();
148 let lines = indicator.render(&ctx());
149 assert!(!lines.is_empty());
150 }
151
152 #[test]
153 fn on_tick_advances_when_waiting() {
154 let mut indicator = ProgressIndicator::default();
155 indicator.update(0, 0, true);
156 let frame_before = indicator.tick;
157 indicator.on_tick();
158 assert_ne!(indicator.tick, frame_before);
159 }
160
161 #[test]
162 fn on_tick_noop_when_idle() {
163 let mut indicator = ProgressIndicator::default();
164 indicator.update(3, 3, false);
165 indicator.on_tick();
166 assert!(indicator.render(&ctx()).is_empty());
167 }
168
169 #[test]
170 fn first_turn_shows_first_tip() {
171 let mut indicator = ProgressIndicator::default();
172 indicator.update(0, 0, true);
173 indicator.set_turn_count(1);
174 let lines = indicator.render(&ctx());
175 let text = lines[0].plain_text();
176 assert!(text.contains(MESSAGES[0]));
177 }
178
179 #[test]
180 fn tip_advances_each_turn() {
181 let mut indicator = ProgressIndicator::default();
182 indicator.update(0, 0, true);
184 assert_eq!(indicator.turn_count, 1);
185 let tip_0 = indicator.render(&ctx())[0].plain_text();
186
187 indicator.update(0, 0, false);
189
190 indicator.update(0, 0, true);
192 assert_eq!(indicator.turn_count, 2);
193 let tip_1 = indicator.render(&ctx())[0].plain_text();
194
195 assert_ne!(tip_0, tip_1);
196 assert!(tip_0.contains(MESSAGES[0]));
197 assert!(tip_1.contains(MESSAGES[1]));
198 }
199
200 #[test]
201 fn shows_working_after_tips_exhausted() {
202 let mut indicator = ProgressIndicator::default();
203 indicator.update(0, 0, true);
204 indicator.set_turn_count(MESSAGES.len() + 1);
205 let text = indicator.render(&ctx())[0].plain_text();
206 assert!(text.contains("Working..."));
207 }
208
209 #[test]
210 fn reset_restarts_tips() {
211 let mut indicator = ProgressIndicator::default();
212 indicator.update(0, 0, true);
213 assert_eq!(indicator.turn_count, 1);
214
215 let indicator = ProgressIndicator::default();
216 assert_eq!(indicator.turn_count, 0);
217 }
218
219 #[test]
220 fn staying_active_does_not_advance_tip() {
221 let mut indicator = ProgressIndicator::default();
222 indicator.update(0, 0, true);
223 assert_eq!(indicator.turn_count, 1);
224
225 indicator.update(1, 3, true);
227 indicator.update(2, 3, true);
228 indicator.update(3, 3, true);
229 assert_eq!(indicator.turn_count, 1);
231 }
232}