Skip to main content

agent_air_tui/widgets/
status_bar.rs

1//! Status bar widget for displaying application state
2//!
3//! The status bar is a non-interactive widget that displays:
4//! - Current working directory
5//! - Model name
6//! - Context usage (if applicable)
7//! - Contextual help hints
8//!
9//! # Customization
10//!
11//! The status bar can be customized in several ways:
12//!
13//! 1. **Config flags** - Show/hide specific elements via [`StatusBarConfig`]
14//! 2. **Custom renderer** - Complete control over display via [`StatusBarRenderer`]
15//! 3. **Opt-out** - Unregister the widget or exclude from layout
16//!
17//! # Examples
18//!
19//! ```rust,ignore
20//! // Default status bar (automatically registered)
21//! let app = App::with_config(config);
22//!
23//! // Custom renderer
24//! let status_bar = StatusBar::new()
25//!     .with_renderer(|data, theme| {
26//!         vec![Line::from(format!(" {} | {}", data.model_name, data.session_id))]
27//!     });
28//! app.register_widget(status_bar);
29//!
30//! // Hide specific elements
31//! let status_bar = StatusBar::new()
32//!     .with_config(StatusBarConfig {
33//!         show_cwd: false,
34//!         ..Default::default()
35//!     });
36//! ```
37
38use crossterm::event::KeyEvent;
39use ratatui::{
40    Frame,
41    layout::Rect,
42    style::{Color, Style},
43    text::{Line, Span},
44    widgets::Paragraph,
45};
46use std::any::Any;
47use std::time::Duration;
48
49use super::{Widget, WidgetKeyContext, WidgetKeyResult};
50use crate::themes::Theme;
51
52/// Custom renderer function type for status bar content
53pub type StatusBarRenderer = Box<dyn Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send>;
54
55/// Configuration for the status bar widget
56#[derive(Default)]
57pub struct StatusBarConfig {
58    /// Height of the status bar (default: 2)
59    pub height: u16,
60    /// Show current working directory (default: true)
61    pub show_cwd: bool,
62    /// Show model name (default: true)
63    pub show_model: bool,
64    /// Show context usage (default: true)
65    pub show_context: bool,
66    /// Show help hints (default: true)
67    pub show_hints: bool,
68    /// Custom content renderer (overrides all flags)
69    pub content_renderer: Option<StatusBarRenderer>,
70    /// Custom hint for unconfigured/no session state
71    pub hint_unconfigured: Option<String>,
72    /// Custom hint when input is empty and ready
73    pub hint_ready: Option<String>,
74    /// Custom hint when user is typing
75    pub hint_typing: Option<String>,
76}
77
78impl StatusBarConfig {
79    /// Create default configuration
80    pub fn new() -> Self {
81        Self {
82            height: 2,
83            show_cwd: true,
84            show_model: true,
85            show_context: true,
86            show_hints: true,
87            content_renderer: None,
88            hint_unconfigured: None,
89            hint_ready: None,
90            hint_typing: None,
91        }
92    }
93}
94
95/// Data required for rendering the status bar
96///
97/// This struct is updated before each render by the App via [`StatusBar::update_data`].
98#[derive(Clone, Default)]
99pub struct StatusBarData {
100    /// Current working directory
101    pub cwd: String,
102    /// Model name
103    pub model_name: String,
104    /// Context tokens used
105    pub context_used: i64,
106    /// Context token limit
107    pub context_limit: i32,
108    /// Current session ID
109    pub session_id: i64,
110    /// Status hint from key handler
111    pub status_hint: Option<String>,
112    /// Whether the app is waiting for a response
113    pub is_waiting: bool,
114    /// Time elapsed since waiting started
115    pub waiting_elapsed: Option<Duration>,
116    /// Whether the input is empty
117    pub input_empty: bool,
118    /// Whether panels are active (suppress hints)
119    pub panels_active: bool,
120}
121
122/// Status bar widget implementation
123pub struct StatusBar {
124    active: bool,
125    config: StatusBarConfig,
126    pub(crate) data: StatusBarData,
127}
128
129impl StatusBar {
130    /// Create a new status bar with default configuration
131    pub fn new() -> Self {
132        Self {
133            active: true,
134            config: StatusBarConfig::new(),
135            data: StatusBarData::default(),
136        }
137    }
138
139    /// Create with custom configuration
140    pub fn with_config(config: StatusBarConfig) -> Self {
141        Self {
142            active: true,
143            config,
144            data: StatusBarData::default(),
145        }
146    }
147
148    /// Set a custom renderer function
149    pub fn with_renderer<F>(mut self, renderer: F) -> Self
150    where
151        F: Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send + 'static,
152    {
153        self.config.content_renderer = Some(Box::new(renderer));
154        self
155    }
156
157    /// Set the hint shown when no session/API key is configured
158    ///
159    /// Default: " No session - type /new-session to start"
160    pub fn with_hint_unconfigured(mut self, hint: impl Into<String>) -> Self {
161        self.config.hint_unconfigured = Some(hint.into());
162        self
163    }
164
165    /// Set the hint shown when input is empty and ready
166    ///
167    /// Default: " Ctrl-D to exit"
168    pub fn with_hint_ready(mut self, hint: impl Into<String>) -> Self {
169        self.config.hint_ready = Some(hint.into());
170        self
171    }
172
173    /// Set the hint shown when user is typing
174    ///
175    /// Default: " Shift-Enter to add a new line"
176    pub fn with_hint_typing(mut self, hint: impl Into<String>) -> Self {
177        self.config.hint_typing = Some(hint.into());
178        self
179    }
180
181    /// Update the status bar data before rendering
182    ///
183    /// This should be called by App before layout computation.
184    pub fn update_data(&mut self, data: StatusBarData) {
185        self.data = data;
186    }
187
188    /// Render the default status bar content
189    fn render_default(&self, theme: &Theme, width: usize) -> Vec<Line<'static>> {
190        let data = &self.data;
191        let config = &self.config;
192
193        // Line 1: CWD + spacing + context + model
194        let cwd_display = if config.show_cwd && !data.cwd.is_empty() {
195            format!(" {}", data.cwd)
196        } else {
197            String::new()
198        };
199
200        let context_str = if config.show_context {
201            Self::format_context_display(data)
202        } else {
203            String::new()
204        };
205
206        let context_style = Self::context_style(data, theme);
207
208        let model_display = if config.show_model {
209            format!("{} ", data.model_name)
210        } else {
211            String::new()
212        };
213
214        let cwd_len = cwd_display.chars().count();
215        let context_len = context_str.chars().count();
216        let model_len = model_display.chars().count();
217        let spacing = if context_len > 0 { 2 } else { 0 };
218        let total_right = context_len + spacing + model_len;
219        let line1_padding = width.saturating_sub(cwd_len + total_right);
220
221        let line1 = if context_len > 0 {
222            Line::from(vec![
223                Span::styled(cwd_display, theme.status_help),
224                Span::raw(" ".repeat(line1_padding)),
225                Span::styled(context_str, context_style),
226                Span::raw("  "),
227                Span::styled(model_display, theme.status_model),
228            ])
229        } else {
230            Line::from(vec![
231                Span::styled(cwd_display, theme.status_help),
232                Span::raw(" ".repeat(line1_padding)),
233                Span::styled(model_display, theme.status_model),
234            ])
235        };
236
237        // Line 2: Help text (hint line)
238        let help_text = if !config.show_hints || data.panels_active {
239            String::new()
240        } else if let Some(hint) = &data.status_hint {
241            format!(" {}", hint)
242        } else if data.is_waiting {
243            let elapsed_str = data
244                .waiting_elapsed
245                .map(format_elapsed)
246                .unwrap_or_else(|| "0s".to_string());
247            format!(" escape to interrupt ({})", elapsed_str)
248        } else if data.session_id == 0 {
249            config
250                .hint_unconfigured
251                .clone()
252                .unwrap_or_else(|| " No session - type /new-session to start".to_string())
253        } else if data.input_empty {
254            config
255                .hint_ready
256                .clone()
257                .unwrap_or_else(|| " esc to exit".to_string())
258        } else {
259            config
260                .hint_typing
261                .clone()
262                .unwrap_or_else(|| " enter to send ยท shift-enter for new line".to_string())
263        };
264
265        let line2 = Line::from(vec![Span::styled(help_text, theme.status_help)]);
266
267        vec![line1, line2]
268    }
269
270    /// Format the context display string
271    fn format_context_display(data: &StatusBarData) -> String {
272        if data.context_limit == 0 {
273            return String::new();
274        }
275
276        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
277        let prefix = if utilization > 80.0 {
278            "Context Low:"
279        } else {
280            "Context:"
281        };
282
283        format!(
284            "{} {}/{} ({:.0}%)",
285            prefix,
286            format_tokens(data.context_used),
287            format_tokens(data.context_limit as i64),
288            utilization
289        )
290    }
291
292    /// Get the style for context display based on utilization
293    fn context_style(data: &StatusBarData, theme: &Theme) -> Style {
294        if data.context_limit == 0 {
295            return theme.status_help;
296        }
297
298        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
299        if utilization > 80.0 {
300            Style::default().fg(Color::Yellow)
301        } else {
302            theme.status_help
303        }
304    }
305}
306
307impl Default for StatusBar {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313impl Widget for StatusBar {
314    fn id(&self) -> &'static str {
315        super::widget_ids::STATUS_BAR
316    }
317
318    fn priority(&self) -> u8 {
319        100
320    }
321
322    fn is_active(&self) -> bool {
323        self.active
324    }
325
326    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
327        // Status bar is non-interactive
328        WidgetKeyResult::NotHandled
329    }
330
331    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
332        let width = area.width as usize;
333
334        let lines = if let Some(renderer) = &self.config.content_renderer {
335            renderer(&self.data, theme)
336        } else {
337            self.render_default(theme, width)
338        };
339
340        let paragraph = Paragraph::new(lines);
341        frame.render_widget(paragraph, area);
342    }
343
344    fn required_height(&self, _available: u16) -> u16 {
345        self.config.height
346    }
347
348    fn blocks_input(&self) -> bool {
349        false
350    }
351
352    fn is_overlay(&self) -> bool {
353        false
354    }
355
356    fn as_any(&self) -> &dyn Any {
357        self
358    }
359
360    fn as_any_mut(&mut self) -> &mut dyn Any {
361        self
362    }
363
364    fn into_any(self: Box<Self>) -> Box<dyn Any> {
365        self
366    }
367}
368
369/// Format a duration for display (e.g., "2s", "1m 30s", "2h 5m")
370fn format_elapsed(duration: Duration) -> String {
371    let secs = duration.as_secs();
372    if secs < 60 {
373        format!("{}s", secs)
374    } else if secs < 3600 {
375        let mins = secs / 60;
376        let remaining_secs = secs % 60;
377        if remaining_secs == 0 {
378            format!("{}m", mins)
379        } else {
380            format!("{}m {}s", mins, remaining_secs)
381        }
382    } else {
383        let hours = secs / 3600;
384        let remaining_mins = (secs % 3600) / 60;
385        if remaining_mins == 0 {
386            format!("{}h", hours)
387        } else {
388            format!("{}h {}m", hours, remaining_mins)
389        }
390    }
391}
392
393/// Format token counts for display (e.g., "4.3K", "200K", "850")
394fn format_tokens(tokens: i64) -> String {
395    if tokens >= 100_000 {
396        format!("{}K", tokens / 1000)
397    } else if tokens >= 1000 {
398        format!("{:.1}K", tokens as f64 / 1000.0)
399    } else {
400        format!("{}", tokens)
401    }
402}