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}
44
45impl WorkingIndicator {
46    pub fn new() -> Self {
47        let theme = crate::agent::ui::theme::current_theme().clone();
48        Self {
49            options: IndicatorOptions::default(),
50            frame: 0,
51            last_tick: std::time::Instant::now(),
52            theme,
53            active: false,
54            message: "Working...".into(),
55        }
56    }
57
58    pub fn start(&mut self) {
59        self.active = true;
60        self.last_tick = std::time::Instant::now();
61    }
62
63    pub fn stop(&mut self) {
64        self.active = false;
65    }
66
67    /// Set the message shown alongside the spinner (e.g. "Working...").
68    /// Mirrors pi's `Loader::setMessage()`.
69    pub fn set_message(&mut self, message: String) {
70        self.message = message;
71    }
72
73    /// Configure the indicator frames and interval.
74    /// Mirrors pi's `Loader::setIndicator()`.
75    pub fn set_indicator(&mut self, options: Option<IndicatorOptions>) {
76        self.options = options.unwrap_or_default();
77        self.frame = 0;
78    }
79
80    /// Returns true if the frame changed (caller should re-render).
81    pub fn tick(&mut self) -> bool {
82        if !self.active || self.options.frames.is_empty() {
83            return false;
84        }
85        let elapsed = self.last_tick.elapsed();
86        if elapsed.as_millis() >= self.options.interval_ms as u128 {
87            self.frame = (self.frame + 1) % self.options.frames.len();
88            self.last_tick = std::time::Instant::now();
89            return true;
90        }
91        false
92    }
93}
94
95impl Default for WorkingIndicator {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl Component for WorkingIndicator {
102    fn render(&self, _width: usize) -> Vec<String> {
103        if !self.active || self.options.frames.is_empty() {
104            return vec![];
105        }
106        let frame = &self.options.frames[self.frame % self.options.frames.len()];
107        // Matches pi's Loader::updateDisplay(): colored spinner + space + colored message
108        // pi uses accent for spinner, muted for message.
109        // pi's Text paddingX=1 adds one space on each side.
110        let line = format!(
111            " {} {} ",
112            self.theme.accent(frame),
113            self.theme.muted(&self.message)
114        );
115        // pi's Loader.render() prepends a blank line: ["", " ⠋ Working... "]
116        vec![String::new(), line]
117    }
118}