Skip to main content

rpi_tui/
status_indicator.rs

1//! Status indicator components — port of `status-indicator.ts`.
2//!
3//! The TS original is a class hierarchy extending `Loader`. The Rust port
4//! collapses it into a single [`StatusIndicator`] Component that owns its own
5//! spinner + elapsed logic (reusing the loader's frame cadence), an optional
6//! [`CountdownTimer`] for the retry case, and themed coloring per `kind`.
7//! [`IdleStatus`] renders two blank lines (the slot shape when nothing runs).
8//!
9//! `app.interrupt` does not exist in the Rust keybinding registry, so the
10//! cancel hint uses a literal `"Ctrl+C"` via [`raw_key_hint`].
11
12use 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/// What the indicator represents.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum StatusKind {
26    /// Agent is actively working on a turn.
27    Working,
28    /// Waiting to retry after an error, with a backoff countdown.
29    Retry,
30    /// Compacting the context window (manual or auto).
31    Compaction,
32    /// Summarizing a branch.
33    BranchSummary,
34}
35
36/// What triggered a compaction (mirrors `CompactionStatusReason`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CompactionReason {
39    Manual,
40    Threshold,
41    Overflow,
42}
43
44/// Active status display: spinner + elapsed + themed message (+countdown).
45pub 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    /// Build a working status with a message.
58    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    /// Build a retry status that counts down `delay`.
70    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    /// Build a compaction status.
90    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    /// Build a branch-summary status.
110    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    /// Replace the message (e.g. as a retry countdown ticks down).
123    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    /// Tick the retry countdown and refresh its message if still running.
130    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        // Truncate message to fit remaining width, leaving a separating space.
199        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        // Advance spinner for the next render (the 120ms tick re-renders).
209        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
221/// Two blank lines indicating the idle status slot is empty.
222pub 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
249/// Literal "Ctrl+C" text for interrupt hints (no `app.interrupt` binding exists).
250fn raw_key_hint_keys() -> String {
251    // raw_key_hint returns "{keys} {desc}"; we only want the keys portion.
252    // Re-implement inline to get just the key glyph.
253    let colors = theme().colors;
254    // Return a pre-styled "Ctrl+C" so callers can interpolate it into messages.
255    // We strip the description arg by wrapping: use raw_key_hint then split.
256    let full = raw_key_hint("Ctrl+C", "cancel");
257    let _ = colors;
258    // raw_key_hint = "<muted>Ctrl+C</muted> <muted>cancel</muted>"; take the
259    // first styled segment only:
260    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}