1use crate::context_engine::ContextSnapshot;
10use crate::usage_signal::{AccountInfo, AgentUsage, UsageSnapshot};
11
12pub fn render(snapshot: &ContextSnapshot, usage: &UsageSnapshot) -> String {
13 let mut out = String::new();
14 out.push_str("# Agent HUD\n\n");
15 out.push_str(&format!(
16 "_Updated: `{}` · Source: `{}`_\n\n",
17 snapshot.updated_at, usage.source
18 ));
19
20 out.push_str("| Agent | Session (5h) | Week (7d) | Context | Model | Last turn |\n");
21 out.push_str("|---|---:|---:|---:|---|---|\n");
22 out.push_str(&format_row("Claude", &usage.claude));
23 out.push_str(&format_row("Codex", &usage.codex));
24 out.push('\n');
25
26 let active = usage.accounts.iter().find(|a| a.is_active)
27 .or_else(|| usage.accounts.first());
28 if let Some(a) = active {
29 out.push_str("## Limits\n\n");
30 out.push_str("| | 5h session | 7d week |\n");
31 out.push_str("|---|---|---|\n");
32 out.push_str(&format_limit_row(a, &usage.claude));
33 out.push('\n');
34 }
35
36 if usage.source != "python3" {
37 out.push_str(&format!(
38 "> Usage data unavailable: {}\n> Falling back to git-only signals.\n\n",
39 usage.source
40 ));
41 }
42
43 out.push_str(&format!("## Worktree\n- `{}`\n- branch `{}`\n",
44 snapshot.worktree_root, snapshot.branch));
45
46 out
47}
48
49fn format_limit_row(a: &AccountInfo, claude: &AgentUsage) -> String {
50 let plan = match a.subscription_type.as_str() {
51 "pro" => "Pro",
52 "max" => {
53 if a.rate_limit_tier.contains("20x") { "Max 20×" }
54 else if a.rate_limit_tier.contains("5x") { "Max 5×" }
55 else { "Max" }
56 }
57 _ => &a.subscription_type,
58 };
59 let cell5h = format_pct_cell(claude.session_5h_percent, a.limit_5h_messages);
60 let cell7d = format_pct_cell(claude.week_7d_percent, a.limit_7d_messages);
61 format!("| {} {} | {} | {} |\n", a.name, plan, cell5h, cell7d)
62}
63
64fn format_pct_cell(pct: Option<f64>, total: u32) -> String {
65 match pct {
66 Some(p) => {
67 let bar = ascii_bar(p, 10);
68 let used = ((p / 100.0) * total as f64).round() as u32;
69 format!("{bar} **{p:.0}%** ({used}/{total})")
70 }
71 None => {
72 if total > 0 { format!("— / {total} msgs") } else { "—".to_string() }
73 }
74 }
75}
76
77fn ascii_bar(pct: f64, width: usize) -> String {
78 let filled = ((pct / 100.0) * width as f64).round() as usize;
79 let filled = filled.min(width);
80 format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
81}
82
83fn format_row(label: &str, usage: &AgentUsage) -> String {
84 let ctx = match (usage.last_context_pct, usage.last_context_window) {
85 (Some(pct), Some(window)) => format!("{pct:.1}% of {}", format_tokens(window)),
86 (Some(pct), None) => format!("{pct:.1}%"),
87 _ => "—".to_string(),
88 };
89 let model = usage.last_model.as_deref().unwrap_or("—");
90 let last = usage.last_turn_at.as_deref().unwrap_or("—");
91 format!(
92 "| {label} | {} | {} | {ctx} | `{model}` | {last} |\n",
93 format_tokens(usage.session_5h_tokens),
94 format_tokens(usage.week_7d_tokens),
95 )
96}
97
98fn format_tokens(value: u64) -> String {
99 if value >= 1_000_000 {
100 format!("{:.2}M", value as f64 / 1_000_000.0)
101 } else if value >= 1_000 {
102 format!("{:.1}k", value as f64 / 1_000.0)
103 } else {
104 value.to_string()
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::format_tokens;
111
112 #[test]
113 fn token_formatting_uses_compact_units() {
114 assert_eq!(format_tokens(42), "42");
115 assert_eq!(format_tokens(1500), "1.5k");
116 assert_eq!(format_tokens(2_500_000), "2.50M");
117 }
118}