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}
71
72impl StatusBarConfig {
73    /// Create default configuration
74    pub fn new() -> Self {
75        Self {
76            height: 2,
77            show_cwd: true,
78            show_model: true,
79            show_context: true,
80            show_hints: true,
81            content_renderer: None,
82        }
83    }
84}
85
86/// Data required for rendering the status bar
87///
88/// This struct is updated before each render by the App via [`StatusBar::update_data`].
89#[derive(Clone, Default)]
90pub struct StatusBarData {
91    /// Current working directory
92    pub cwd: String,
93    /// Model name
94    pub model_name: String,
95    /// Context tokens used
96    pub context_used: i64,
97    /// Context token limit
98    pub context_limit: i32,
99    /// Current session ID
100    pub session_id: i64,
101    /// Status hint from key handler
102    pub status_hint: Option<String>,
103    /// Whether the app is waiting for a response
104    pub is_waiting: bool,
105    /// Time elapsed since waiting started
106    pub waiting_elapsed: Option<Duration>,
107    /// Whether the input is empty
108    pub input_empty: bool,
109    /// Whether panels are active (suppress hints)
110    pub panels_active: bool,
111}
112
113/// Status bar widget implementation
114pub struct StatusBar {
115    active: bool,
116    config: StatusBarConfig,
117    pub(crate) data: StatusBarData,
118}
119
120impl StatusBar {
121    /// Create a new status bar with default configuration
122    pub fn new() -> Self {
123        Self {
124            active: true,
125            config: StatusBarConfig::new(),
126            data: StatusBarData::default(),
127        }
128    }
129
130    /// Create with custom configuration
131    pub fn with_config(config: StatusBarConfig) -> Self {
132        Self {
133            active: true,
134            config,
135            data: StatusBarData::default(),
136        }
137    }
138
139    /// Set a custom renderer function
140    pub fn with_renderer<F>(mut self, renderer: F) -> Self
141    where
142        F: Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send + 'static,
143    {
144        self.config.content_renderer = Some(Box::new(renderer));
145        self
146    }
147
148    /// Update the status bar data before rendering
149    ///
150    /// This should be called by App before layout computation.
151    pub fn update_data(&mut self, data: StatusBarData) {
152        self.data = data;
153    }
154
155    /// Render the default status bar content
156    fn render_default(&self, theme: &Theme, width: usize) -> Vec<Line<'static>> {
157        let data = &self.data;
158        let config = &self.config;
159
160        // Line 1: CWD + spacing + context + model
161        let cwd_display = if config.show_cwd && !data.cwd.is_empty() {
162            format!(" {}", data.cwd)
163        } else {
164            String::new()
165        };
166
167        let context_str = if config.show_context {
168            Self::format_context_display(data)
169        } else {
170            String::new()
171        };
172
173        let context_style = Self::context_style(data, theme);
174
175        let model_display = if config.show_model {
176            format!("{} ", data.model_name)
177        } else {
178            String::new()
179        };
180
181        let cwd_len = cwd_display.chars().count();
182        let context_len = context_str.chars().count();
183        let model_len = model_display.chars().count();
184        let spacing = if context_len > 0 { 2 } else { 0 };
185        let total_right = context_len + spacing + model_len;
186        let line1_padding = width.saturating_sub(cwd_len + total_right);
187
188        let line1 = if context_len > 0 {
189            Line::from(vec![
190                Span::styled(cwd_display, theme.status_help),
191                Span::raw(" ".repeat(line1_padding)),
192                Span::styled(context_str, context_style),
193                Span::raw("  "),
194                Span::styled(model_display, theme.status_model),
195            ])
196        } else {
197            Line::from(vec![
198                Span::styled(cwd_display, theme.status_help),
199                Span::raw(" ".repeat(line1_padding)),
200                Span::styled(model_display, theme.status_model),
201            ])
202        };
203
204        // Line 2: Help text
205        let help_text = if !config.show_hints {
206            String::new()
207        } else if data.panels_active {
208            String::new()
209        } else if let Some(hint) = &data.status_hint {
210            format!(" {}", hint)
211        } else if data.is_waiting {
212            let elapsed_str = data
213                .waiting_elapsed
214                .map(format_elapsed)
215                .unwrap_or_else(|| "0s".to_string());
216            format!(" escape to interrupt ({})", elapsed_str)
217        } else if data.session_id == 0 {
218            " No session - type /new-session to start".to_string()
219        } else if data.input_empty {
220            " Ctrl-D to exit".to_string()
221        } else {
222            " Shift-Enter to add a new line".to_string()
223        };
224
225        let line2 = Line::from(vec![Span::styled(help_text, theme.status_help)]);
226
227        vec![line1, line2]
228    }
229
230    /// Format the context display string
231    fn format_context_display(data: &StatusBarData) -> String {
232        if data.context_limit == 0 {
233            return String::new();
234        }
235
236        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
237        let prefix = if utilization > 80.0 {
238            "Context Low:"
239        } else {
240            "Context:"
241        };
242
243        format!(
244            "{} {}/{} ({:.0}%)",
245            prefix,
246            format_tokens(data.context_used),
247            format_tokens(data.context_limit as i64),
248            utilization
249        )
250    }
251
252    /// Get the style for context display based on utilization
253    fn context_style(data: &StatusBarData, theme: &Theme) -> Style {
254        if data.context_limit == 0 {
255            return theme.status_help;
256        }
257
258        let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
259        if utilization > 80.0 {
260            Style::default().fg(Color::Yellow)
261        } else {
262            theme.status_help
263        }
264    }
265}
266
267impl Default for StatusBar {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273impl Widget for StatusBar {
274    fn id(&self) -> &'static str {
275        super::widget_ids::STATUS_BAR
276    }
277
278    fn priority(&self) -> u8 {
279        100
280    }
281
282    fn is_active(&self) -> bool {
283        self.active
284    }
285
286    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
287        // Status bar is non-interactive
288        WidgetKeyResult::NotHandled
289    }
290
291    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
292        let width = area.width as usize;
293
294        let lines = if let Some(renderer) = &self.config.content_renderer {
295            renderer(&self.data, theme)
296        } else {
297            self.render_default(theme, width)
298        };
299
300        let paragraph = Paragraph::new(lines);
301        frame.render_widget(paragraph, area);
302    }
303
304    fn required_height(&self, _available: u16) -> u16 {
305        self.config.height
306    }
307
308    fn blocks_input(&self) -> bool {
309        false
310    }
311
312    fn is_overlay(&self) -> bool {
313        false
314    }
315
316    fn as_any(&self) -> &dyn Any {
317        self
318    }
319
320    fn as_any_mut(&mut self) -> &mut dyn Any {
321        self
322    }
323
324    fn into_any(self: Box<Self>) -> Box<dyn Any> {
325        self
326    }
327}
328
329/// Format a duration for display (e.g., "2s", "1m 30s", "2h 5m")
330fn format_elapsed(duration: Duration) -> String {
331    let secs = duration.as_secs();
332    if secs < 60 {
333        format!("{}s", secs)
334    } else if secs < 3600 {
335        let mins = secs / 60;
336        let remaining_secs = secs % 60;
337        if remaining_secs == 0 {
338            format!("{}m", mins)
339        } else {
340            format!("{}m {}s", mins, remaining_secs)
341        }
342    } else {
343        let hours = secs / 3600;
344        let remaining_mins = (secs % 3600) / 60;
345        if remaining_mins == 0 {
346            format!("{}h", hours)
347        } else {
348            format!("{}h {}m", hours, remaining_mins)
349        }
350    }
351}
352
353/// Format token counts for display (e.g., "4.3K", "200K", "850")
354fn format_tokens(tokens: i64) -> String {
355    if tokens >= 100_000 {
356        format!("{}K", tokens / 1000)
357    } else if tokens >= 1000 {
358        format!("{:.1}K", tokens as f64 / 1000.0)
359    } else {
360        format!("{}", tokens)
361    }
362}