1use 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
52pub type StatusBarRenderer = Box<dyn Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send>;
54
55#[derive(Default)]
57pub struct StatusBarConfig {
58 pub height: u16,
60 pub show_cwd: bool,
62 pub show_model: bool,
64 pub show_context: bool,
66 pub show_hints: bool,
68 pub content_renderer: Option<StatusBarRenderer>,
70 pub hint_unconfigured: Option<String>,
72 pub hint_ready: Option<String>,
74 pub hint_typing: Option<String>,
76}
77
78impl StatusBarConfig {
79 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#[derive(Clone, Default)]
99pub struct StatusBarData {
100 pub cwd: String,
102 pub model_name: String,
104 pub context_used: i64,
106 pub context_limit: i32,
108 pub session_id: i64,
110 pub status_hint: Option<String>,
112 pub is_waiting: bool,
114 pub waiting_elapsed: Option<Duration>,
116 pub input_empty: bool,
118 pub panels_active: bool,
120}
121
122pub struct StatusBar {
124 active: bool,
125 config: StatusBarConfig,
126 pub(crate) data: StatusBarData,
127}
128
129impl StatusBar {
130 pub fn new() -> Self {
132 Self {
133 active: true,
134 config: StatusBarConfig::new(),
135 data: StatusBarData::default(),
136 }
137 }
138
139 pub fn with_config(config: StatusBarConfig) -> Self {
141 Self {
142 active: true,
143 config,
144 data: StatusBarData::default(),
145 }
146 }
147
148 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 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 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 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 pub fn update_data(&mut self, data: StatusBarData) {
185 self.data = data;
186 }
187
188 fn render_default(&self, theme: &Theme, width: usize) -> Vec<Line<'static>> {
190 let data = &self.data;
191 let config = &self.config;
192
193 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 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 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 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 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
365fn 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
389fn 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}