Skip to main content

context_bar_core/
detail_html.rs

1//! Detail page renderer.
2//!
3//! Produces `~/.context-bar/detail.html`, a self-contained dark-themed HTML
4//! export. The macOS menubar app uses a native AppKit window; this file exists
5//! as a standalone artifact for direct local viewing or sharing.
6
7use crate::usage_signal::{AccountInfo, AgentUsage, DailyInstance, NamedBucket, SessionRecord, TimeBucket, ToolSummary, UsageSnapshot};
8
9pub fn render(snap: &UsageSnapshot) -> String {
10    let lang = Language::detect();
11    let updated = snap.collected_at.as_deref().unwrap_or("-");
12    let mut html = String::new();
13    html.push_str(HEAD);
14    html.push_str(r#"<div class="app-shell">"#);
15    html.push_str(&format!(
16        r#"<header><div class="title-block"><h1>{}</h1><div class="muted">{} {}</div></div></header>"#,
17        lang.text("Agent Usage Detail", "Ajan Kullanım Detayı"),
18        lang.text("Updated", "Güncellendi"),
19        html_escape(updated),
20    ));
21    html.push_str(
22        &format!(r#"<nav class="tabs"><div class="seg-ctrl">
23  <button class="tab-btn active" data-tab="today">{}</button>
24  <button class="tab-btn" data-tab="cost">{}</button>
25  <button class="tab-btn" data-tab="history">{}</button>
26  <button class="tab-btn" data-tab="sessions">{}</button>
27  <button class="tab-btn" data-tab="breakdown">{}</button>
28</div></nav>"#,
29            lang.text("Today", "Bugün"),
30            lang.text("Cost", "Maliyet"),
31            lang.text("History", "Geçmiş"),
32            lang.text("Sessions", "Oturumlar"),
33            lang.text("Breakdown", "Kırılım"),
34        ),
35    );
36    html.push_str(r#"<main>"#);
37    html.push_str(&panel("today", true, &render_today(snap, lang)));
38    html.push_str(&panel("cost", false, &render_cost(snap, lang)));
39    html.push_str(&panel("history", false, &render_history(snap, lang)));
40    html.push_str(&panel("sessions", false, &render_sessions(snap, lang)));
41    html.push_str(&panel("breakdown", false, &render_breakdown(snap, lang)));
42    html.push_str(r#"</main>"#);
43    html.push_str(r#"</div>"#);
44    html.push_str(FOOT);
45    html
46}
47
48use crate::i18n::Language;
49
50fn panel(id: &str, active: bool, body: &str) -> String {
51    let class = if active {
52        "tab-panel active"
53    } else {
54        "tab-panel"
55    };
56    format!(r#"<div class="{class}" id="tab-{id}">{body}</div>"#)
57}
58
59fn render_today(snap: &UsageSnapshot, lang: Language) -> String {
60    let mut out = format!(
61        r#"<section class="today-grid">{}{}</section>"#,
62        today_agent("Claude", &snap.claude, lang),
63        today_agent("Codex", &snap.codex, lang)
64    );
65    if !snap.accounts.is_empty() {
66        out.push_str(&render_accounts(&snap.accounts, &snap.claude, lang));
67    }
68    if !snap.others.is_empty() {
69        out.push_str(&render_other_tools(&snap.others, lang));
70    }
71    out
72}
73
74fn render_cost(snap: &UsageSnapshot, lang: Language) -> String {
75    let note = format!(
76        r#"<div class="cost-note">{}</div>"#,
77        lang.text(
78            "Estimated API-equivalent cost. You're on a subscription, so these are not billed amounts — they show what the metered API would charge, priced from the LiteLLM rate table (same source as ccusage).",
79            "Tahmini API-eşdeğeri maliyet. Abonelik kullandığınız için bunlar faturalandırılan tutarlar değildir — ölçümlü API'nin ne kadar ücretlendireceğini gösterir; oranlar LiteLLM tablosundan (ccusage ile aynı kaynak) alınır.",
80        ),
81    );
82    format!(
83        r#"<div class="stack">{note}{}{}</div>"#,
84        cost_agent("Claude", &snap.claude, lang),
85        cost_agent("Codex", &snap.codex, lang),
86    )
87}
88
89fn cost_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
90    let mut tiles_inner = format!(
91        "{}{}{}{}",
92        metric(lang.text("Today", "Bugün"), &format_usd(usage.cost_today)),
93        metric(lang.text("Last 7 days", "Son 7 gün"), &format_usd(usage.cost_7d)),
94        metric(lang.text("Last 30 days", "Son 30 gün"), &format_usd(usage.total_cost_30d)),
95        metric(
96            lang.text("30d in / out", "30g girdi / çıktı"),
97            &format!("{} / {}", format_tokens(usage.total_input_30d), format_tokens(usage.total_output_30d)),
98        ),
99    );
100    if usage.cache_savings_30d > 0.0 {
101        tiles_inner.push_str(&metric(
102            lang.text("30d cache saved", "30g önbellek tasarrufu"),
103            &format_usd(usage.cache_savings_30d),
104        ));
105    }
106    let tiles = format!(r#"<div class="metric-grid cost-tiles">{tiles_inner}</div>"#);
107    format!(
108        r#"<section class="agent-section"><h2>{}</h2>{tiles}{}</section>"#,
109        html_escape(name),
110        daily_instances_table(&usage.by_day_project, lang),
111    )
112}
113
114/// The `better-ccusage daily --instances` cross-tab: one row per (day × project)
115/// with the token-category split and estimated cost shown side by side.
116fn daily_instances_table(items: &[DailyInstance], lang: Language) -> String {
117    let mut rows = String::new();
118    let mut last_date = "";
119    for it in items.iter().take(120) {
120        // Visually group by day: dim the date except on its first row.
121        let date_cell = if it.date == last_date {
122            String::new()
123        } else {
124            html_escape(&it.date)
125        };
126        last_date = &it.date;
127        let models = it
128            .models
129            .iter()
130            .map(|m| html_escape(&short_model(m)))
131            .collect::<Vec<_>>()
132            .join(", ");
133        rows.push_str(&format!(
134            r#"<tr><td class="date">{date_cell}</td><td><strong>{}</strong></td><td class="muted">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num cost">{}</td></tr>"#,
135            html_escape(&it.project),
136            models,
137            format_tokens(it.input),
138            format_tokens(it.output),
139            format_tokens(it.cache_creation),
140            format_tokens(it.cache_read),
141            format_tokens(it.tokens),
142            format_usd(it.cost),
143        ));
144    }
145    if rows.is_empty() {
146        rows = format!(
147            r#"<tr><td colspan="9" class="empty">{}</td></tr>"#,
148            lang.text("no usage in the last 30 days", "son 30 günde kullanım yok")
149        );
150    }
151    format!(
152        r#"<div class="table-card wide" style="margin-top:14px"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
153        lang.text("date", "tarih"),
154        lang.text("project", "proje"),
155        lang.text("models", "modeller"),
156        lang.text("input", "girdi"),
157        lang.text("output", "çıktı"),
158        lang.text("cache +", "önbellek +"),
159        lang.text("cache read", "önbellek oku"),
160        lang.text("tokens", "token"),
161        lang.text("cost", "maliyet"),
162    )
163}
164
165fn plan_label(a: &AccountInfo) -> &'static str {
166    match a.subscription_type.as_str() {
167        "pro" => "Pro",
168        "max" => {
169            if a.rate_limit_tier.contains("20x") { "Max 20×" }
170            else if a.rate_limit_tier.contains("5x") { "Max 5×" }
171            else { "Max" }
172        }
173        "free" => "Free",
174        _ => "Unknown",
175    }
176}
177
178fn render_accounts(accounts: &[AccountInfo], claude: &AgentUsage, lang: Language) -> String {
179    let active = accounts.iter().find(|a| a.is_active).or_else(|| accounts.first());
180
181    let limit_bars = if let Some(a) = active {
182        let plan_name = format!(
183            "{} — <span class=\"plan-badge plan-{}\">{}</span>",
184            html_escape(&a.name),
185            html_escape(&a.subscription_type),
186            plan_label(a)
187        );
188
189        let bar5h = render_limit_bar(
190            lang.text("Session (5h)", "Oturum (5s)"),
191            claude.session_5h_percent,
192            a.limit_5h_messages,
193            lang,
194        );
195        let bar7d = render_limit_bar(
196            lang.text("Week (7d)", "Hafta (7g)"),
197            claude.week_7d_percent,
198            a.limit_7d_messages,
199            lang,
200        );
201
202        format!(
203            r#"<div class="limit-account-header">{plan_name}</div><div class="limit-bars">{bar5h}{bar7d}</div>"#
204        )
205    } else {
206        String::new()
207    };
208
209    // Non-active accounts: compact table
210    let other_rows: String = accounts.iter()
211        .filter(|a| !a.is_active)
212        .map(|a| {
213            let l5h = if a.limit_5h_messages > 0 { format!("{} msgs", a.limit_5h_messages) } else { "—".to_string() };
214            let l7d = if a.limit_7d_messages > 0 { format!("{} msgs", a.limit_7d_messages) } else { "—".to_string() };
215            format!(
216                r#"<tr><td><strong>{}</strong></td><td><span class="plan-badge plan-{}">{}</span></td><td class="num">{}</td><td class="num">{}</td></tr>"#,
217                html_escape(&a.name),
218                html_escape(&a.subscription_type),
219                plan_label(a),
220                l5h,
221                l7d,
222            )
223        })
224        .collect();
225
226    let other_table = if !other_rows.is_empty() {
227        format!(
228            r#"<div class="table-card wide" style="margin-top:10px"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{other_rows}</tbody></table></div>"#,
229            lang.text("account", "hesap"),
230            lang.text("plan", "plan"),
231            lang.text("5h limit", "5s limit"),
232            lang.text("7d limit", "7g limit"),
233        )
234    } else {
235        String::new()
236    };
237
238    format!(
239        r#"<section class="accounts-section"><h2>{}</h2>{limit_bars}{other_table}</section>"#,
240        lang.text("Limits", "Limitler"),
241    )
242}
243
244fn render_limit_bar(label: &str, pct: Option<f64>, total_msgs: u32, lang: Language) -> String {
245    let (pct_val, pct_text, used_text) = if let Some(p) = pct {
246        let used = ((p / 100.0) * total_msgs as f64).round() as u32;
247        let used_label = if lang == Language::Tr {
248            format!("{} / {} mesaj", used, total_msgs)
249        } else {
250            format!("{} / {} msgs", used, total_msgs)
251        };
252        (p, format!("{p:.0}%"), used_label)
253    } else if total_msgs > 0 {
254        (0.0, "—".to_string(), format!("/ {total_msgs} msgs"))
255    } else {
256        (0.0, "—".to_string(), "—".to_string())
257    };
258
259    let color = if pct_val >= 90.0 {
260        "var(--limit-danger)"
261    } else if pct_val >= 70.0 {
262        "var(--limit-warn)"
263    } else {
264        "var(--limit-ok)"
265    };
266
267    format!(
268        r#"<div class="limit-bar-item">
269  <div class="limit-bar-header"><span class="limit-bar-label">{label}</span><span class="limit-bar-pct">{pct_text}</span></div>
270  <div class="limit-bar-track"><div class="limit-bar-fill" style="width:{pct_val:.1}%;background:{color}"></div></div>
271  <div class="limit-bar-sub">{used_text}</div>
272</div>"#
273    )
274}
275
276fn render_other_tools(tools: &[ToolSummary], lang: Language) -> String {
277    let mut rows = String::new();
278    for t in tools {
279        let sessions = if t.sessions_7d > 0 {
280            if lang == Language::Tr {
281                format!("bu hafta {}", t.sessions_7d)
282            } else {
283                format!("{} this week", t.sessions_7d)
284            }
285        } else {
286            "—".to_string()
287        };
288        let tokens = if t.tokens_7d > 0 {
289            format_tokens(t.tokens_7d)
290        } else {
291            "—".to_string()
292        };
293        let last = t.last_used.as_deref().map(format_time).unwrap_or_else(|| "—".to_string());
294        let model = t.last_model.as_deref().unwrap_or("—");
295        rows.push_str(&format!(
296            r#"<tr><td><strong>{}</strong></td><td>{}</td><td class="num">{}</td><td class="muted">{}</td><td class="muted">{}</td></tr>"#,
297            html_escape(&t.name),
298            html_escape(model),
299            tokens,
300            sessions,
301            html_escape(&last),
302        ));
303    }
304    format!(
305        r#"<section class="other-tools"><h2>{}</h2><div class="table-card wide"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div></section>"#,
306        lang.text("Other AI Tools", "Diğer AI Araçları"),
307        lang.text("tool", "araç"),
308        lang.text("last model", "son model"),
309        lang.text("7d tokens", "7g token"),
310        lang.text("7d sessions", "7g oturum"),
311        lang.text("last used", "son kullanım"),
312    )
313}
314
315fn today_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
316    let mut out = String::new();
317    out.push_str(&format!(
318        r#"<article class="agent-card"><div class="agent-heading"><h2>{}</h2><span class="status-pill" data-active="{}">{}</span></div>"#,
319        html_escape(name),
320        usage.active_session_started_at.is_some(),
321        if usage.active_session_started_at.is_some() {
322            lang.text("active", "aktif")
323        } else {
324            lang.text("idle", "boşta")
325        }
326    ));
327    out.push_str(r#"<div class="metric-grid">"#);
328    out.push_str(&metric(lang.text("Active session tokens", "Aktif oturum token"), &active_session_value(usage, lang)));
329    out.push_str(&metric(lang.text("30d tokens", "30g token"), &format_tokens(usage.total_tokens_30d)));
330    out.push_str(&metric(
331        lang.text("30d sessions", "30g oturum"),
332        &usage.total_sessions_30d.to_string(),
333    ));
334    out.push_str(&metric(
335        lang.text("Est. 30d cost", "Tahmini 30g maliyet"),
336        &format_usd(usage.total_cost_30d),
337    ));
338    out.push_str(&metric(
339        lang.text("Est. today cost", "Tahmini bugünkü maliyet"),
340        &format_usd(usage.cost_today),
341    ));
342    out.push_str(&metric(
343        lang.text("Last model", "Son model"),
344        usage.last_model.as_deref().unwrap_or("-"),
345    ));
346    out.push_str(&metric(lang.text("Context", "Bağlam"), &format_pct(usage.last_context_pct)));
347    out.push_str(&metric(lang.text("Last turn", "Son tur"), &last_turn_value(usage)));
348    out.push_str(r#"</div></article>"#);
349    out
350}
351
352fn active_session_value(usage: &AgentUsage, lang: Language) -> String {
353    let tokens = format_tokens(usage.active_session_tokens);
354    match usage.active_session_started_at.as_deref() {
355        Some(started) if usage.active_session_tokens > 0 => format!(
356            r#"{tokens}<br><span class="muted">since {}</span>"#,
357            html_escape(&format!("{} {}", lang.text("since", "beri"), format_time(started)))
358        ),
359        Some(started) => format!(
360            r#"0<br><span class="muted">since {}</span>"#,
361            html_escape(&format!("{} {}", lang.text("since", "beri"), format_time(started)))
362        ),
363        None if usage.active_session_tokens > 0 => tokens,
364        None => "-".to_string(),
365    }
366}
367
368fn last_turn_value(usage: &AgentUsage) -> String {
369    usage
370        .last_turn_at
371        .as_deref()
372        .map(format_time)
373        .unwrap_or_else(|| "-".to_string())
374}
375
376fn metric(label: &str, value: &str) -> String {
377    format!(
378        r#"<div class="metric"><div class="label">{}</div><div class="value">{}</div></div>"#,
379        html_escape(label),
380        value
381    )
382}
383
384fn render_history(snap: &UsageSnapshot, lang: Language) -> String {
385    format!(
386        r#"<div class="stack">{}{}</div>"#,
387        history_agent("Claude", &snap.claude, lang),
388        history_agent("Codex", &snap.codex, lang)
389    )
390}
391
392fn history_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
393    let mut out = String::new();
394    out.push_str(&format!(
395        r#"<section class="agent-section"><h2>{}</h2><div class="charts">"#,
396        html_escape(name)
397    ));
398    out.push_str(&chart_card(lang.text("Daily", "Günlük"), &usage.by_day, lang));
399    out.push_str(&chart_card(lang.text("Weekly", "Haftalık"), &usage.by_week, lang));
400    out.push_str(&chart_card(lang.text("Monthly", "Aylık"), &usage.by_month, lang));
401    out.push_str(r#"</div></section>"#);
402    out
403}
404
405fn render_sessions(snap: &UsageSnapshot, lang: Language) -> String {
406    format!(
407        r#"<div class="stack">{}{}</div>"#,
408        sessions_agent("Claude", &snap.claude.recent_sessions, lang),
409        sessions_agent("Codex", &snap.codex.recent_sessions, lang)
410    )
411}
412
413fn sessions_agent(name: &str, sessions: &[SessionRecord], lang: Language) -> String {
414    format!(
415        r#"<section class="agent-section"><h2>{}</h2>{}</section>"#,
416        html_escape(name),
417        recent_sessions_table(sessions, lang)
418    )
419}
420
421fn render_breakdown(snap: &UsageSnapshot, lang: Language) -> String {
422    format!(
423        r#"<div class="stack">{}{}</div>"#,
424        breakdown_agent("Claude", &snap.claude, lang),
425        breakdown_agent("Codex", &snap.codex, lang)
426    )
427}
428
429fn breakdown_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
430    let mut out = String::new();
431    out.push_str(&format!(
432        r#"<section class="agent-section"><h2>{}</h2><div class="tables">"#,
433        html_escape(name)
434    ));
435    out.push_str(&named_table(lang.text("By model", "Modele göre"), lang.text("model", "model"), &usage.by_model, lang));
436    out.push_str(&named_table(lang.text("By project", "Projeye göre"), lang.text("project", "proje"), &usage.by_project, lang));
437    out.push_str(r#"</div></section>"#);
438    out
439}
440
441fn chart_card(title: &str, buckets: &[TimeBucket], lang: Language) -> String {
442    if buckets.is_empty() {
443        return format!(
444            r#"<div class="chart-card"><h3>{}</h3><div class="empty">{}</div></div>"#,
445            html_escape(title),
446            lang.text("no data", "veri yok"),
447        );
448    }
449
450    let max_tokens = buckets.iter().map(|b| b.tokens).max().unwrap_or(1).max(1);
451    let bar_w = 18;
452    let gap = 4;
453    let w = (bar_w + gap) * buckets.len() + 40;
454    let h = 150;
455    let mut svg = format!(r#"<svg viewBox="0 0 {w} {h}" preserveAspectRatio="none" class="chart">"#);
456    for (i, b) in buckets.iter().enumerate() {
457        let bh = (b.tokens as f64 / max_tokens as f64 * (h as f64 - 34.0)).max(1.0);
458        let x = (i * (bar_w + gap)) as f64 + 20.0;
459        let y = h as f64 - 22.0 - bh;
460        svg.push_str(&format!(
461            r#"<rect x="{x}" y="{y}" width="{bar_w}" height="{bh}" rx="3" class="bar"><title>{}</title></rect>"#,
462            html_escape(&format!(
463                "{}\n{} tokens · {} sessions",
464                b.date,
465                b.tokens,
466                b.sessions
467            ))
468        ));
469    }
470    if let (Some(first), Some(last)) = (buckets.first(), buckets.last()) {
471        svg.push_str(&format!(
472            r#"<text x="20" y="{}" class="label-x start">{}</text><text x="{}" y="{}" class="label-x end">{}</text>"#,
473            h - 4,
474            html_escape(&first.date),
475            w - 20,
476            h - 4,
477            html_escape(&last.date)
478        ));
479    }
480    svg.push_str("</svg>");
481
482    format!(
483        r#"<div class="chart-card"><h3>{}</h3>{svg}</div>"#,
484        html_escape(title)
485    )
486}
487
488fn named_table(title: &str, name_heading: &str, items: &[NamedBucket], lang: Language) -> String {
489    let mut rows = String::new();
490    let total: u64 = items.iter().map(|i| i.tokens).sum::<u64>().max(1);
491    for it in items.iter().take(12) {
492        let pct = it.tokens as f64 / total as f64 * 100.0;
493        rows.push_str(&format!(
494            r#"<tr><td>{}</td><td class="num">{}</td><td class="num">{}</td><td class="num cost">{}</td><td class="bar-cell"><div class="hbar" style="width:{:.1}%"></div><span>{:.1}%</span></td></tr>"#,
495            html_escape(&it.model),
496            it.sessions,
497            format_tokens(it.tokens),
498            format_usd(it.cost),
499            pct,
500            pct
501        ));
502    }
503    if rows.is_empty() {
504        rows = format!(
505            r#"<tr><td colspan="5" class="empty">{}</td></tr>"#,
506            lang.text("no data", "veri yok")
507        );
508    }
509    format!(
510        r#"<div class="table-card"><h3>{}</h3><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
511        html_escape(title),
512        html_escape(name_heading),
513        lang.text("sessions", "oturum"),
514        lang.text("tokens", "token"),
515        lang.text("cost", "maliyet"),
516        lang.text("share", "pay"),
517    )
518}
519
520fn recent_sessions_table(items: &[SessionRecord], lang: Language) -> String {
521    let mut rows = String::new();
522    for s in items {
523        rows.push_str(&format!(
524            r#"<tr><td>{}</td><td>{}</td><td>{}</td><td>{:.0} min</td><td class="num">{}</td><td class="num cost">{}</td><td><code>{}</code></td></tr>"#,
525            html_escape(&format_time(&s.started_at)),
526            html_escape(&s.project),
527            html_escape(&s.model),
528            s.duration_minutes,
529            format_tokens(s.tokens),
530            format_usd(s.cost),
531            html_escape(&s.id)
532        ));
533    }
534    if rows.is_empty() {
535        rows = format!(
536            r#"<tr><td colspan="7" class="empty">{}</td></tr>"#,
537            lang.text("no recent sessions", "yakın oturum yok")
538        );
539    }
540    format!(
541        r#"<div class="table-card wide"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
542        lang.text("started", "başlangıç"),
543        lang.text("project", "proje"),
544        lang.text("model", "model"),
545        lang.text("duration", "süre"),
546        lang.text("tokens", "token"),
547        lang.text("cost", "maliyet"),
548        lang.text("session id", "oturum id"),
549    )
550}
551
552fn format_tokens(value: u64) -> String {
553    if value >= 1_000_000_000 {
554        format!("{:.2}B", value as f64 / 1_000_000_000.0)
555    } else if value >= 1_000_000 {
556        format!("{:.2}M", value as f64 / 1_000_000.0)
557    } else if value >= 1_000 {
558        format!("{:.1}k", value as f64 / 1_000.0)
559    } else {
560        value.to_string()
561    }
562}
563
564/// USD with thousands separators and cents. `<$0.01` for tiny non-zero values.
565fn format_usd(value: f64) -> String {
566    if value <= 0.0 {
567        return "$0.00".to_string();
568    }
569    if value < 0.01 {
570        return "&lt;$0.01".to_string();
571    }
572    let cents = (value * 100.0).round() as u64;
573    let dollars = cents / 100;
574    let frac = cents % 100;
575    format!("${}.{:02}", group_thousands(dollars), frac)
576}
577
578fn group_thousands(mut n: u64) -> String {
579    if n == 0 {
580        return "0".to_string();
581    }
582    let mut parts: Vec<String> = Vec::new();
583    while n > 0 {
584        parts.push(format!("{:03}", n % 1000));
585        n /= 1000;
586    }
587    parts.reverse();
588    // Trim leading zeros on the most-significant group only.
589    parts.join(",").trim_start_matches('0').to_string()
590}
591
592/// Trim an Anthropic/OpenAI model id to a short, readable label for tables.
593fn short_model(id: &str) -> String {
594    let m = id.to_lowercase();
595    let label = if m.contains("opus-4-8") { "Opus 4.8" }
596        else if m.contains("opus-4-7") { "Opus 4.7" }
597        else if m.contains("opus-4-6") { "Opus 4.6" }
598        else if m.contains("opus-4-5") { "Opus 4.5" }
599        else if m.contains("opus-4-1") { "Opus 4.1" }
600        else if m.contains("opus-4") { "Opus 4" }
601        else if m.contains("sonnet-4-6") { "Sonnet 4.6" }
602        else if m.contains("sonnet-4-5") { "Sonnet 4.5" }
603        else if m.contains("sonnet-4") { "Sonnet 4" }
604        else if m.contains("haiku-4-5") { "Haiku 4.5" }
605        else if m.contains("haiku") { "Haiku" }
606        else if m.contains("mythos") { "Mythos" }
607        else if m.contains("gpt-5.5") { "GPT-5.5" }
608        else if m.contains("gpt-5.4") { "GPT-5.4" }
609        else if m.contains("gpt-5.3") { "GPT-5.3" }
610        else if m.contains("gpt-5.2") { "GPT-5.2" }
611        else if m.contains("gpt-5.1") { "GPT-5.1" }
612        else if m.contains("gpt-5") { "GPT-5" }
613        else { return id.to_string(); };
614    let suffix = if m.contains("codex") { " codex" }
615        else if m.contains("[1m]") || m.contains("-1m") { " (1M)" }
616        else { "" };
617    format!("{label}{suffix}")
618}
619
620fn format_pct(value: Option<f64>) -> String {
621    value
622        .map(|pct| format!("{pct:.0}%"))
623        .unwrap_or_else(|| "-".to_string())
624}
625
626fn format_time(raw: &str) -> String {
627    if raw.is_empty() {
628        return "-".to_string();
629    }
630    raw.get(..16).unwrap_or(raw).replace('T', " ")
631}
632
633fn html_escape(s: &str) -> String {
634    s.replace('&', "&amp;")
635        .replace('<', "&lt;")
636        .replace('>', "&gt;")
637}
638
639const HEAD: &str = r#"<!doctype html>
640<html lang="en"><head><meta charset="utf-8"><title>context-bar · usage</title>
641<style>
642:root {
643  color-scheme: light dark;
644  --bg: Canvas;
645  --text: CanvasText;
646  --accent: AccentColor;
647
648  /* light */
649  --panel: rgba(255,255,255,0.72);
650  --panel2: rgba(246,246,248,0.80);
651  --border: rgba(0,0,0,0.09);
652  --separator: rgba(0,0,0,0.06);
653  --muted: rgba(60,60,67,0.60);
654  --bar2: #007AFF;
655  --seg-bg: rgba(118,118,128,0.12);
656  --seg-thumb: rgba(255,255,255,0.95);
657  --seg-thumb-shadow: 0 1px 3px rgba(0,0,0,0.18);
658  --pill-active-bg: rgba(0,122,255,0.12);
659  --pill-active-text: #007AFF;
660  --pill-idle-bg: rgba(118,118,128,0.10);
661  --pill-idle-text: rgba(60,60,67,0.55);
662}
663@media (prefers-color-scheme: dark) {
664  :root {
665    --panel: rgba(40,40,44,0.70);
666    --panel2: rgba(28,28,32,0.75);
667    --border: rgba(255,255,255,0.09);
668    --separator: rgba(255,255,255,0.06);
669    --muted: rgba(235,235,245,0.50);
670    --bar2: #0A84FF;
671    --seg-bg: rgba(118,118,128,0.24);
672    --seg-thumb: rgba(72,72,76,0.98);
673    --seg-thumb-shadow: 0 1px 4px rgba(0,0,0,0.55);
674    --pill-active-bg: rgba(10,132,255,0.18);
675    --pill-active-text: #0A84FF;
676    --pill-idle-bg: rgba(118,118,128,0.18);
677    --pill-idle-text: rgba(235,235,245,0.45);
678  }
679}
680* { box-sizing: border-box; margin:0; padding:0; }
681body {
682  min-height: 100vh;
683  background:
684    linear-gradient(to bottom, color-mix(in srgb, var(--panel) 72%, transparent), transparent 180px),
685    var(--bg);
686  color: var(--text);
687  font: 13px/1.45 -apple-system, "SF Pro Text", ui-sans-serif, sans-serif;
688  -webkit-font-smoothing: antialiased;
689}
690.app-shell {
691  min-height: 100vh;
692}
693
694/* ── Header ───────────────────────────────────────────── */
695header {
696  padding: 18px 24px 12px;
697  border-bottom: 1px solid var(--separator);
698  backdrop-filter: blur(20px) saturate(180%);
699  -webkit-backdrop-filter: blur(20px) saturate(180%);
700  background: var(--panel);
701  position: sticky; top: 0; z-index: 10;
702}
703h1, h2, h3 { text-wrap: balance; }
704.title-block {
705  max-width: 1180px;
706  margin: 0 auto;
707}
708h1 { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
709.muted { color: var(--muted); font-size: 11px; margin-top: 2px; }
710
711/* ── Segmented Control (macOS NSSegmentedControl style) ── */
712nav.tabs {
713  display: flex;
714  justify-content: center;
715  padding: 12px 24px 0;
716  border-bottom: 1px solid var(--separator);
717  backdrop-filter: blur(20px) saturate(180%);
718  -webkit-backdrop-filter: blur(20px) saturate(180%);
719  background: color-mix(in srgb, var(--panel) 86%, transparent);
720  position: sticky;
721  top: 58px;
722  z-index: 9;
723}
724.seg-ctrl {
725  display: inline-flex;
726  flex-wrap: wrap;
727  max-width: 100%;
728  background: var(--seg-bg);
729  border-radius: 8px;
730  padding: 2px;
731  gap: 1px;
732}
733.tab-btn {
734  background: none;
735  border: none;
736  padding: 5px 14px;
737  border-radius: 6px;
738  color: var(--muted);
739  cursor: pointer;
740  font: 12px/1.4 -apple-system, sans-serif;
741  font-weight: 500;
742  letter-spacing: -0.003em;
743  transition: color 0.1s;
744  -webkit-user-select: none;
745  white-space: nowrap;
746}
747.tab-btn.active {
748  background: var(--seg-thumb);
749  box-shadow: var(--seg-thumb-shadow);
750  color: var(--text);
751  font-weight: 590;
752}
753.tab-panel {
754  display: none;
755  padding: 20px 24px 34px;
756  max-width: 1228px;
757  margin: 0 auto;
758}
759.tab-panel.active { display: block; }
760main { min-height: calc(100vh - 116px); }
761
762/* ── Layout helpers ───────────────────────────────────── */
763.stack { display: flex; flex-direction: column; gap: 14px; }
764.today-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px,1fr)); gap: 14px; }
765
766/* ── Cards ────────────────────────────────────────────── */
767.agent-card, .agent-section {
768  background: var(--panel);
769  border: 1px solid var(--border);
770  border-radius: 12px;
771  padding: 18px 20px;
772  backdrop-filter: blur(16px) saturate(160%);
773  -webkit-backdrop-filter: blur(16px) saturate(160%);
774  box-shadow:
775    0 1px 0 rgba(255,255,255,0.08) inset,
776    0 12px 28px rgba(0,0,0,0.05);
777}
778h2 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }
779h3 { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; margin-bottom: 10px; }
780
781/* ── Agent heading + status pill ─────────────────────── */
782.agent-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
783.status-pill {
784  border-radius: 999px;
785  padding: 2px 9px;
786  font-size: 11px;
787  font-weight: 500;
788  letter-spacing: 0.03em;
789  text-transform: uppercase;
790}
791.status-pill[data-active="true"]  { background: var(--pill-active-bg); color: var(--pill-active-text); }
792.status-pill[data-active="false"] { background: var(--pill-idle-bg);   color: var(--pill-idle-text); }
793
794/* ── Metric grid ──────────────────────────────────────── */
795.metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
796.metric {
797  background: var(--panel2);
798  border: 1px solid var(--border);
799  border-radius: 10px;
800  min-height: 80px;
801  padding: 11px 13px;
802}
803.metric .label { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; margin-bottom: 7px; }
804.metric .value { font-size: 19px; line-height: 1.2; font-weight: 650; letter-spacing: -0.02em; overflow-wrap: anywhere; }
805
806/* ── Charts ───────────────────────────────────────────── */
807.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 10px; margin-top: 14px; }
808.chart-card {
809  background: var(--panel2);
810  border: 1px solid var(--border);
811  border-radius: 10px;
812  padding: 13px;
813  min-height: 195px;
814  min-width: 0;
815}
816svg.chart { width: 100%; height: 148px; display: block; }
817svg.chart rect.bar { fill: var(--bar2); opacity: 0.85; }
818svg.chart rect.bar:hover { opacity: 1; }
819svg.chart text.label-x { fill: var(--muted); font-size: 9.5px; }
820svg.chart text.label-x.end { text-anchor: end; }
821
822/* ── Tables ───────────────────────────────────────────── */
823.tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 10px; margin-top: 14px; }
824.table-card {
825  background: var(--panel2);
826  border: 1px solid var(--border);
827  border-radius: 10px;
828  padding: 13px;
829  overflow-x: auto;
830  min-width: 0;
831}
832.table-card.wide { margin-top: 14px; }
833.other-tools { margin-top: 20px; }
834.other-tools h2 { margin-bottom: 10px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; }
835.accounts-section { margin-top: 20px; }
836.accounts-section h2 { margin-bottom: 12px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; }
837.limit-account-header { font-size: 12px; font-weight: 500; margin-bottom: 10px; }
838.limit-bars { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); gap: 10px; }
839.limit-bar-item { background: var(--panel2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
840.limit-bar-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 7px; }
841.limit-bar-label { font-size: 11px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
842.limit-bar-pct { font-size: 18px; font-weight: 650; letter-spacing: -0.02em; }
843.limit-bar-track { height: 6px; background: var(--seg-bg); border-radius: 999px; overflow: hidden; margin-bottom: 6px; }
844.limit-bar-fill { height: 100%; border-radius: 999px; transition: width 0.3s ease; }
845.limit-bar-sub { font-size: 10.5px; color: var(--muted); }
846:root { --limit-ok: #34C759; --limit-warn: #FF9F0A; --limit-danger: #FF3B30; }
847.plan-badge { border-radius: 999px; padding: 2px 8px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.02em; }
848.plan-badge.plan-pro { background: rgba(52,199,89,0.15); color: #34C759; }
849.plan-badge.plan-max { background: rgba(10,132,255,0.15); color: #0A84FF; }
850.plan-badge.plan-free { background: var(--pill-idle-bg); color: var(--muted); }
851tr.active-account-row td { background: rgba(10,132,255,0.06); }
852table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
853th { text-align: left; padding: 5px 8px; font-weight: 500; color: var(--muted); border-bottom: 1px solid var(--separator); white-space: nowrap; font-size: 11px; }
854td { padding: 5px 8px; border-bottom: 1px solid var(--separator); vertical-align: top; }
855tr:last-child td { border-bottom: none; }
856td.num { text-align: right; font-variant-numeric: tabular-nums; }
857code { font-family: "SF Mono", ui-monospace, "Menlo", monospace; font-size: 10.5px; color: var(--muted); }
858.bar-cell { position: relative; min-width: 110px; padding-right: 46px; }
859.bar-cell .hbar { height: 5px; background: var(--bar2); border-radius: 3px; opacity: 0.75; }
860.bar-cell span { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10.5px; color: var(--muted); }
861.empty { color: var(--muted); font-size: 11.5px; padding: 10px 8px; }
862.cost-note { background: var(--panel2); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; font-size: 11.5px; color: var(--muted); line-height: 1.5; }
863.cost-tiles { margin-bottom: 4px; }
864td.cost { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
865td.date { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
866
867@media (max-width: 920px) {
868  .today-grid,
869  .charts,
870  .tables {
871    grid-template-columns: 1fr;
872  }
873}
874
875@media (max-width: 680px) {
876  header,
877  nav.tabs {
878    padding-left: 14px;
879    padding-right: 14px;
880  }
881  nav.tabs {
882    justify-content: flex-start;
883    overflow-x: auto;
884    top: 54px;
885  }
886  .seg-ctrl {
887    flex-wrap: nowrap;
888    width: max-content;
889  }
890  .tab-panel {
891    padding: 14px;
892  }
893  .agent-card,
894  .agent-section,
895  .chart-card,
896  .table-card {
897    border-radius: 11px;
898  }
899  .today-grid,
900  .metric-grid {
901    grid-template-columns: 1fr;
902  }
903  .agent-heading {
904    align-items: flex-start;
905    flex-direction: column;
906  }
907  .status-pill {
908    align-self: flex-start;
909  }
910  .metric .value {
911    font-size: 17px;
912  }
913  table {
914    min-width: 560px;
915  }
916}
917
918@media (max-width: 480px) {
919  h1 {
920    font-size: 14px;
921  }
922  .muted,
923  h3,
924  th,
925  td,
926  code {
927    font-size: 10.5px;
928  }
929  .tab-btn {
930    padding: 5px 11px;
931    font-size: 11.5px;
932  }
933  .agent-card,
934  .agent-section {
935    padding: 15px 16px;
936  }
937  .metric {
938    min-height: 72px;
939    padding: 10px 11px;
940  }
941  .bar-cell {
942    min-width: 96px;
943    padding-right: 40px;
944  }
945}
946</style></head><body>"#;
947
948const FOOT: &str = r#"<script>
949document.querySelectorAll('.tab-btn').forEach(btn => {
950  btn.addEventListener('click', () => {
951    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
952    document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
953    btn.classList.add('active');
954    document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
955  });
956});
957</script></body></html>"#;