use std::path::{Path, PathBuf};
use crate::agent::tools::background::BackgroundStore;
use crate::agent::tools::bg_shell::BackgroundShellStore;
use crate::session::Session;
pub struct StatusLine;
fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{}k", n / 1000)
} else {
n.to_string()
}
}
fn git_branch(start: &Path) -> Option<String> {
let head_path = find_git_head(start)?;
let head = std::fs::read_to_string(head_path).ok()?;
let head = head.trim();
head.strip_prefix("ref: refs/heads/").map(|b| b.to_string())
}
fn find_git_head(start: &Path) -> Option<PathBuf> {
let mut cur: PathBuf = start.to_path_buf();
loop {
let dot_git = cur.join(".git");
if dot_git.is_dir() {
return Some(dot_git.join("HEAD"));
}
if dot_git.is_file() {
let txt = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = txt.trim().strip_prefix("gitdir: ")?;
return Some(PathBuf::from(gitdir).join("HEAD"));
}
if !cur.pop() {
return None;
}
}
}
fn cached_git_branch(start: &Path) -> Option<String> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
const TTL: Duration = Duration::from_secs(3);
static CACHE: Mutex<Option<(Instant, PathBuf, Option<String>)>> = Mutex::new(None);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((at, dir, branch)) = guard.as_ref()
&& dir.as_path() == start
&& at.elapsed() < TTL
{
return branch.clone();
}
let fresh = git_branch(start);
*guard = Some((Instant::now(), start.to_path_buf(), fresh.clone()));
fresh
}
impl StatusLine {
#[allow(clippy::too_many_arguments)]
pub fn render(
session: &Session,
is_running: bool,
_spinner_tick: u64,
loop_label: Option<&str>,
prompt_name: Option<&str>,
perm_mode: Option<&str>,
bg_store: Option<&BackgroundStore>,
shell_store: Option<&BackgroundShellStore>,
sandbox_badge: Option<&'static str>,
) -> String {
let state = if is_running { "running" } else { "ready" };
let wd_path = Path::new(&session.working_dir);
let dir = wd_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&session.working_dir);
let project_label = match cached_git_branch(wd_path) {
Some(b) => format!("{}:{}", dir, b),
None => dir.to_string(),
};
let ctx =
crate::agent::agent_loop::context_manager::compaction_budget(session.context_window);
let used = session.total_estimated_tokens;
let pct = (used * 100).checked_div(ctx).unwrap_or(0);
let cost_str = String::new();
let compact_badge = if session.compactions.is_empty() {
String::new()
} else {
format!(" cmp:{}", session.compactions.len())
};
let loop_badge = match loop_label {
Some(label) => format!(" [{}]", label),
None => String::new(),
};
let prompt_badge = match prompt_name {
Some(name) => format!(" [{}]", name),
None => String::new(),
};
let perm_badge = match perm_mode {
Some(m) if m != "standard" => format!(" | mode:{}", m),
_ => String::new(),
};
let active_agents = bg_store.map(|s| s.running_count()).unwrap_or(0);
let active_shells = shell_store.map(|s| s.running_count()).unwrap_or(0);
let agents_badge = if active_agents > 0 {
format!(" | agents:{}", active_agents)
} else {
String::new()
};
let shells_badge = if active_shells > 0 {
format!(" | shells:{}", active_shells)
} else {
String::new()
};
let sandbox_badge_str = match sandbox_badge {
Some(label) => format!(" | sbx:{}", label),
None => String::new(),
};
let session_badge = format!(" session:{}", crate::text::short_id(session.id.as_str()));
format!(
"{}{} | {}{} | {}/{} ({}%) | {}msgs | {}{}{}{}{}{}{}{}",
project_label,
cost_str,
session.model,
loop_badge,
fmt_tokens(used),
fmt_tokens(ctx),
pct,
session.messages.len(),
state,
compact_badge,
sandbox_badge_str,
prompt_badge,
perm_badge,
agents_badge,
shells_badge,
session_badge,
)
}
}
#[cfg(test)]
mod tests {
use super::{StatusLine, cached_git_branch, git_branch};
use crate::agent::tools::background::BackgroundStore;
use crate::agent::tools::bg_shell::BackgroundShellStore;
use crate::session::Session;
use std::path::Path;
#[test]
fn cached_git_branch_matches_direct_and_caches() {
let p = Path::new(".");
let direct = git_branch(p);
let cached = cached_git_branch(p);
assert_eq!(direct, cached);
assert_eq!(cached_git_branch(p), cached);
}
fn agent_store(agents: usize) -> BackgroundStore {
let store = BackgroundStore::new();
for n in 0..agents {
let id = format!("a{n}");
store.insert(id.clone());
if tokio::runtime::Handle::try_current().is_ok() {
store.attach_handle(&id, tokio::spawn(std::future::pending::<()>()));
}
}
store
}
fn shell_store(shells: usize) -> BackgroundShellStore {
let store = BackgroundShellStore::new();
for n in 0..shells {
store.register(format!("s{n}"), "cmd".to_string());
}
store
}
fn render(agents: usize, shells: usize) -> String {
let session = Session::new("openrouter", "test-model", 100_000);
let a = agent_store(agents);
let s = shell_store(shells);
StatusLine::render(
&session,
false,
0,
None,
None,
None,
Some(&a),
Some(&s),
None,
)
}
#[tokio::test]
async fn badges_hidden_when_nothing_active() {
let line = render(0, 0);
assert!(
!line.contains("agents:"),
"no agents badge expected: {line}"
);
assert!(
!line.contains("shells:"),
"no shells badge expected: {line}"
);
}
#[tokio::test]
async fn agents_and_shells_counted_separately() {
let line = render(2, 3);
assert!(line.contains("agents:2"), "expected agents:2 in: {line}");
assert!(line.contains("shells:3"), "expected shells:3 in: {line}");
}
#[tokio::test]
async fn session_id_appears_in_status_line() {
let session = Session::new("openrouter", "test-model", 100_000);
let line = StatusLine::render(&session, false, 0, None, None, None, None, None, None);
let expected = format!(" session:{}", crate::text::short_id(session.id.as_str()));
assert!(line.contains(&expected), "session id not in status: {line}");
}
}