1use std::any::Any;
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16use super::component::Component;
17use crate::ansi::visible_width;
18use crate::countdown_timer::CountdownTimer;
19use crate::keybinding_hints::raw_key_hint;
20use crate::theme::{theme, Color};
21use crate::utils::truncate_to_width;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum StatusKind {
26 Working,
28 Retry,
30 Compaction,
32 BranchSummary,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CompactionReason {
39 Manual,
40 Threshold,
41 Overflow,
42}
43
44pub struct StatusIndicator {
46 kind: Mutex<StatusKind>,
47 message: Mutex<String>,
48 frame: Mutex<usize>,
49 start: Mutex<Option<Instant>>,
50 countdown: Mutex<Option<CountdownTimer>>,
51 retry_progress: Option<(u32, u32)>,
52}
53
54const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
55
56impl StatusIndicator {
57 pub fn working(message: impl Into<String>) -> Self {
59 Self {
60 kind: Mutex::new(StatusKind::Working),
61 message: Mutex::new(message.into()),
62 frame: Mutex::new(0),
63 start: Mutex::new(Some(Instant::now())),
64 countdown: Mutex::new(None),
65 retry_progress: None,
66 }
67 }
68
69 pub fn retry(attempt: u32, max_attempts: u32, delay: Duration) -> Self {
71 let secs_remaining = delay.as_secs().max(1) as u32;
72 let message = format!(
73 "Retrying ({}/{}) in {}s... ({} to cancel)",
74 attempt,
75 max_attempts,
76 secs_remaining,
77 raw_key_hint_keys()
78 );
79 Self {
80 kind: Mutex::new(StatusKind::Retry),
81 message: Mutex::new(message),
82 frame: Mutex::new(0),
83 start: Mutex::new(Some(Instant::now())),
84 countdown: Mutex::new(Some(CountdownTimer::new(delay))),
85 retry_progress: Some((attempt, max_attempts)),
86 }
87 }
88
89 pub fn compaction(reason: CompactionReason) -> Self {
91 let cancel = format!("({} to cancel)", raw_key_hint_keys());
92 let label = match reason {
93 CompactionReason::Manual => format!("Compacting context... {}", cancel),
94 CompactionReason::Overflow => {
95 format!("Context overflow detected, Auto-compacting... {}", cancel)
96 }
97 CompactionReason::Threshold => format!("Auto-compacting... {}", cancel),
98 };
99 Self {
100 kind: Mutex::new(StatusKind::Compaction),
101 message: Mutex::new(label),
102 frame: Mutex::new(0),
103 start: Mutex::new(Some(Instant::now())),
104 countdown: Mutex::new(None),
105 retry_progress: None,
106 }
107 }
108
109 pub fn branch_summary() -> Self {
111 let message = format!("Summarizing branch... ({} to cancel)", raw_key_hint_keys());
112 Self {
113 kind: Mutex::new(StatusKind::BranchSummary),
114 message: Mutex::new(message),
115 frame: Mutex::new(0),
116 start: Mutex::new(Some(Instant::now())),
117 countdown: Mutex::new(None),
118 retry_progress: None,
119 }
120 }
121
122 pub fn set_message(&self, message: impl Into<String>) {
124 if let Ok(mut m) = self.message.lock() {
125 *m = message.into();
126 }
127 }
128
129 pub fn tick_countdown(&self) {
131 let update = {
132 let c = self.countdown.lock().unwrap();
133 c.as_ref().map(CountdownTimer::remaining_secs)
134 };
135 if let Some(secs) = update {
136 if let Some((attempt, max_attempts)) = self.retry_progress {
137 self.set_message(format!(
138 "Retrying ({attempt}/{max_attempts}) in {secs}s... ({} to cancel)",
139 raw_key_hint_keys()
140 ));
141 }
142 }
143 }
144
145 fn elapsed(&self) -> Duration {
146 self.start
147 .lock()
148 .ok()
149 .and_then(|s| s.map(|t| t.elapsed()))
150 .unwrap_or_default()
151 }
152
153 fn advance(&self) {
154 if let Ok(mut f) = self.frame.lock() {
155 *f = (*f + 1) % SPINNER.len();
156 }
157 }
158
159 fn spinner_char(&self) -> char {
160 let f = *self.frame.lock().unwrap();
161 SPINNER.get(f).copied().unwrap_or('⠋')
162 }
163
164 fn colors_for(kind: StatusKind) -> (Color, Color) {
165 let c = theme().colors;
166 match kind {
167 StatusKind::Working | StatusKind::Compaction | StatusKind::BranchSummary => {
168 (c.accent, c.muted)
169 }
170 StatusKind::Retry => (c.warning, c.muted),
171 }
172 }
173}
174
175impl Component for StatusIndicator {
176 fn render(&self, width: usize) -> Vec<String> {
177 self.tick_countdown();
178 let kind = *self.kind.lock().unwrap();
179 let message = self.message.lock().unwrap().clone();
180 let (spinner_color, msg_color) = Self::colors_for(kind);
181
182 let elapsed = self.elapsed();
183 let secs = elapsed.as_secs();
184 let time_str = if secs >= 60 {
185 format!("{}m {}s", secs / 60, secs % 60)
186 } else {
187 format!("{}s", secs)
188 };
189
190 let spinner = self.spinner_char();
191 let prefix = format!(
192 "{} {}",
193 spinner_color.fg(&spinner.to_string()),
194 msg_color.fg(&time_str)
195 );
196 let prefix_w = visible_width(&prefix);
197
198 let max_msg = width.saturating_sub(prefix_w + 1);
200 let display_msg = if visible_width(&message) > max_msg {
201 truncate_to_width(&message, max_msg, "…")
202 } else {
203 message.clone()
204 };
205
206 let line = format!("{} {}", prefix, msg_color.fg(&display_msg));
207
208 self.advance();
210
211 vec![line]
212 }
213
214 fn invalidate(&self) {}
215
216 fn as_any(&self) -> &dyn Any {
217 self
218 }
219}
220
221pub struct IdleStatus;
223
224impl IdleStatus {
225 pub fn new() -> Self {
226 IdleStatus
227 }
228}
229
230impl Default for IdleStatus {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236impl Component for IdleStatus {
237 fn render(&self, width: usize) -> Vec<String> {
238 let empty = " ".repeat(width);
239 vec![empty.clone(), empty]
240 }
241
242 fn invalidate(&self) {}
243
244 fn as_any(&self) -> &dyn Any {
245 self
246 }
247}
248
249fn raw_key_hint_keys() -> String {
251 let colors = theme().colors;
254 let full = raw_key_hint("Ctrl+C", "cancel");
257 let _ = colors;
258 if let Some(space_idx) = full.find(" \x1b") {
261 full[..space_idx].to_string()
262 } else {
263 full
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_working_indicator_renders() {
273 let ind = StatusIndicator::working("Thinking");
274 let lines = ind.render(40);
275 assert_eq!(lines.len(), 1);
276 assert!(lines[0].contains("Thinking"));
277 }
278
279 #[test]
280 fn test_compaction_indicator_renders() {
281 let ind = StatusIndicator::compaction(CompactionReason::Manual);
282 let lines = ind.render(60);
283 assert!(lines[0].contains("Compacting"));
284 }
285
286 #[test]
287 fn test_retry_indicator_renders_attempt_budget() {
288 let ind = StatusIndicator::retry(3, 10, Duration::from_secs(8));
289 let lines = ind.render(80);
290 assert!(lines[0].contains("Retrying (3/10)"));
291 assert!(lines[0].contains("to cancel"));
292 }
293
294 #[test]
295 fn test_idle_status_two_blank_lines() {
296 let idle = IdleStatus::new();
297 let lines = idle.render(10);
298 assert_eq!(lines.len(), 2);
299 assert_eq!(lines[0], " ");
300 }
301
302 #[test]
303 fn test_long_message_truncated_no_panic() {
304 let ind = StatusIndicator::working(&"x".repeat(200));
305 let lines = ind.render(20);
306 assert_eq!(lines.len(), 1);
307 assert!(visible_width(&lines[0]) <= 20);
308 }
309}