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