1use 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
114fn 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 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 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
564fn format_usd(value: f64) -> String {
566 if value <= 0.0 {
567 return "$0.00".to_string();
568 }
569 if value < 0.01 {
570 return "<$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 parts.join(",").trim_start_matches('0').to_string()
590}
591
592fn 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('&', "&")
635 .replace('<', "<")
636 .replace('>', ">")
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>"#;