use crossterm::event::KeyEvent;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use std::any::Any;
use std::time::Duration;
use super::{Widget, WidgetKeyContext, WidgetKeyResult};
use crate::themes::Theme;
pub type StatusBarRenderer = Box<dyn Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send>;
#[derive(Default)]
pub struct StatusBarConfig {
pub height: u16,
pub show_cwd: bool,
pub show_model: bool,
pub show_context: bool,
pub show_hints: bool,
pub content_renderer: Option<StatusBarRenderer>,
pub hint_unconfigured: Option<String>,
pub hint_ready: Option<String>,
pub hint_typing: Option<String>,
}
impl StatusBarConfig {
pub fn new() -> Self {
Self {
height: 2,
show_cwd: true,
show_model: true,
show_context: true,
show_hints: true,
content_renderer: None,
hint_unconfigured: None,
hint_ready: None,
hint_typing: None,
}
}
}
#[derive(Clone, Default)]
pub struct StatusBarData {
pub cwd: String,
pub model_name: String,
pub context_used: i64,
pub context_limit: i32,
pub session_id: i64,
pub status_hint: Option<String>,
pub is_waiting: bool,
pub waiting_elapsed: Option<Duration>,
pub input_empty: bool,
pub panels_active: bool,
}
pub struct StatusBar {
active: bool,
config: StatusBarConfig,
pub(crate) data: StatusBarData,
}
impl StatusBar {
pub fn new() -> Self {
Self {
active: true,
config: StatusBarConfig::new(),
data: StatusBarData::default(),
}
}
pub fn with_config(config: StatusBarConfig) -> Self {
Self {
active: true,
config,
data: StatusBarData::default(),
}
}
pub fn with_renderer<F>(mut self, renderer: F) -> Self
where
F: Fn(&StatusBarData, &Theme) -> Vec<Line<'static>> + Send + 'static,
{
self.config.content_renderer = Some(Box::new(renderer));
self
}
pub fn with_hint_unconfigured(mut self, hint: impl Into<String>) -> Self {
self.config.hint_unconfigured = Some(hint.into());
self
}
pub fn with_hint_ready(mut self, hint: impl Into<String>) -> Self {
self.config.hint_ready = Some(hint.into());
self
}
pub fn with_hint_typing(mut self, hint: impl Into<String>) -> Self {
self.config.hint_typing = Some(hint.into());
self
}
pub fn update_data(&mut self, data: StatusBarData) {
self.data = data;
}
fn render_default(&self, theme: &Theme, width: usize) -> Vec<Line<'static>> {
let data = &self.data;
let config = &self.config;
let cwd_display = if config.show_cwd && !data.cwd.is_empty() {
format!(" {}", data.cwd)
} else {
String::new()
};
let context_str = if config.show_context {
Self::format_context_display(data)
} else {
String::new()
};
let context_style = Self::context_style(data, theme);
let model_display = if config.show_model {
format!("{} ", data.model_name)
} else {
String::new()
};
let cwd_len = cwd_display.chars().count();
let context_len = context_str.chars().count();
let model_len = model_display.chars().count();
let spacing = if context_len > 0 { 2 } else { 0 };
let total_right = context_len + spacing + model_len;
let line1_padding = width.saturating_sub(cwd_len + total_right);
let line1 = if context_len > 0 {
Line::from(vec![
Span::styled(cwd_display, theme.status_help),
Span::raw(" ".repeat(line1_padding)),
Span::styled(context_str, context_style),
Span::raw(" "),
Span::styled(model_display, theme.status_model),
])
} else {
Line::from(vec![
Span::styled(cwd_display, theme.status_help),
Span::raw(" ".repeat(line1_padding)),
Span::styled(model_display, theme.status_model),
])
};
let help_text = if !config.show_hints {
String::new()
} else if data.panels_active {
String::new()
} else if let Some(hint) = &data.status_hint {
format!(" {}", hint)
} else if data.is_waiting {
let elapsed_str = data
.waiting_elapsed
.map(format_elapsed)
.unwrap_or_else(|| "0s".to_string());
format!(" escape to interrupt ({})", elapsed_str)
} else if data.session_id == 0 {
config.hint_unconfigured.clone()
.unwrap_or_else(|| " No session - type /new-session to start".to_string())
} else if data.input_empty {
config.hint_ready.clone()
.unwrap_or_else(|| " esc to exit".to_string())
} else {
config.hint_typing.clone()
.unwrap_or_else(|| " enter to send · shift-enter for new line".to_string())
};
let line2 = Line::from(vec![Span::styled(help_text, theme.status_help)]);
vec![line1, line2]
}
fn format_context_display(data: &StatusBarData) -> String {
if data.context_limit == 0 {
return String::new();
}
let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
let prefix = if utilization > 80.0 {
"Context Low:"
} else {
"Context:"
};
format!(
"{} {}/{} ({:.0}%)",
prefix,
format_tokens(data.context_used),
format_tokens(data.context_limit as i64),
utilization
)
}
fn context_style(data: &StatusBarData, theme: &Theme) -> Style {
if data.context_limit == 0 {
return theme.status_help;
}
let utilization = (data.context_used as f64 / data.context_limit as f64) * 100.0;
if utilization > 80.0 {
Style::default().fg(Color::Yellow)
} else {
theme.status_help
}
}
}
impl Default for StatusBar {
fn default() -> Self {
Self::new()
}
}
impl Widget for StatusBar {
fn id(&self) -> &'static str {
super::widget_ids::STATUS_BAR
}
fn priority(&self) -> u8 {
100
}
fn is_active(&self) -> bool {
self.active
}
fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
WidgetKeyResult::NotHandled
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let width = area.width as usize;
let lines = if let Some(renderer) = &self.config.content_renderer {
renderer(&self.data, theme)
} else {
self.render_default(theme, width)
};
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
fn required_height(&self, _available: u16) -> u16 {
self.config.height
}
fn blocks_input(&self) -> bool {
false
}
fn is_overlay(&self) -> bool {
false
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
fn format_elapsed(duration: Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
let mins = secs / 60;
let remaining_secs = secs % 60;
if remaining_secs == 0 {
format!("{}m", mins)
} else {
format!("{}m {}s", mins, remaining_secs)
}
} else {
let hours = secs / 3600;
let remaining_mins = (secs % 3600) / 60;
if remaining_mins == 0 {
format!("{}h", hours)
} else {
format!("{}h {}m", hours, remaining_mins)
}
}
}
fn format_tokens(tokens: i64) -> String {
if tokens >= 100_000 {
format!("{}K", tokens / 1000)
} else if tokens >= 1000 {
format!("{:.1}K", tokens as f64 / 1000.0)
} else {
format!("{}", tokens)
}
}