use crate::Theme;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
#[derive(Debug, Clone)]
pub struct FooterData {
pub model_name: String,
pub thinking_level: Option<String>, pub provider_name: String,
pub git_branch: Option<String>,
pub git_dirty: bool,
pub pwd: Option<String>,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_write_tokens: u32,
pub context_window_pct: f32,
pub context_window_max: u32,
pub context_tokens: u32,
pub total_cost: f64,
pub session_duration_secs: u64,
pub is_busy: bool,
pub version: String,
}
impl Default for FooterData {
fn default() -> Self {
Self {
model_name: String::new(),
thinking_level: None,
provider_name: String::new(),
git_branch: Option::None,
git_dirty: false,
pwd: None,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
context_window_pct: 0.0,
context_window_max: 200_000,
context_tokens: 0,
total_cost: 0.0,
session_duration_secs: 0,
is_busy: false,
version: String::new(),
}
}
}
impl FooterData {
pub fn fmt_count(count: u32) -> String {
if count < 1000 {
count.to_string()
} else if count < 1_000_000 {
format!("{:.1}k", count as f32 / 1000.0)
} else {
format!("{:.1}M", count as f32 / 1_000_000.0)
}
}
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
}
}
fn display_path(pwd: &str) -> String {
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let shortened = if !home.is_empty() && pwd.starts_with(&home) {
format!("~{}", &pwd[home.len()..])
} else {
pwd.to_string()
};
if shortened.len() > 20 {
std::path::Path::new(&shortened)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(shortened)
} else {
shortened
}
}
}
#[derive(Debug, Default)]
pub struct FooterState {
pub data: FooterData,
}
pub struct Footer<'a> {
theme: &'a Theme,
}
impl<'a> Footer<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self { theme }
}
}
impl StatefulWidget for Footer<'_> {
type State = FooterState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if area.height < 2 || area.width < 4 {
return;
}
let styles = self.theme.to_styles();
let d = &state.data;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
Block::default()
.borders(Borders::TOP)
.border_style(styles.border)
.render(rows[0], buf);
{
let total_tokens =
d.input_tokens + d.output_tokens + d.cache_read_tokens + d.cache_write_tokens;
let pct = if d.context_window_max > 0 {
(total_tokens as f64 / d.context_window_max as f64).min(1.0)
} else {
0.0
};
let pct_display = (pct * 100.0) as f32;
let has_tokens = total_tokens > 0;
let token_style = if has_tokens {
styles.normal
} else {
styles.muted
};
let in_fmt = FooterData::fmt_count(d.input_tokens + d.cache_read_tokens);
let out_fmt = FooterData::fmt_count(d.output_tokens);
let mut left_spans: Vec<Span<'_>> = vec![
Span::styled(
format!(" \u{2191}{} \u{2193}{}", in_fmt, out_fmt),
token_style,
),
Span::styled(
format!(
" {:.1}%/{}",
pct_display,
FooterData::fmt_count(d.context_window_max)
),
token_style,
),
];
if d.session_duration_secs > 0 {
left_spans.push(Span::styled(
format!(" {}", FooterData::format_duration(d.session_duration_secs)),
styles.muted,
));
}
Paragraph::new(Line::from(left_spans))
.alignment(Alignment::Left)
.render(rows[1], buf);
}
{
let mut left_spans: Vec<Span> = Vec::new();
if let Some(ref pwd) = d.pwd {
let display = FooterData::display_path(pwd);
left_spans.push(Span::styled(format!(" {}", display), styles.muted));
}
if let Some(ref branch) = d.git_branch {
if !branch.is_empty() {
left_spans.push(Span::styled(
format!(" ({})", branch),
Style::default().fg(self.theme.colors.accent.to_ratatui()),
));
if d.git_dirty {
left_spans.push(Span::styled(
" *",
Style::default().fg(self.theme.colors.warning.to_ratatui()),
));
}
}
}
let mut right_spans: Vec<Span<'_>> = vec![];
if d.model_name.is_empty() {
right_spans.push(Span::styled(
"[no model]".to_string(),
Style::default()
.fg(self.theme.colors.primary.to_ratatui())
.add_modifier(Modifier::BOLD),
));
} else {
let model_bold = Style::default()
.fg(self.theme.colors.primary.to_ratatui())
.add_modifier(Modifier::BOLD);
let thinking_style = Style::default().fg(self.theme.colors.muted.to_ratatui());
let model_part = d.model_name.split('/').next_back().unwrap_or(&d.model_name);
let provider_part = d.model_name.split('/').next().unwrap_or("");
if !provider_part.is_empty() {
right_spans.push(Span::styled(format!("({}) ", provider_part), model_bold));
}
right_spans.push(Span::styled(model_part.to_string(), model_bold));
if let Some(ref level) = d.thinking_level {
right_spans.push(Span::styled(format!(" \u{2022} {}", level), thinking_style));
}
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Min(1)])
.split(rows[2]);
Paragraph::new(Line::from(left_spans))
.alignment(Alignment::Left)
.render(cols[0], buf);
Paragraph::new(Line::from(right_spans))
.alignment(Alignment::Right)
.render(cols[1], buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footer_data_default() {
let data = FooterData::default();
assert!(data.model_name.is_empty());
assert_eq!(data.input_tokens, 0);
}
#[test]
fn footer_data_format_duration() {
assert_eq!(FooterData::format_duration(30), "30s");
assert_eq!(FooterData::format_duration(90), "1m");
assert_eq!(FooterData::format_duration(3661), "1h1m");
}
#[test]
fn footer_data_fmt_count() {
assert_eq!(FooterData::fmt_count(500), "500");
assert_eq!(FooterData::fmt_count(1500), "1.5k");
assert_eq!(FooterData::fmt_count(1_500_000), "1.5M");
}
#[test]
fn display_path_short() {
let path = format!("{}/projects/myapp", dirs::home_dir().unwrap().display());
assert!(FooterData::display_path(&path).contains("~/projects/myapp"));
}
#[test]
fn display_path_long_truncates_to_basename() {
let path = "/Volumes/MERCURY/PROJECTS/oxi";
let displayed = FooterData::display_path(path);
assert_eq!(displayed, "oxi");
}
#[test]
fn display_path_home_short() {
let home = dirs::home_dir().unwrap();
let path = format!("{}/src", home.display());
let displayed = FooterData::display_path(&path);
assert_eq!(displayed, "~/src");
}
}