use koda_core::mcp::manager::McpStatusBarInfo;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Widget,
};
use std::path::Path;
const CWD_MAX_LEN: usize = 24;
pub struct StatusBar<'a> {
cwd: Option<&'a Path>,
model: &'a str,
mode_label: &'a str,
context_pct: u32,
queue_len: usize,
elapsed_secs: u64,
last_turn: Option<&'a TurnStats>,
scroll_info: Option<(usize, usize)>,
mcp_info: Option<McpStatusBarInfo>,
bg_agents: usize,
bg_processes: usize,
vim_label: Option<&'a str>,
}
#[derive(Debug, Clone, Default)]
pub struct TurnStats {
pub tokens_in: i64,
pub tokens_out: i64,
pub cache_read: i64,
pub elapsed_ms: u64,
pub rate: f64,
}
impl<'a> StatusBar<'a> {
pub fn new(model: &'a str, mode_label: &'a str, context_pct: u32) -> Self {
Self {
cwd: None,
model,
mode_label,
context_pct,
queue_len: 0,
elapsed_secs: 0,
last_turn: None,
scroll_info: None,
mcp_info: None,
bg_agents: 0,
bg_processes: 0,
vim_label: None,
}
}
pub fn with_cwd(mut self, cwd: &'a Path) -> Self {
self.cwd = Some(cwd);
self
}
pub fn with_queue(mut self, queue_len: usize) -> Self {
self.queue_len = queue_len;
self
}
pub fn with_elapsed(mut self, secs: u64) -> Self {
self.elapsed_secs = secs;
self
}
pub fn with_last_turn(mut self, stats: &'a TurnStats) -> Self {
self.last_turn = Some(stats);
self
}
pub fn with_scroll_info(mut self, offset: usize, total: usize) -> Self {
self.scroll_info = Some((offset, total));
self
}
pub fn with_mcp_info(mut self, info: McpStatusBarInfo) -> Self {
self.mcp_info = Some(info);
self
}
#[must_use]
pub fn with_bg_counts(mut self, agents: usize, processes: usize) -> Self {
self.bg_agents = agents;
self.bg_processes = processes;
self
}
pub fn with_vim_label(mut self, label: Option<&'a str>) -> Self {
self.vim_label = label;
self
}
}
fn format_cwd_compact(path: &Path, home: Option<&str>, max_len: usize) -> String {
let raw = path.to_string_lossy();
let homed: String = match home {
Some(h) if !h.is_empty() && raw.starts_with(h) => {
let rest = &raw[h.len()..];
if rest.is_empty() {
"~".to_string()
} else if rest.starts_with('/') {
format!("~{rest}")
} else {
raw.into_owned()
}
}
_ => raw.into_owned(),
};
if homed.chars().count() <= max_len {
return homed;
}
let budget = max_len.saturating_sub(2);
let chars: Vec<char> = homed.chars().collect();
let suffix: String = chars.iter().rev().take(budget).rev().collect();
let aligned = match suffix.find('/') {
Some(idx) if idx + 1 < suffix.len() => &suffix[idx + 1..],
_ => suffix.as_str(),
};
format!("β¦/{aligned}")
}
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mode_color = match self.mode_label {
"auto" => Color::Green,
"strict" => Color::Cyan,
"safe" => Color::Yellow,
_ => Color::DarkGray,
};
let bar_width: u32 = 10;
let filled = (self.context_pct * bar_width / 100).min(bar_width);
let empty = bar_width - filled;
let ctx_color = if self.context_pct >= 90 {
Color::Red
} else if self.context_pct >= 75 {
Color::Yellow
} else {
Color::DarkGray
};
let model_display = if self.model.len() > 32 {
format!("{}β¦", &self.model[..31])
} else {
self.model.to_string()
};
let mut spans = vec![];
if let Some(cwd) = self.cwd {
let home = std::env::var("HOME").ok();
let cwd_display = format_cwd_compact(cwd, home.as_deref(), CWD_MAX_LEN);
spans.push(Span::styled(
format!(" {cwd_display} "),
Style::default().fg(Color::Rgb(140, 140, 140)),
));
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
}
spans.extend([
Span::styled(
format!(" {model_display} "),
Style::default().fg(Color::DarkGray),
),
Span::styled("\u{2502}", Style::default().fg(Color::Rgb(60, 60, 60))),
Span::styled(
format!(" {} ", self.mode_label),
Style::default().fg(mode_color),
),
Span::styled("\u{2502}", Style::default().fg(Color::Rgb(60, 60, 60))),
Span::styled(
format!(
" {}{} {}%",
"\u{2588}".repeat(filled as usize),
"\u{2591}".repeat(empty as usize),
self.context_pct,
),
Style::default().fg(ctx_color),
),
]);
if let Some(label) = self.vim_label {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
spans.push(Span::styled(
format!(" VIM:{label} "),
Style::default().fg(Color::Magenta),
));
}
if let Some(mcp) = self.mcp_info {
let mcp_color = if mcp.failed == 0 {
Color::Green
} else if mcp.connected > 0 {
Color::Yellow
} else {
Color::Red
};
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
spans.push(Span::styled(
format!(" \u{26a1}{}/{} ", mcp.connected, mcp.total),
Style::default().fg(mcp_color),
));
}
if self.bg_agents > 0 || self.bg_processes > 0 {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
let mut label = String::from(" ");
if self.bg_agents > 0 {
label.push_str(&format!("\u{1f916} {}", self.bg_agents));
}
if self.bg_agents > 0 && self.bg_processes > 0 {
label.push(' ');
}
if self.bg_processes > 0 {
label.push_str(&format!("\u{2699}\u{fe0f} {}", self.bg_processes));
}
label.push(' ');
spans.push(Span::styled(label, Style::default().fg(Color::Cyan)));
}
if self.elapsed_secs > 0 {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
spans.push(Span::styled(
format!(" \u{23f3} {}s ", self.elapsed_secs),
Style::default().fg(Color::Cyan),
));
}
if self.queue_len > 0 {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
spans.push(Span::styled(
format!(" \u{1f4cb} {} queued ", self.queue_len),
Style::default().fg(Color::Yellow),
));
spans.push(Span::styled(
"^U clear ",
Style::default().fg(Color::Rgb(100, 100, 100)),
));
}
if let Some(stats) = self.last_turn {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
let time = if stats.elapsed_ms >= 1000 {
format!("{:.1}s", stats.elapsed_ms as f64 / 1000.0)
} else {
format!("{}ms", stats.elapsed_ms)
};
let mut stat_str = format!(
" β{} β{} Β· {} Β· {:.0} t/s ",
stats.tokens_in, stats.tokens_out, time, stats.rate
);
if stats.cache_read > 0 && stats.tokens_in > 0 {
let pct = (stats.cache_read * 100) / stats.tokens_in;
stat_str = format!(
" β{} β{} π{pct}% Β· {} Β· {:.0} t/s ",
stats.tokens_in, stats.tokens_out, time, stats.rate
);
}
spans.push(Span::styled(stat_str, Style::default().fg(Color::DarkGray)));
}
if let Some((offset, total)) = self.scroll_info {
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(Color::Rgb(60, 60, 60)),
));
spans.push(Span::styled(
format!(" \u{2191}{offset}/{total} "),
Style::default().fg(Color::Yellow),
));
}
Line::from(spans).render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
fn render_bar(bar: StatusBar<'_>, width: u16) -> String {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
bar.render(area, &mut buf);
(0..width)
.map(|x| buf.cell((x, 0)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn mcp_indicator_hidden_when_no_servers() {
let bar = StatusBar::new("gpt-4", "safe", 50);
let text = render_bar(bar, 120);
assert!(
!text.contains('β‘'),
"MCP indicator should be hidden: {text}"
);
}
#[test]
fn mcp_indicator_shows_connected_count() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_mcp_info(McpStatusBarInfo {
connected: 2,
failed: 0,
total: 3,
});
let text = render_bar(bar, 120);
assert!(text.contains("2/3"), "should show 2/3: {text}");
}
fn mcp_indicator_color(info: McpStatusBarInfo) -> Color {
let bar = StatusBar::new("gpt-4", "safe", 50).with_mcp_info(info);
let area = Rect::new(0, 0, 120, 1);
let mut buf = Buffer::empty(area);
bar.render(area, &mut buf);
let mcp_cell = (0..120u16)
.find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("β‘"))
.expect("should have β‘ cell");
buf.cell((mcp_cell, 0)).unwrap().fg
}
#[test]
fn mcp_color_green_when_all_connected() {
let fg = mcp_indicator_color(McpStatusBarInfo {
connected: 3,
failed: 0,
total: 3,
});
assert_eq!(fg, Color::Green, "all connected β green");
}
#[test]
fn mcp_color_yellow_when_partial() {
let fg = mcp_indicator_color(McpStatusBarInfo {
connected: 1,
failed: 1,
total: 2,
});
assert_eq!(fg, Color::Yellow, "partial β yellow");
}
#[test]
fn mcp_color_red_when_all_failed() {
let fg = mcp_indicator_color(McpStatusBarInfo {
connected: 0,
failed: 2,
total: 2,
});
assert_eq!(fg, Color::Red, "all failed β red");
}
#[test]
fn format_cwd_short_path_passes_through_with_home_substitution() {
let path = Path::new("/Users/lijun/repo/koda");
let out = format_cwd_compact(path, Some("/Users/lijun"), 32);
assert_eq!(out, "~/repo/koda");
}
#[test]
fn format_cwd_no_home_match_keeps_absolute_path() {
let path = Path::new("/srv/koda");
let out = format_cwd_compact(path, Some("/Users/lijun"), 32);
assert_eq!(out, "/srv/koda");
}
#[test]
fn format_cwd_no_home_set_keeps_absolute_path() {
let path = Path::new("/srv/koda");
let out = format_cwd_compact(path, None, 32);
assert_eq!(out, "/srv/koda");
}
#[test]
fn format_cwd_home_root_renders_as_tilde() {
let path = Path::new("/Users/lijun");
let out = format_cwd_compact(path, Some("/Users/lijun"), 32);
assert_eq!(out, "~");
}
#[test]
fn format_cwd_long_path_truncates_from_left_at_segment_boundary() {
let path = Path::new("/Users/lijun/repo/koda/koda-cli/src/widgets/status_bar.rs");
let out = format_cwd_compact(path, Some("/Users/lijun"), 24);
assert!(
out.chars().count() <= 24,
"output `{out}` exceeds budget (len {})",
out.chars().count()
);
assert!(out.starts_with("β¦/"), "missing β¦/ prefix: {out}");
assert!(out.contains("status_bar.rs"), "last segment dropped: {out}");
}
#[test]
fn format_cwd_truncation_does_not_split_segment_mid_name() {
let path = Path::new("/Users/lijun/repo/koda/widgets/status_bar.rs");
let out = format_cwd_compact(path, Some("/Users/lijun"), 22);
assert!(
!out.contains("dgets"),
"truncation cut a segment mid-name: {out}"
);
}
#[test]
fn cwd_segment_appears_in_rendered_bar_when_set() {
let p = Path::new("/tmp/short");
let bar = StatusBar::new("gpt-4", "safe", 50).with_cwd(p);
let text = render_bar(bar, 200);
assert!(text.contains("/tmp/short"), "cwd missing from bar: {text}");
}
#[test]
fn cwd_segment_hidden_when_not_set() {
let bar = StatusBar::new("gpt-4", "safe", 50);
let text = render_bar(bar, 120);
assert!(
text.trim_start().starts_with("gpt-4"),
"unexpected leading content before model: `{text}`"
);
}
#[test]
fn cwd_segment_renders_leftmost_before_model() {
let p = Path::new("/tmp/koda");
let bar = StatusBar::new("gpt-4", "safe", 50).with_cwd(p);
let text = render_bar(bar, 200);
let cwd_pos = text.find("/tmp/koda").expect("cwd should render: {text}");
let model_pos = text.find("gpt-4").expect("model should render");
assert!(
cwd_pos < model_pos,
"cwd ({cwd_pos}) must come before model ({model_pos}) in: {text}"
);
}
#[test]
fn bg_pill_hidden_when_both_counts_zero() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_bg_counts(0, 0);
let text = render_bar(bar, 200);
assert!(
!text.contains('\u{1f916}'),
"π€ should be hidden when zero agents: {text}"
);
assert!(
!text.contains('\u{2699}'),
"β should be hidden when zero processes: {text}"
);
}
#[test]
fn bg_pill_shows_only_agents_when_processes_zero() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_bg_counts(2, 0);
let text = render_bar(bar, 200);
assert!(
text.contains('\u{1f916}') && text.contains('2'),
"agents pill missing or wrong count: {text}"
);
assert!(
!text.contains('\u{2699}'),
"process gear must NOT render when count=0: {text}"
);
}
#[test]
fn bg_pill_shows_only_processes_when_agents_zero() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_bg_counts(0, 3);
let text = render_bar(bar, 200);
assert!(
text.contains('\u{2699}') && text.contains('3'),
"processes pill missing or wrong count: {text}"
);
assert!(
!text.contains('\u{1f916}'),
"agent robot must NOT render when count=0: {text}"
);
}
#[test]
fn bg_pill_shows_both_when_both_nonzero() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_bg_counts(2, 5);
let text = render_bar(bar, 200);
let agent_pos = text.find('\u{1f916}').expect("agent emoji missing");
let proc_pos = text.find('\u{2699}').expect("process gear missing");
assert!(
agent_pos < proc_pos,
"agents must render before processes: {text}"
);
assert!(text.contains('2') && text.contains('5'));
}
#[test]
fn bg_pill_renders_in_cyan_to_match_inflight_palette() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_bg_counts(1, 0);
let area = Rect::new(0, 0, 200, 1);
let mut buf = Buffer::empty(area);
bar.render(area, &mut buf);
let cell_x = (0..200u16)
.find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("\u{1f916}"))
.expect("π€ cell missing from rendered buffer");
assert_eq!(
buf.cell((cell_x, 0)).unwrap().fg,
Color::Cyan,
"bg pill must render in cyan (in-flight palette)"
);
}
#[test]
fn vim_segment_hidden_when_label_is_none() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_vim_label(None);
let out = render_bar(bar, 200);
assert!(
!out.contains("VIM"),
"vim segment must not render when label is None: {out}"
);
}
#[test]
fn vim_segment_renders_label_when_some() {
let bar = StatusBar::new("gpt-4", "safe", 50).with_vim_label(Some("NORMAL"));
let out = render_bar(bar, 200);
assert!(
out.contains("VIM:NORMAL"),
"vim pill must render label: {out}"
);
}
}