1use 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
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 || 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 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 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 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
369fn 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
393fn 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}