use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::tui::state::UiState;
fn token_budget_bar(used: usize, max: usize, width: usize) -> String {
if max == 0 {
return format!("{} 0%", "░".repeat(width));
}
let pct = ((used as f64 / max as f64) * 100.0).min(100.0);
let filled = ((pct / 100.0) * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
format!("{}{} {:.0}%", "█".repeat(filled), "░".repeat(empty), pct)
}
pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
if area.height >= 3 {
render_two_line(state, area, buf);
} else {
render_single_line(state, area, buf);
}
}
fn render_two_line(state: &UiState, area: Rect, buf: &mut Buffer) {
let theme = &state.theme;
let top = Rect { height: 1, ..area };
let mid = Rect {
y: area.y + 1,
height: 1,
..area
};
let bot = Rect {
y: area.y + 2,
height: 1,
..area
};
let mode_label_owned: String = if let Some(ref hive) = state.swarm_status {
let agent_count = hive.agents.len();
format!("{}:{agent_count}", hive.mode_label)
} else {
let raw_mode = state.agent_mode.as_str();
let mut s = raw_mode[..raw_mode.len().min(10)].to_string();
if let Some(c) = s.get_mut(0..1) {
c.make_ascii_uppercase();
}
s
};
let mode_label = mode_label_owned.as_str();
let spinner_w: usize = 4;
let spinner_str = if state.agent_busy {
let indicator = state.spinner.status_indicator(theme);
let iw = indicator.chars().count();
format!("{indicator}{}", " ".repeat(spinner_w.saturating_sub(iw)))
} else {
" ".repeat(spinner_w)
};
let (line1_bg, status_color) = if state.agent_busy {
(theme.status_busy_bg, theme.status_busy_fg)
} else if state.shell_mode {
(theme.warning, theme.bg)
} else {
(theme.status_bg, theme.status_fg)
};
let base = Style::default().fg(status_color).bg(line1_bg);
let mode_style = Style::default().fg(line1_bg).bg(status_color).bold();
let model_str = format!(" {}/{}", state.provider_name, state.model_name);
let left_spans: Vec<Span> = vec![
Span::styled(spinner_str, base),
Span::styled(format!(" {mode_label} "), mode_style),
Span::styled(model_str, base),
];
let left_w: usize = left_spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let effective_status =
if let crate::tui::state::ViewMode::WorkerAttached { ref agent_id } = state.view_mode {
format!("[attached: {}] Esc to detach", agent_id)
} else {
state.status_msg.clone()
};
let max_right = (area.width as usize).saturating_sub(left_w + 1);
let limit = max_right.saturating_sub(1);
let mut msg = String::new();
let mut msg_w = 0usize;
for ch in effective_status.chars() {
let cw = ch.width().unwrap_or(1);
if msg_w + cw > limit {
break;
}
msg.push(ch);
msg_w += cw;
}
let right = format!("{msg} ");
let right_w = UnicodeWidthStr::width(right.as_str());
let gap = (area.width as usize).saturating_sub(left_w + right_w);
let mut spans1 = left_spans;
spans1.push(Span::styled(" ".repeat(gap), base));
spans1.push(Span::styled(right, base.bold()));
Paragraph::new(Line::from(spans1)).render(top, buf);
let _ = mid;
Paragraph::new(" ")
.style(Style::default().bg(theme.status_line2_bg))
.render(mid, buf);
let budget_bar = token_budget_bar(state.context_used_tokens, state.context_max_tokens, 12);
let cache_str = if state.cache_hit_rate > 0.0 {
format!(" · cache {:.0}%", state.cache_hit_rate * 100.0)
} else {
String::new()
};
let stats_right = format!(
"iter:{} {}s t:{}{}{}",
state.iteration,
state.elapsed_secs,
state.tool_log.len(),
if state.checkpoint_count > 0 {
format!(" ckpt:{}", state.checkpoint_count)
} else {
String::new()
},
if !state.project_name.is_empty() {
format!(" 📁 {}", state.project_name)
} else {
String::new()
},
);
let style2 = Style::default()
.fg(theme.status_line2_fg)
.bg(theme.status_line2_bg);
let left2 = format!(" ctx {}{}", budget_bar, cache_str);
let left2_w = UnicodeWidthStr::width(left2.as_str());
let right2_w = UnicodeWidthStr::width(stats_right.as_str());
let gap2 = (area.width as usize).saturating_sub(left2_w + right2_w);
let spans2 = vec![
Span::styled(left2, style2),
Span::styled(" ".repeat(gap2), style2),
Span::styled(stats_right, style2),
];
Paragraph::new(Line::from(spans2)).render(bot, buf);
}
fn render_single_line(state: &UiState, area: Rect, buf: &mut Buffer) {
let theme = &state.theme;
let pct = if state.context_max_tokens > 0 {
(state.context_used_tokens as f64 / state.context_max_tokens as f64 * 100.0).min(100.0)
} else {
0.0
};
let tokens = state.token_stats.total_tokens();
let token_str = if tokens > 0 {
format!(" · tok:{}k({:.0}%)", tokens / 1000, pct)
} else {
String::new()
};
let spinner_str = if state.agent_busy {
format!("{} ", state.spinner.status_indicator(theme))
} else {
String::new()
};
let status = format!(
" {}{} · iter:{} · {}s · t:{}{} /help ",
spinner_str,
state.status_msg,
state.iteration,
state.elapsed_secs,
state.tool_log.len(),
token_str,
);
let style = if state.agent_busy {
Style::default()
.fg(theme.status_busy_fg)
.bg(theme.status_busy_bg)
} else if state.shell_mode {
Style::default().fg(theme.bg).bg(theme.warning)
} else {
Style::default().fg(theme.status_fg).bg(theme.status_bg)
};
Paragraph::new(status).style(style).render(area, buf);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_budget_bar_empty() {
let bar = token_budget_bar(0, 120_000, 10);
assert!(bar.contains("░░░░░░░░░░"));
assert!(bar.contains("0%"));
}
#[test]
fn test_token_budget_bar_half() {
let bar = token_budget_bar(60_000, 120_000, 10);
assert!(bar.contains("█████"));
assert!(bar.contains("50%"));
}
#[test]
fn test_token_budget_bar_full() {
let bar = token_budget_bar(120_000, 120_000, 10);
assert!(bar.contains("██████████"));
assert!(bar.contains("100%"));
}
#[test]
fn test_token_budget_bar_over() {
let bar = token_budget_bar(200_000, 120_000, 10);
assert!(bar.contains("100%"));
}
#[test]
fn test_token_budget_bar_zero_max() {
let bar = token_budget_bar(0, 0, 10);
assert!(bar.contains("0%"));
}
}