use crate::agent::types::Usage;
use crate::agent::ui::messages::pad_to_width;
use crate::agent::ui::theme::RabTheme;
use crate::tui::util::{truncate_to_width, visible_width};
fn sanitize_status_text(text: &str) -> String {
text.replace(['\r', '\n', '\t'], " ")
.split(' ')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub fn format_tokens(count: u64) -> String {
if count < 1000 {
return count.to_string();
}
if count < 10000 {
return format!("{:.1}k", count as f64 / 1000.0);
}
if count < 1_000_000 {
return format!("{}k", (count as f64 / 1000.0).round() as u64);
}
if count < 10_000_000 {
return format!("{:.1}M", count as f64 / 1_000_000.0);
}
format!("{}M", (count as f64 / 1_000_000.0).round() as u64)
}
pub fn format_cwd_for_footer(cwd: &str, home: Option<&str>) -> String {
let home = match home {
Some(h) => h,
None => return cwd.to_string(),
};
let resolved_cwd = std::path::Path::new(cwd);
let resolved_home = std::path::Path::new(home);
let relative = match resolved_cwd.strip_prefix(resolved_home) {
Ok(rest) => {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
rest.to_string_lossy().to_string()
}
Err(_) => return cwd.to_string(),
};
format!("~/{}", relative)
}
pub struct Footer {
cwd: String,
git_branch: Option<String>,
session_name: Option<String>,
total_input: u64,
total_output: u64,
total_cache_read: u64,
total_cache_write: u64,
total_cost: f64,
latest_cache_hit_rate: Option<f64>,
context_percent: Option<f64>,
context_window: u64,
auto_compact: bool,
model: String,
model_supports_reasoning: bool,
thinking_level: Option<String>,
available_provider_count: usize,
using_subscription: bool,
experimental_enabled: bool,
pub extension_statuses: Vec<(String, String)>,
theme: RabTheme,
}
impl Footer {
pub fn new(cwd: impl Into<String>) -> Self {
let theme = crate::agent::ui::theme::current_theme().clone();
Self {
cwd: cwd.into(),
git_branch: None,
session_name: None,
total_input: 0,
total_output: 0,
total_cache_read: 0,
total_cache_write: 0,
total_cost: 0.0,
latest_cache_hit_rate: None,
context_percent: None,
context_window: 0,
auto_compact: true,
model: String::new(),
model_supports_reasoning: false,
thinking_level: None,
available_provider_count: 1,
using_subscription: false,
experimental_enabled: false,
extension_statuses: Vec::new(),
theme,
}
}
pub fn set_cwd(&mut self, cwd: impl Into<String>) {
self.cwd = cwd.into();
}
pub fn set_git_branch(&mut self, branch: Option<String>) {
self.git_branch = branch;
}
pub fn set_session_name(&mut self, name: Option<String>) {
self.session_name = name;
}
pub fn set_model(&mut self, model: impl Into<String>) {
self.model = model.into();
}
pub fn set_model_supports_reasoning(&mut self, supports: bool) {
self.model_supports_reasoning = supports;
}
pub fn set_thinking_level(&mut self, level: Option<String>) {
self.thinking_level = level;
}
pub fn set_auto_compact(&mut self, enabled: bool) {
self.auto_compact = enabled;
}
pub fn set_available_provider_count(&mut self, count: usize) {
self.available_provider_count = count;
}
pub fn set_using_subscription(&mut self, using: bool) {
self.using_subscription = using;
}
pub fn set_experimental_enabled(&mut self, enabled: bool) {
self.experimental_enabled = enabled;
}
pub fn accumulate_usage(&mut self, usage: &Usage) {
let input = usage.input_tokens.unwrap_or(0) as u64;
let output = usage.output_tokens.unwrap_or(0) as u64;
let cache_read = usage.cache_tokens.unwrap_or(0) as u64;
self.total_input += input;
self.total_output += output;
self.total_cache_read += cache_read;
let total_prompt = input + cache_read;
if total_prompt > 0 {
self.latest_cache_hit_rate = Some((cache_read as f64 / total_prompt as f64) * 100.0);
}
}
pub fn set_usage(
&mut self,
total_input: u64,
total_output: u64,
total_cache_read: u64,
total_cache_write: u64,
total_cost: f64,
latest_cache_hit_rate: Option<f64>,
) {
self.total_input = total_input;
self.total_output = total_output;
self.total_cache_read = total_cache_read;
self.total_cache_write = total_cache_write;
self.total_cost = total_cost;
self.latest_cache_hit_rate = latest_cache_hit_rate;
}
pub fn set_cost(&mut self, cost: f64) {
self.total_cost = cost;
}
pub fn set_cache_write(&mut self, cache_write: u64) {
self.total_cache_write = cache_write;
}
pub fn set_streaming(&mut self, _streaming: bool) {
}
pub fn set_context(&mut self, percent: Option<f64>, window: u64) {
self.context_percent = percent;
self.context_window = window;
}
pub fn set_extension_status(&mut self, key: String, text: Option<String>) {
if let Some(text) = text {
if let Some(pos) = self.extension_statuses.iter().position(|(k, _)| k == &key) {
self.extension_statuses[pos].1 = text;
} else {
self.extension_statuses.push((key, text));
}
} else {
self.extension_statuses.retain(|(k, _)| k != &key);
}
self.extension_statuses.sort_by(|(a, _), (b, _)| a.cmp(b));
}
pub fn clear_extension_statuses(&mut self) {
self.extension_statuses.clear();
}
}
impl crate::tui::Component for Footer {
fn render(&self, width: usize) -> Vec<String> {
let w = width;
if w < 4 {
return vec![]; }
let theme = &self.theme;
let home = std::env::var("HOME").ok();
let mut pwd = format_cwd_for_footer(&self.cwd, home.as_deref());
if let Some(ref branch) = self.git_branch {
pwd = format!("{} ({})", pwd, branch);
}
if let Some(ref name) = self.session_name {
pwd = format!("{} • {}", pwd, name);
}
let pwd_line = truncate_to_width(&theme.fg("dim", &pwd), w, &theme.fg("dim", "..."), true);
let pwd_line = if pwd_line.is_empty() {
String::new()
} else {
pad_to_width(&pwd_line, w)
};
let mut stats_parts: Vec<String> = Vec::new();
if self.total_input > 0 {
stats_parts.push(format!("↑{}", format_tokens(self.total_input)));
}
if self.total_output > 0 {
stats_parts.push(format!("↓{}", format_tokens(self.total_output)));
}
if self.total_cache_read > 0 {
stats_parts.push(format!("R{}", format_tokens(self.total_cache_read)));
}
if self.total_cache_write > 0 {
stats_parts.push(format!("W{}", format_tokens(self.total_cache_write)));
}
if (self.total_cache_read > 0 || self.total_cache_write > 0)
&& let Some(hit_rate) = self.latest_cache_hit_rate
{
stats_parts.push(format!("CH{:.1}%", hit_rate));
}
if self.total_cost > 0.0 || self.using_subscription {
let cost_str = if self.using_subscription {
format!("${:.3} (sub)", self.total_cost)
} else {
format!("${:.3}", self.total_cost)
};
stats_parts.push(cost_str);
}
let context_percent_str = match self.context_percent {
Some(p) => {
let window_str = format_tokens(self.context_window);
let display = if self.auto_compact {
format!("{:.1}%/{} (auto)", p, window_str)
} else {
format!("{:.1}%/{}", p, window_str)
};
if p > 90.0 {
theme.fg("error", &display)
} else if p > 70.0 {
theme.fg("warning", &display)
} else {
display
}
}
None => {
let window_str = format_tokens(self.context_window);
if self.auto_compact {
format!("?/{} (auto)", window_str)
} else {
format!("?/{}", window_str)
}
}
};
if !context_percent_str.is_empty() {
stats_parts.push(context_percent_str);
}
if self.experimental_enabled {
stats_parts.push(format!(
"{} {}",
theme.fg("dim", "•"),
theme.bold(&theme.fg("warning", "xp"))
));
}
let mut stats_left = stats_parts.join(" ");
let model_name = if self.model.is_empty() {
"no-model".to_string()
} else {
self.model
.strip_prefix("opencode_go::")
.unwrap_or(&self.model)
.to_string()
};
let right_side_without_provider = if self.model_supports_reasoning {
match &self.thinking_level {
Some(level) if level != "off" => format!("{} • {}", model_name, level),
_ => format!("{} • thinking off", model_name),
}
} else {
model_name.clone()
};
let right_side = if self.available_provider_count > 1 && !self.model.is_empty() {
let model_with_provider = format!("(?) {}", right_side_without_provider);
model_with_provider
} else {
right_side_without_provider.clone()
};
let mut stats_left_width = visible_width(&stats_left);
if stats_left_width > w {
stats_left = truncate_to_width(&stats_left, w, "…", true);
stats_left_width = visible_width(&stats_left);
}
let right_side_width = visible_width(&right_side);
let min_padding: usize = 2;
let stats_line = if stats_left_width + min_padding + right_side_width <= w {
let padding = " ".repeat(w - stats_left_width - right_side_width);
format!("{}{}{}", stats_left, padding, right_side)
} else if !self.model.is_empty()
&& self.available_provider_count > 1
&& stats_left_width + min_padding + visible_width(&right_side_without_provider) <= w
{
let padding =
" ".repeat(w - stats_left_width - visible_width(&right_side_without_provider));
format!("{}{}{}", stats_left, padding, right_side_without_provider)
} else {
let available_for_right = w.saturating_sub(stats_left_width + min_padding);
if available_for_right > 0 {
let truncated_right = truncate_to_width(&right_side, available_for_right, "", true);
let truncated_right_width = visible_width(&truncated_right);
let padding = " ".repeat(w - stats_left_width - truncated_right_width);
format!("{}{}{}", stats_left, padding, truncated_right)
} else {
stats_left.clone()
}
};
let dim_stats_left = theme.fg("dim", &stats_left);
let remainder = &stats_line[stats_left.len()..]; let dim_remainder = theme.fg("dim", remainder);
let stats_line_formatted = format!("{}{}", dim_stats_left, dim_remainder);
let mut lines = vec![pwd_line, stats_line_formatted];
if !self.extension_statuses.is_empty() {
let status_text: Vec<String> = self
.extension_statuses
.iter()
.map(|(_, text)| sanitize_status_text(text))
.collect();
let status_line = status_text.join(" ");
let truncated = truncate_to_width(&status_line, w, &theme.fg("dim", "..."), true);
let status_line = if truncated.is_empty() {
String::new()
} else {
pad_to_width(&truncated, w)
};
if !status_line.trim().is_empty() {
lines.push(status_line);
}
}
lines
}
fn invalidate(&mut self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::Component;
fn make_footer() -> Footer {
crate::agent::ui::theme::init_theme(Some("dark"), false);
let mut footer = Footer::new("/home/user/project");
footer.set_model("test-model");
footer.set_git_branch(Some("main".into()));
footer
}
#[test]
fn test_format_cwd_home() {
let result = format_cwd_for_footer("/home/user/project", Some("/home/user"));
assert_eq!(result, "~/project");
}
#[test]
fn test_format_cwd_home_exact() {
let result = format_cwd_for_footer("/home/user", Some("/home/user"));
assert_eq!(result, "~");
}
#[test]
fn test_format_cwd_outside_home() {
let result = format_cwd_for_footer("/opt/app", Some("/home/user"));
assert_eq!(result, "/opt/app");
}
#[test]
fn test_format_cwd_no_home() {
let result = format_cwd_for_footer("/some/path", None::<&str>);
assert_eq!(result, "/some/path");
}
#[test]
fn test_format_tokens_under_1k() {
assert_eq!(format_tokens(500), "500");
}
#[test]
fn test_format_tokens_1k_to_10k() {
assert_eq!(format_tokens(5500), "5.5k");
}
#[test]
fn test_format_tokens_10k_to_1m() {
assert_eq!(format_tokens(55500), "56k");
}
#[test]
fn test_format_tokens_1m_to_10m() {
assert_eq!(format_tokens(5_500_000), "5.5M");
}
#[test]
fn test_format_tokens_over_10m() {
assert_eq!(format_tokens(55_000_000), "55M");
}
#[test]
fn test_sanitize_status() {
assert_eq!(sanitize_status_text("hello\nworld"), "hello world");
assert_eq!(sanitize_status_text("hello\tworld"), "hello world");
assert_eq!(sanitize_status_text("hello\r\nworld"), "hello world");
assert_eq!(sanitize_status_text(" spaced "), "spaced");
}
#[test]
fn test_footer_shows_cwd() {
let footer = make_footer();
let lines = footer.render(80);
assert!(lines.len() >= 2, "Should have at least 2 lines");
assert!(lines[0].contains("project"), "Should show cwd");
}
#[test]
fn test_footer_shows_git_branch() {
let footer = make_footer();
let lines = footer.render(80);
assert!(lines[0].contains("main"), "Should show git branch");
}
#[test]
fn test_footer_shows_session_name() {
let mut footer = make_footer();
footer.set_session_name(Some("my-session".into()));
let lines = footer.render(80);
assert!(lines[0].contains("my-session"), "Should show session name");
}
#[test]
fn test_cwd_truncated_to_width() {
let mut footer = Footer::new("/very/long/path/that/exceeds/available/width/completely");
footer.set_model("model");
let lines = footer.render(30);
assert!(lines.len() >= 2);
for line in &lines {
assert!(
visible_width(line) <= 30,
"Line '{}' exceeds width 30",
line
);
}
}
#[test]
fn test_footer_shows_model() {
let footer = make_footer();
let lines = footer.render(80);
assert!(lines[1].contains("test-model"), "Should show model name");
}
#[test]
fn test_footer_shows_no_model() {
let mut footer = Footer::new("/path");
footer.set_model("");
let lines = footer.render(80);
assert!(
lines[1].contains("no-model"),
"Should show 'no-model' when model not set"
);
}
#[test]
fn test_footer_shows_thinking_level() {
let mut footer = make_footer();
footer.set_model_supports_reasoning(true);
footer.set_thinking_level(Some("high".into()));
let lines = footer.render(80);
assert!(lines[1].contains("high"), "Should show thinking level");
}
#[test]
fn test_footer_thinking_off_with_reasoning() {
let mut footer = make_footer();
footer.set_model_supports_reasoning(true);
footer.set_thinking_level(Some("off".into()));
let lines = footer.render(80);
assert!(
lines[1].contains("thinking off"),
"Should show 'thinking off' when reasoning model has level off"
);
}
#[test]
fn test_footer_shows_token_usage() {
let mut footer = make_footer();
let usage = Usage {
input_tokens: Some(1500),
output_tokens: Some(500),
cache_tokens: None,
};
footer.accumulate_usage(&usage);
let lines = footer.render(80);
assert!(lines[1].contains("↑"), "Should show input tokens");
assert!(lines[1].contains("↓"), "Should show output tokens");
}
#[test]
fn test_footer_usage_multiple_calls() {
let mut footer = make_footer();
let u1 = Usage {
input_tokens: Some(1000),
output_tokens: Some(500),
cache_tokens: None,
};
footer.accumulate_usage(&u1);
let u2 = Usage {
input_tokens: Some(2000),
output_tokens: Some(300),
cache_tokens: None,
};
footer.accumulate_usage(&u2);
let lines = footer.render(80);
assert!(lines[1].contains("↑3.0k"), "Should show accumulated input");
assert!(lines[1].contains("↓800"), "Should show accumulated output");
}
#[test]
fn test_footer_shows_cache_hit_rate() {
let mut footer = make_footer();
let usage = Usage {
input_tokens: Some(1000),
output_tokens: Some(500),
cache_tokens: Some(200),
};
footer.accumulate_usage(&usage);
let lines = footer.render(80);
assert!(
lines[1].contains("CH"),
"Should show cache hit rate when cache tokens present"
);
assert!(
lines[1].contains("CH16.7%"),
"Should show correct cache hit rate"
);
}
#[test]
fn test_footer_shows_cost() {
let mut footer = make_footer();
footer.set_cost(0.0123);
let lines = footer.render(80);
assert!(lines[1].contains("$0.012"), "Should show cost");
}
#[test]
fn test_footer_shows_subscription_indicator() {
let mut footer = make_footer();
footer.set_cost(0.0);
footer.set_using_subscription(true);
let lines = footer.render(80);
assert!(
lines[1].contains("(sub)"),
"Should show subscription indicator"
);
}
#[test]
fn test_footer_shows_auto_compact_next_to_context() {
let mut footer = make_footer();
footer.set_auto_compact(true);
footer.set_context(Some(50.0), 128000);
let lines = footer.render(80);
assert!(
lines[1].contains("(auto)"),
"Should show (auto) next to context percentage"
);
assert!(
lines[1].contains("50.0%/128k (auto)"),
"Should show context percent with auto compact"
);
}
#[test]
fn test_footer_hides_auto_compact_when_disabled() {
let mut footer = make_footer();
footer.set_auto_compact(false);
footer.set_context(Some(50.0), 128000);
let lines = footer.render(80);
assert!(
!lines[1].contains("(auto)"),
"Should NOT show (auto) when disabled"
);
}
#[test]
fn test_footer_context_percent_high() {
let mut footer = make_footer();
footer.set_context(Some(95.0), 128000);
let lines = footer.render(80);
assert!(lines[1].contains("95"), "Should show context percent");
assert!(
lines[1].contains("128k"),
"Should show formatted window size"
);
assert!(
lines[1].contains("\x1b[38;2;"),
"Should have ANSI color for high context"
);
}
#[test]
fn test_footer_context_without_percent() {
let mut footer = make_footer();
footer.set_context(None, 64000);
let lines = footer.render(80);
assert!(lines[1].contains("?"), "Should show unknown context");
assert!(lines[1].contains("64k"), "Should show context window size");
}
#[test]
fn test_footer_shows_extension_statuses() {
let mut footer = make_footer();
footer.set_extension_status("ext1".into(), Some("ready".into()));
let lines = footer.render(80);
assert!(lines.len() >= 3, "Should have 3 lines");
assert!(lines[2].contains("ready"), "Should show extension status");
}
#[test]
fn test_footer_extension_status_sorted() {
let mut footer = make_footer();
footer.set_extension_status("z_last".into(), Some("last".into()));
footer.set_extension_status("a_first".into(), Some("first".into()));
let lines = footer.render(80);
if lines.len() >= 3 {
let first_idx = lines[2].find("first");
let last_idx = lines[2].find("last");
assert!(
first_idx < last_idx,
"Extension statuses should be sorted by key"
);
}
}
#[test]
fn test_footer_extension_status_sanitized() {
let mut footer = make_footer();
footer.set_extension_status("ext1".into(), Some("hello\nworld\ttab".into()));
let lines = footer.render(80);
if lines.len() >= 3 {
assert!(
!lines[2].contains('\n'),
"Extension status should not contain newlines"
);
assert!(
!lines[2].contains('\t'),
"Extension status should not contain tabs"
);
}
}
#[test]
fn test_footer_extension_status_removed() {
let mut footer = make_footer();
footer.set_extension_status("ext1".into(), Some("ready".into()));
footer.set_extension_status("ext1".into(), None);
let lines = footer.render(80);
assert!(
lines.len() < 3 || !lines[2].contains("ready"),
"Extension status should be removed"
);
}
#[test]
fn test_footer_handles_narrow_terminal() {
let mut footer = make_footer();
footer.set_model_supports_reasoning(true);
footer.set_thinking_level(Some("high".into()));
let usage = Usage {
input_tokens: Some(100000),
output_tokens: Some(50000),
cache_tokens: Some(10000),
};
footer.accumulate_usage(&usage);
footer.set_context(Some(45.0), 128000);
let lines = footer.render(10);
assert!(!lines.is_empty(), "Should render even at width 10");
for line in &lines {
assert!(
visible_width(line) <= 10,
"Line '{}' exceeds width 10",
line
);
}
}
#[test]
fn test_footer_handles_very_narrow_terminal() {
let footer = make_footer();
let lines = footer.render(3);
assert!(lines.is_empty(), "Should return empty at width 3");
}
#[test]
fn test_footer_stats_not_truncated_when_room() {
let mut footer = make_footer();
let usage = Usage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_tokens: None,
};
footer.accumulate_usage(&usage);
let lines = footer.render(80);
assert!(lines[1].contains("↑100"), "Should show full token count");
assert!(lines[1].contains("↓50"), "Should show full token count");
}
#[test]
fn test_footer_line2_exact_width() {
let footer = make_footer();
let lines = footer.render(80);
for line in &lines {
let vw = visible_width(line);
assert!(vw <= 80, "Line width {} > 80", vw);
}
}
#[test]
fn test_footer_line2_padded_correctly() {
let footer = make_footer();
for w in [40, 60, 80, 120] {
let lines = footer.render(w);
for line in &lines {
let vw = visible_width(line);
assert!(vw <= w, "At width {}: line width {} exceeds", w, vw);
}
}
}
#[test]
fn test_footer_model_strip_prefix() {
let mut footer = make_footer();
footer.set_model("opencode_go::claude-opus");
let lines = footer.render(80);
assert!(
!lines[1].contains("opencode_go::"),
"Should strip opencode_go:: prefix"
);
assert!(
lines[1].contains("claude-opus"),
"Should show model after prefix"
);
}
#[test]
fn test_footer_provider_prefix_when_multiple_providers() {
let mut footer = make_footer();
footer.set_model("test-model");
footer.set_available_provider_count(2);
let lines = footer.render(80);
assert!(
lines[1].contains("(?)"),
"Should show provider count-based prefix"
);
}
#[test]
fn test_footer_experimental_indicator() {
let mut footer = make_footer();
footer.set_experimental_enabled(true);
let lines = footer.render(80);
assert!(
lines[1].contains("xp"),
"Should show experimental indicator"
);
}
}