Skip to main content

agent_core/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    layout::Rect,
41    style::{Color, Style},
42    text::{Line, Span},
43    widgets::Paragraph,
44    Frame,
45};
46use std::any::Any;
47use std::time::Duration;
48
49use super::{Widget, WidgetKeyContext, WidgetKeyResult};
50use crate::tui::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 {
239            String::new()
240        } else if data.panels_active {
241            String::new()
242        } else if let Some(hint) = &data.status_hint {
243            format!(" {}", hint)
244        } else if data.is_waiting {
245            let elapsed_str = data
246                .waiting_elapsed
247                .map(format_elapsed)
248                .unwrap_or_else(|| "0s".to_string());
249            format!(" escape to interrupt ({})", elapsed_str)
250        } else if data.session_id == 0 {
251            config.hint_unconfigured.clone()
252                .unwrap_or_else(|| " No session - type /new-session to start".to_string())
253        } else if data.input_empty {
254            config.hint_ready.clone()
255                .unwrap_or_else(|| " esc to exit".to_string())
256        } else {
257            config.hint_typing.clone()
258                .unwrap_or_else(|| " enter to send ยท shift-enter for new line".to_string())
259        };
260
261        let line2 = Line::from(vec![Span::styled(help_text, theme.status_help)]);
262
263        vec![line1, line2]
264    }
265
266    /// Format the context display string
267    fn format_context_display(data: &StatusBarData) -> String {
268        if data.context_limit == 0 {
269            return String::new();
270        }
271
272        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
273        let prefix = if utilization > 80.0 {
274            "Context Low:"
275        } else {
276            "Context:"
277        };
278
279        format!(
280            "{} {}/{} ({:.0}%)",
281            prefix,
282            format_tokens(data.context_used),
283            format_tokens(data.context_limit as i64),
284            utilization
285        )
286    }
287
288    /// Get the style for context display based on utilization
289    fn context_style(data: &StatusBarData, theme: &Theme) -> Style {
290        if data.context_limit == 0 {
291            return theme.status_help;
292        }
293
294        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
295        if utilization > 80.0 {
296            Style::default().fg(Color::Yellow)
297        } else {
298            theme.status_help
299        }
300    }
301}
302
303impl Default for StatusBar {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309impl Widget for StatusBar {
310    fn id(&self) -> &'static str {
311        super::widget_ids::STATUS_BAR
312    }
313
314    fn priority(&self) -> u8 {
315        100
316    }
317
318    fn is_active(&self) -> bool {
319        self.active
320    }
321
322    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
323        // Status bar is non-interactive
324        WidgetKeyResult::NotHandled
325    }
326
327    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
328        let width = area.width as usize;
329
330        let lines = if let Some(renderer) = &self.config.content_renderer {
331            renderer(&self.data, theme)
332        } else {
333            self.render_default(theme, width)
334        };
335
336        let paragraph = Paragraph::new(lines);
337        frame.render_widget(paragraph, area);
338    }
339
340    fn required_height(&self, _available: u16) -> u16 {
341        self.config.height
342    }
343
344    fn blocks_input(&self) -> bool {
345        false
346    }
347
348    fn is_overlay(&self) -> bool {
349        false
350    }
351
352    fn as_any(&self) -> &dyn Any {
353        self
354    }
355
356    fn as_any_mut(&mut self) -> &mut dyn Any {
357        self
358    }
359
360    fn into_any(self: Box<Self>) -> Box<dyn Any> {
361        self
362    }
363}
364
365/// Format a duration for display (e.g., "2s", "1m 30s", "2h 5m")
366fn format_elapsed(duration: Duration) -> String {
367    let secs = duration.as_secs();
368    if secs < 60 {
369        format!("{}s", secs)
370    } else if secs < 3600 {
371        let mins = secs / 60;
372        let remaining_secs = secs % 60;
373        if remaining_secs == 0 {
374            format!("{}m", mins)
375        } else {
376            format!("{}m {}s", mins, remaining_secs)
377        }
378    } else {
379        let hours = secs / 3600;
380        let remaining_mins = (secs % 3600) / 60;
381        if remaining_mins == 0 {
382            format!("{}h", hours)
383        } else {
384            format!("{}h {}m", hours, remaining_mins)
385        }
386    }
387}
388
389/// Format token counts for display (e.g., "4.3K", "200K", "850")
390fn format_tokens(tokens: i64) -> String {
391    if tokens >= 100_000 {
392        format!("{}K", tokens / 1000)
393    } else if tokens >= 1000 {
394        format!("{:.1}K", tokens as f64 / 1000.0)
395    } else {
396        format!("{}", tokens)
397    }
398}