use crate::adapters::ToolAdapter;
use crate::core::budget::BudgetLevel;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HudRow {
pub pane: String,
pub tool: Option<String>,
pub used_pct: Option<u8>,
pub level: Option<BudgetLevel>,
}
pub fn hud_row(pane: &str, statusline: &str, adapters: &[&dyn ToolAdapter]) -> HudRow {
for adapter in adapters {
if let Some(used) = adapter.parse_ctx_pct(statusline) {
return HudRow {
pane: pane.to_string(),
tool: Some(adapter.name().to_string()),
used_pct: Some(used),
level: Some(BudgetLevel::classify(used)),
};
}
}
HudRow {
pane: pane.to_string(),
tool: None,
used_pct: None,
level: None,
}
}
pub fn default_adapters() -> Vec<Box<dyn ToolAdapter>> {
vec![
Box::new(crate::adapters::claude::ClaudeAdapter),
Box::new(crate::adapters::codex::CodexAdapter),
Box::new(crate::adapters::gemini::GeminiAdapter),
]
}
pub fn hud_rows(specimens: &[(String, String)]) -> Vec<HudRow> {
let adapters = default_adapters();
let refs: Vec<&dyn ToolAdapter> = adapters.iter().map(|a| a.as_ref()).collect();
specimens
.iter()
.map(|(pane, statusline)| hud_row(pane, statusline, &refs))
.collect()
}
pub const TMUX_SOCKET_ENV: &str = "FORGE_FLEET_TMUX_SOCKET";
fn tmux() -> std::process::Command {
let mut cmd = std::process::Command::new("tmux");
if let Some(socket) = std::env::var_os(TMUX_SOCKET_ENV).filter(|v| !v.is_empty()) {
cmd.arg("-L").arg(socket);
}
cmd
}
fn list_panes() -> Vec<String> {
let out = tmux()
.args([
"list-panes",
"-a",
"-F",
"#{session_name}:#{window_index}.#{pane_index}",
])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.to_string())
.collect(),
_ => Vec::new(),
}
}
const STATUSLINE_LINES: usize = 3;
pub fn last_raw_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
fn capture_statusline(pane: &str) -> String {
let out = tmux().args(["capture-pane", "-p", "-t", pane]).output();
let visible = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
_ => return String::new(),
};
last_raw_lines(&visible, STATUSLINE_LINES)
}
pub fn read_fleet() -> Vec<HudRow> {
let specimens: Vec<(String, String)> = list_panes()
.into_iter()
.map(|pane| {
let statusline = capture_statusline(&pane);
(pane, statusline)
})
.collect();
hud_rows(&specimens)
}
pub fn render_table(rows: &[HudRow], show_all: bool) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "{:<28} {:<7} {:>5} LEVEL", "PANE", "TOOL", "CTX%");
for row in rows {
if row.used_pct.is_none() && !show_all {
continue;
}
let ctx = row
.used_pct
.map(|p| format!("{p}%"))
.unwrap_or_else(|| "n/a".to_string());
let level = row.level.map(|l| l.label()).unwrap_or("n/a");
let tool = row.tool.as_deref().unwrap_or("-");
let _ = writeln!(out, "{:<28} {:<7} {:>5} {}", row.pane, tool, ctx, level);
}
out
}
pub fn strip_summary(rows: &[HudRow]) -> String {
let mut tracked: Vec<(&str, u8, BudgetLevel)> = rows
.iter()
.filter_map(|r| match (r.used_pct, r.level) {
(Some(p), Some(l)) => Some((r.pane.as_str(), p, l)),
_ => None,
})
.collect();
if tracked.is_empty() {
return "fleet: no ctx gauges".to_string();
}
tracked.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let flagged: Vec<String> = tracked
.iter()
.filter(|(_, _, l)| *l >= BudgetLevel::Prep)
.map(|(pane, pct, level)| format!("{pane} {pct}% {}", level.label()))
.collect();
let ok = tracked.len() - flagged.len();
if flagged.is_empty() {
format!("fleet: {} panes, all OK", tracked.len())
} else {
format!("fleet ({ok} OK): {}", flagged.join(" · "))
}
}
pub fn render_json(rows: &[HudRow]) -> String {
let arr: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"pane": r.pane,
"tool": r.tool,
"ctx_used_pct": r.used_pct,
"level": r.level.map(|l| l.label()),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::Value::Array(arr)).unwrap_or_else(|_| "[]".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn claude_line(used: u8) -> String {
format!("[WOLF] /ASIF [Fable 5:high] ctx:{used}% 5h:43%(~2h58m) 7d:94%(~4h8m)")
}
fn codex_line(left: u8) -> String {
format!("gpt-5.6-sol high \u{00b7} Context {left}% left \u{00b7} weekly 61% left")
}
#[test]
fn claude_statusline_reads_ctx_not_the_sibling_rate_limit_percentages() {
let rows = hud_rows(&[("wolf".into(), claude_line(36))]);
assert_eq!(rows[0].used_pct, Some(36), "{rows:?}");
assert_eq!(rows[0].tool.as_deref(), Some("claude"));
assert_eq!(rows[0].level, Some(BudgetLevel::Prep));
}
#[test]
fn codex_statusline_normalizes_remaining_to_used() {
let rows = hud_rows(&[("codex".into(), codex_line(21))]);
assert_eq!(rows[0].used_pct, Some(79));
assert_eq!(rows[0].tool.as_deref(), Some("codex"));
assert_eq!(rows[0].level, Some(BudgetLevel::Compact));
}
#[test]
fn codex_reads_the_context_gauge_not_the_sibling_weekly_gauge() {
let rows = hud_rows(&[("codex".into(), codex_line(59))]);
assert_eq!(rows[0].used_pct, Some(41), "{rows:?}");
assert_eq!(rows[0].tool.as_deref(), Some("codex"));
}
#[test]
fn polarity_boundaries() {
let claude = |u| hud_rows(&[("c".into(), claude_line(u))])[0].used_pct;
assert_eq!(claude(0), Some(0));
assert_eq!(claude(100), Some(100));
let codex = |l| hud_rows(&[("c".into(), codex_line(l))])[0].used_pct;
assert_eq!(codex(0), Some(100), "0% context left = fully used");
assert_eq!(codex(100), Some(0), "100% left = none used");
}
#[test]
fn control_one_line_diagnostic_pane_is_na() {
for line in [
"Context 5% left",
"only 5% left to process the queue",
"weekly 61% left",
"ctx:42% appears in this log line",
"[INFO] [12:30] ctx:42% starting run",
"[WARN] [09:05:11] ctx:88% retrying",
"[INFO] [priority:high] ctx:42% starting run",
"[job] [worker:high] ctx:63% dispatched",
"[worker5] ctx:42% 5h:3",
] {
let rows = hud_rows(&[("diag".into(), line.into())]);
assert_eq!(rows[0].used_pct, None, "must be n/a: {line:?}");
assert_eq!(rows[0].tool, None, "must be n/a: {line:?}");
}
}
#[test]
fn control_real_codex_statusline_reads() {
let rows = hud_rows(&[("codex".into(), codex_line(54))]);
assert_eq!(rows[0].used_pct, Some(46));
assert_eq!(rows[0].tool.as_deref(), Some("codex"));
}
#[test]
fn control_real_claude_statusline_reads() {
let rows = hud_rows(&[("wolf".into(), claude_line(43))]);
assert_eq!(rows[0].used_pct, Some(43));
assert_eq!(rows[0].tool.as_deref(), Some("claude"));
}
#[test]
fn control_sparse_pane_with_a_mid_text_percentage_is_na() {
let pane = "Context 5% left\n\n";
let region = last_raw_lines(pane, STATUSLINE_LINES);
let rows = hud_rows(&[("sparse".into(), region)]);
assert_eq!(rows[0].used_pct, None, "{rows:?}");
assert_eq!(rows[0].tool, None);
}
#[test]
fn last_raw_lines_keeps_the_last_n_lines_unfiltered() {
assert_eq!(last_raw_lines("a\nb\nc", 2), "b\nc");
assert_eq!(
last_raw_lines("x\n\ny", 3),
"x\n\ny",
"blank lines preserved (no filtering)"
);
assert_eq!(
last_raw_lines("x\ny", 5),
"x\ny",
"fewer lines than n → all"
);
}
#[test]
fn a_mid_pane_statusline_phrase_is_out_of_the_raw_window() {
let pane = format!(
"history\nContext 5% left DIAGNOSTIC\nmore\n{}\nbypass permissions\nfocus",
claude_line(43)
);
let region = last_raw_lines(&pane, STATUSLINE_LINES);
assert!(
!region.contains("DIAGNOSTIC"),
"mid-pane text out of window: {region:?}"
);
let rows = hud_rows(&[("p".into(), region)]);
assert_ne!(
rows[0].used_pct,
Some(95),
"must never read the mid-pane diagnostic"
);
}
#[test]
fn a_pane_with_no_gauge_is_na_never_guessed_or_an_error() {
let rows = hud_rows(&[("idle".into(), " bash /home/axw/projects".into())]);
assert_eq!(rows[0].used_pct, None);
assert_eq!(rows[0].tool, None);
assert_eq!(rows[0].level, None);
}
#[test]
fn adapters_do_not_cross_claim_each_others_gauges() {
let claude = hud_rows(&[("a".into(), claude_line(12))]);
assert_eq!(claude[0].tool.as_deref(), Some("claude"));
let codex = hud_rows(&[("b".into(), codex_line(44))]);
assert_eq!(codex[0].tool.as_deref(), Some("codex"));
}
#[test]
fn json_render_carries_pane_tool_pct_and_level() {
let rows = hud_rows(&[
("wolf".into(), claude_line(82)),
("idle".into(), "bash".into()),
]);
let json = render_json(&rows);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v[0]["ctx_used_pct"], 82);
assert_eq!(v[0]["level"], "STOP");
assert_eq!(v[1]["ctx_used_pct"], serde_json::Value::Null);
assert_eq!(v[1]["level"], serde_json::Value::Null);
}
#[test]
fn strip_summary_orders_by_severity_and_counts_ok() {
let rows = hud_rows(&[
("wolf".into(), claude_line(36)), ("kestrel".into(), claude_line(82)), ("dx3".into(), claude_line(12)), ("codex".into(), codex_line(5)), ("idle".into(), "bash".into()), ]);
let s = strip_summary(&rows);
assert!(s.starts_with("fleet (1 OK):"), "{s}");
let emerg = s.find("codex 95% EMERGENCY").unwrap();
let stop = s.find("kestrel 82% STOP").unwrap();
let prep = s.find("wolf 36% PREP").unwrap();
assert!(emerg < stop && stop < prep, "most-severe first: {s}");
assert!(!s.contains("idle"), "n/a panes excluded: {s}");
}
#[test]
fn strip_summary_handles_all_ok_and_empty() {
assert_eq!(
strip_summary(&hud_rows(&[("a".into(), claude_line(5))])),
"fleet: 1 panes, all OK"
);
assert_eq!(
strip_summary(&hud_rows(&[("a".into(), "bash".into())])),
"fleet: no ctx gauges"
);
}
#[test]
fn table_hides_na_panes_unless_show_all() {
let rows = hud_rows(&[
("live".into(), claude_line(40)),
("idle".into(), "bash".into()),
]);
let compact = render_table(&rows, false);
assert!(compact.contains("live"));
assert!(!compact.contains("idle"), "n/a panes hidden by default");
let all = render_table(&rows, true);
assert!(all.contains("idle"), "--all shows n/a panes");
}
}