Skip to main content

rab/agent/ui/
working.rs

1use crate::agent::ui::theme::RabTheme;
2use crate::tui::Component;
3
4/// Options for configuring the working indicator's appearance.
5/// Mirrors pi's `LoaderIndicatorOptions` / `WorkingIndicatorOptions`.
6#[derive(Clone)]
7pub struct IndicatorOptions {
8    /// Animation frames. Empty array hides the indicator entirely.
9    pub frames: Vec<String>,
10    /// Frame interval in milliseconds for animated indicators.
11    pub interval_ms: u64,
12}
13
14impl Default for IndicatorOptions {
15    fn default() -> Self {
16        Self {
17            frames: vec![
18                "⠋".into(),
19                "⠙".into(),
20                "⠹".into(),
21                "⠸".into(),
22                "⠼".into(),
23                "⠴".into(),
24                "⠦".into(),
25                "⠧".into(),
26                "⠇".into(),
27                "⠏".into(),
28            ],
29            interval_ms: 80,
30        }
31    }
32}
33
34/// Loader shown during agent streaming - a spinner + message.
35/// Mirrors pi's `Loader` component (spinner + "Working..." message).
36pub struct WorkingIndicator {
37    options: IndicatorOptions,
38    frame: usize,
39    last_tick: std::time::Instant,
40    theme: RabTheme,
41    pub active: bool,
42    message: String,
43    /// Ensures the indicator renders for at least one frame after `start()`,
44    /// even if `stop()` is called before the next `render()`. This prevents
45    /// a race where a fast agent loop dispatches both AgentStart and AgentEnd
46    /// in the same event batch, causing the spinner to never appear.
47    show_once: bool,
48}
49
50impl WorkingIndicator {
51    pub fn new() -> Self {
52        let theme = crate::agent::ui::theme::current_theme().clone();
53        Self {
54            options: IndicatorOptions::default(),
55            frame: 0,
56            last_tick: std::time::Instant::now(),
57            theme,
58            active: false,
59            show_once: false,
60            message: "Working...".into(),
61        }
62    }
63
64    pub fn start(&mut self) {
65        self.active = true;
66        self.show_once = true;
67        self.last_tick = std::time::Instant::now();
68    }
69
70    pub fn stop(&mut self) {
71        self.active = false;
72        // show_once remains set if start() was called - ensures at least one render
73    }
74
75    /// Set the message shown alongside the spinner (e.g. "Working...").
76    /// Mirrors pi's `Loader::setMessage()`.
77    pub fn set_message(&mut self, message: String) {
78        self.message = message;
79    }
80
81    /// Returns true if the indicator should be shown (active or show_once).
82    pub fn should_show(&self) -> bool {
83        (self.active || self.show_once) && !self.options.frames.is_empty()
84    }
85
86    /// Configure the indicator frames and interval.
87    /// Mirrors pi's `Loader::setIndicator()`.
88    pub fn set_indicator(&mut self, options: Option<IndicatorOptions>) {
89        self.options = options.unwrap_or_default();
90        self.frame = 0;
91    }
92
93    /// Returns true if the frame changed (caller should re-render).
94    pub fn tick(&mut self) -> bool {
95        if !self.active || self.options.frames.is_empty() {
96            return false;
97        }
98        let elapsed = self.last_tick.elapsed();
99        if elapsed.as_millis() >= self.options.interval_ms as u128 {
100            self.frame = (self.frame + 1) % self.options.frames.len();
101            self.last_tick = std::time::Instant::now();
102            return true;
103        }
104        false
105    }
106}
107
108impl Default for WorkingIndicator {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl Component for WorkingIndicator {
115    fn render(&mut self, _width: usize) -> Vec<String> {
116        if (!self.active && !self.show_once) || self.options.frames.is_empty() {
117            return vec![];
118        }
119        let frame = &self.options.frames[self.frame % self.options.frames.len()];
120        // Matches pi's Loader::updateDisplay(): colored spinner + space + colored message
121        // pi uses accent for spinner, muted for message.
122        // pi's Text paddingX=1 adds one space on each side.
123        let line = format!(
124            " {} {} ",
125            self.theme.accent(frame),
126            self.theme.muted(&self.message)
127        );
128        self.show_once = false;
129        // pi's Loader.render() prepends a blank line: ["", " ⠋ Working... "]
130        vec![String::new(), line]
131    }
132}