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}
71
72impl StatusBarConfig {
73 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#[derive(Clone, Default)]
90pub struct StatusBarData {
91 pub cwd: String,
93 pub model_name: String,
95 pub context_used: i64,
97 pub context_limit: i32,
99 pub session_id: i64,
101 pub status_hint: Option<String>,
103 pub is_waiting: bool,
105 pub waiting_elapsed: Option<Duration>,
107 pub input_empty: bool,
109 pub panels_active: bool,
111}
112
113pub struct StatusBar {
115 active: bool,
116 config: StatusBarConfig,
117 pub(crate) data: StatusBarData,
118}
119
120impl StatusBar {
121 pub fn new() -> Self {
123 Self {
124 active: true,
125 config: StatusBarConfig::new(),
126 data: StatusBarData::default(),
127 }
128 }
129
130 pub fn with_config(config: StatusBarConfig) -> Self {
132 Self {
133 active: true,
134 config,
135 data: StatusBarData::default(),
136 }
137 }
138
139 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 pub fn update_data(&mut self, data: StatusBarData) {
152 self.data = data;
153 }
154
155 fn render_default(&self, theme: &Theme, width: usize) -> Vec<Line<'static>> {
157 let data = &self.data;
158 let config = &self.config;
159
160 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 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 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 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 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
329fn 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
353fn 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}