1use chrono::{DateTime, Utc};
13use ratatui::Frame;
14use ratatui::layout::{Constraint, Layout, Rect};
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::Paragraph;
18use ratatui_bubbletea_components::{Progress, Spinner, SpinnerFrames};
19use ratatui_bubbletea_theme::BubbleTheme;
20
21use crate::countdown;
22use crate::format::local_time_hms;
23use crate::pacing::{self, PaceSeverity};
24use crate::pango::severity_for;
25use crate::theme::Theme;
26use crate::tui::app::TabState;
27use crate::tui::style::{bubble_theme, color, progress_theme, severity_color};
28use crate::usage::VendorSnapshot;
29
30pub enum Section {
33 Title { left: String, right: Option<String> },
38 Metric {
40 label: String,
41 pct: u16,
42 severity: PaceSeverity,
43 value_label: String,
44 footnote: String,
45 },
46 Text { label: String, value: String },
48 Block { label: String, body: Vec<String> },
50 Spacer,
52}
53
54pub fn compact_cells(snapshot: &VendorSnapshot) -> (String, Vec<(String, PaceSeverity)>) {
60 let pct = |label: &str, p: i32| (format!("{label} {p}%"), severity_for(p));
61 let money = |v: f64| (format!("${v:.2}"), PaceSeverity::Low);
62 let ccy = |v: f64, c: &str| {
63 let s = match c {
64 "USD" => format!("${v:.2}"),
65 "CNY" => format!("¥{v:.2}"),
66 _ => format!("{v:.2} {c}"),
67 };
68 (s, PaceSeverity::Low)
69 };
70 match snapshot {
71 VendorSnapshot::Anthropic(s) => {
72 let mut cells = vec![
73 pct("S", s.session.utilization_pct),
74 pct("W", s.weekly.utilization_pct),
75 ];
76 if let Some(sonnet) = &s.sonnet {
77 cells.push(pct("Son", sonnet.utilization_pct));
78 }
79 (s.plan.clone(), cells)
80 }
81 VendorSnapshot::AnthropicApi(s) => {
82 let cell = match s.pct() {
83 Some(p) => pct("spend", p),
84 None => (format!("${:.2}/mo", s.spent), PaceSeverity::Low),
85 };
86 (String::new(), vec![cell])
87 }
88 VendorSnapshot::Openai(s) => {
89 let mut cells = Vec::new();
90 if let Some(w) = &s.session {
91 cells.push(pct("5h", w.utilization_pct));
92 }
93 if let Some(w) = &s.weekly {
94 cells.push(pct("7d", w.utilization_pct));
95 }
96 if cells.is_empty() {
97 cells.push(("—".into(), PaceSeverity::Low));
98 }
99 (s.plan.clone(), cells)
100 }
101 VendorSnapshot::Zai(s) => {
102 let mut cells = Vec::new();
103 if let Some(w) = &s.session {
104 cells.push(pct("S", w.utilization_pct));
105 }
106 if let Some(w) = &s.weekly {
107 cells.push(pct("W", w.utilization_pct));
108 }
109 if cells.is_empty() {
110 cells.push(("—".into(), PaceSeverity::Low));
111 }
112 (s.plan.clone(), cells)
113 }
114 VendorSnapshot::Openrouter(s) => (String::new(), vec![money(s.balance())]),
115 VendorSnapshot::Deepseek(s) => (String::new(), vec![ccy(s.balance, &s.currency)]),
116 VendorSnapshot::Kimi(s) => (
117 s.plan.clone().unwrap_or_default(),
118 vec![pct("wk", s.weekly_pct()), pct("5h", s.window_pct())],
119 ),
120 VendorSnapshot::Kilo(s) => (String::new(), vec![money(s.balance)]),
121 VendorSnapshot::Novita(s) => (String::new(), vec![money(s.available)]),
122 VendorSnapshot::Moonshot(s) => (String::new(), vec![ccy(s.available, &s.currency)]),
123 VendorSnapshot::Grok(s) => (String::new(), vec![money(s.balance)]),
124 VendorSnapshot::Antigravity(s) => (
125 s.plan.clone(),
126 vec![
127 pct("S", s.session.utilization_pct),
128 pct("W", s.weekly.utilization_pct),
129 ],
130 ),
131 VendorSnapshot::Cursor(s) => (
132 s.plan.clone(),
133 vec![pct("auto", s.auto_pct), pct("premium", s.api_pct)],
134 ),
135 }
136}
137
138pub fn headline_pct(snapshot: &VendorSnapshot) -> Option<i32> {
143 match snapshot {
144 VendorSnapshot::Anthropic(s) => [
145 Some(s.session.utilization_pct),
146 Some(s.weekly.utilization_pct),
147 s.sonnet.as_ref().map(|w| w.utilization_pct),
148 ]
149 .into_iter()
150 .flatten()
151 .max(),
152 VendorSnapshot::AnthropicApi(s) => s.pct(),
153 VendorSnapshot::Openai(s) => [
154 s.session.as_ref().map(|w| w.utilization_pct),
155 s.weekly.as_ref().map(|w| w.utilization_pct),
156 ]
157 .into_iter()
158 .flatten()
159 .max(),
160 VendorSnapshot::Zai(s) => [
161 s.session.as_ref().map(|w| w.utilization_pct),
162 s.weekly.as_ref().map(|w| w.utilization_pct),
163 ]
164 .into_iter()
165 .flatten()
166 .max(),
167 VendorSnapshot::Kimi(s) => Some(s.weekly_pct().max(s.window_pct())),
168 VendorSnapshot::Antigravity(s) => {
169 Some(s.session.utilization_pct.max(s.weekly.utilization_pct))
170 }
171 VendorSnapshot::Cursor(s) => (!s.unlimited).then_some(s.total_pct),
172 VendorSnapshot::Openrouter(_)
173 | VendorSnapshot::Deepseek(_)
174 | VendorSnapshot::Kilo(_)
175 | VendorSnapshot::Novita(_)
176 | VendorSnapshot::Moonshot(_)
177 | VendorSnapshot::Grok(_) => None,
178 }
179}
180
181pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
183 match tab {
184 TabState::Loading => vec![
185 Section::Spacer,
186 Section::Text {
187 label: "".into(),
188 value: " Loading…".into(),
189 },
190 ],
191 TabState::Error(e) => vec![
192 Section::Spacer,
193 Section::Text {
194 label: "Error".into(),
195 value: e.clone(),
196 },
197 Section::Spacer,
198 Section::Text {
199 label: "".into(),
200 value: "Press `r` to retry, `q` to quit.".into(),
201 },
202 ],
203 TabState::Ready(r) => {
204 let snapshot = &r.snapshot;
205 let last_error = &r.last_error;
206 let mut sections = match snapshot {
207 VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
208 VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
209 VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
210 VendorSnapshot::Zai(s) => zai_sections(s, now),
211 VendorSnapshot::Openrouter(s) => openrouter_sections(s),
212 VendorSnapshot::Deepseek(s) => deepseek_sections(s),
213 VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
214 VendorSnapshot::Kilo(s) => kilo_sections(s),
215 VendorSnapshot::Novita(s) => novita_sections(s),
216 VendorSnapshot::Moonshot(s) => moonshot_sections(s),
217 VendorSnapshot::Grok(s) => grok_sections(s),
218 VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
219 VendorSnapshot::Cursor(s) => cursor_sections(s, now),
220 };
221 let updated = match r.fetched_at {
225 Some(at) => format!("Updated {}", local_time_hms(at)),
226 None => "Updated —".to_string(),
227 };
228 if let Some(Section::Title { right, .. }) = sections.first_mut() {
229 *right = Some(updated);
230 }
231 if let Some((label, msg)) = warning_label(snapshot, last_error) {
233 sections.push(Section::Spacer);
234 sections.push(Section::Text { label, value: msg });
235 }
236 sections
237 }
238 }
239}
240
241fn warning_label(
245 snapshot: &VendorSnapshot,
246 last_error: &Option<(u16, String)>,
247) -> Option<(String, String)> {
248 let (code, message) = last_error.as_ref()?;
249 if *code != 0 {
250 return Some((format!("HTTP {code}"), message.clone()));
251 }
252 if message.is_empty() {
253 return None;
254 }
255 let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
256 && matches!(
257 crate::kimi::vendor::warning_kind(*code, message),
258 crate::kimi::vendor::WarningKind::SchemaDrift
259 ) {
260 "Kimi API schema drift"
261 } else {
262 "Warning"
263 };
264 let value = if label == message {
267 String::new()
268 } else {
269 message.clone()
270 };
271 Some((label.into(), value))
272}
273
274fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> Vec<Section> {
275 let mut v = vec![Section::Title {
276 left: "Anthropic API".into(),
277 right: None,
278 }];
279 match (s.limit.filter(|l| *l > 0.0), s.pct()) {
280 (Some(limit), Some(pct)) => {
281 let p = pct.clamp(0, 100) as u16;
282 v.push(Section::Metric {
283 label: "Spend (mo)".into(),
284 pct: p,
285 severity: severity_for(pct),
286 value_label: format!("${:.2} of ${:.0}", s.spent, limit),
287 footnote: format!("{pct}% of monthly limit"),
288 });
289 }
290 _ => {
291 v.push(Section::Text {
292 label: "Spend (mo)".into(),
293 value: format!("${:.2}", s.spent),
294 });
295 }
296 }
297 v.push(Section::Spacer);
298 v.push(Section::Text {
299 label: "".into(),
300 value: "Month-to-date cost via the Admin usage API.".into(),
301 });
302 v.push(Section::Text {
303 label: "".into(),
304 value: "Prepaid credit balance is Console-only (no API).".into(),
305 });
306 v.push(Section::Text {
307 label: "".into(),
308 value: "Excludes Priority Tier cost (not reported by this API).".into(),
309 });
310 v
311}
312
313fn anthropic_sections(
314 s: &crate::usage::AnthropicSnapshot,
315 now: DateTime<Utc>,
316 tol: u32,
317) -> Vec<Section> {
318 let mut v = vec![Section::Title {
319 left: format!("Claude {}", s.plan),
320 right: None,
321 }];
322
323 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
324 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
325 if let Some(w) = &s.sonnet {
326 push_window(&mut v, "Sonnet only", w, now, tol, false);
327 }
328 for sw in &s.scoped {
329 push_window(
330 &mut v,
331 &format!("{} (7d)", sw.label),
332 &sw.window,
333 now,
334 tol,
335 false,
336 );
337 }
338 if let Some(e) = &s.extra {
339 v.push(Section::Spacer);
340 let pct = e.percent().clamp(0, 100) as u16;
341 let (value_label, footnote) = match e.fmt_limit() {
345 Some(l) => (
346 format!("{} of {}", e.fmt_spent(), l),
347 format!("{pct}% of monthly limit consumed"),
348 ),
349 None => (e.fmt_spent(), "no monthly limit reported".to_string()),
350 };
351 v.push(Section::Metric {
352 label: "Extra usage".into(),
353 pct,
354 severity: severity_for(pct as i32),
355 value_label,
356 footnote,
357 });
358 }
359 v
360}
361
362fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
363 let mut v = vec![Section::Title {
364 left: s.plan.clone(),
365 right: None,
366 }];
367 if let Some(session) = &s.session {
368 push_window(&mut v, "Codex 5h", session, now, tol, true);
369 }
370 if let Some(weekly) = &s.weekly {
371 push_window(&mut v, "Codex weekly", weekly, now, tol, true);
372 }
373 if s.session.is_none() && s.weekly.is_none() {
374 v.push(Section::Spacer);
375 v.push(Section::Text {
376 label: "".into(),
377 value: " no usage windows reported".into(),
378 });
379 }
380 if let Some(cr) = &s.code_review {
381 push_window(&mut v, "Code review", cr, now, tol, false);
382 }
383 if let Some(c) = &s.credits {
384 v.push(Section::Spacer);
385 let balance = if c.unlimited {
386 "unlimited".into()
387 } else {
388 c.balance.clone()
389 };
390 let mut body = vec![format!("balance: {}", balance)];
391 if let Some((lo, hi)) = c.approx_local_messages {
392 body.push(format!("≈ {lo}-{hi} local messages"));
393 }
394 if let Some((lo, hi)) = c.approx_cloud_messages {
395 body.push(format!("≈ {lo}-{hi} cloud messages"));
396 }
397 v.push(Section::Block {
398 label: "Credits".into(),
399 body,
400 });
401 }
402 v
403}
404
405fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
406 let mut v = vec![Section::Title {
407 left: s.plan.clone(),
408 right: None,
409 }];
410 if let Some(w) = &s.session {
411 push_window(&mut v, "Session (5h)", w, now, 5, false);
412 }
413 if let Some(w) = &s.weekly {
414 push_window(&mut v, "Weekly", w, now, 5, false);
415 }
416 if let Some(w) = &s.mcp {
417 push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
418 }
419 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
420 v.push(Section::Spacer);
421 v.push(Section::Text {
422 label: "".into(),
423 value: " no usage windows reported".into(),
424 });
425 }
426 v
427}
428
429fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
430 let mut v = vec![Section::Title {
431 left: s.label.clone(),
432 right: None,
433 }];
434 let pct = s.consumed_pct().clamp(0, 100) as u16;
435 v.push(Section::Spacer);
436 v.push(Section::Metric {
437 label: "Credit balance".into(),
438 pct,
439 severity: severity_for(pct as i32),
440 value_label: format!("${:.2}", s.balance()),
441 footnote: format!(
442 "${:.2} of ${:.2} used ({pct}%)",
443 s.total_usage, s.total_credits
444 ),
445 });
446 v.push(Section::Spacer);
447 v.push(Section::Block {
448 label: "Usage by period".into(),
449 body: vec![format!(
450 "today ${:.2} · week ${:.2} · month ${:.2}",
451 s.usage_daily, s.usage_weekly, s.usage_monthly
452 )],
453 });
454 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
455 v.push(Section::Spacer);
456 v.push(Section::Block {
457 label: "Per-key limit".into(),
458 body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
459 });
460 }
461 v.push(Section::Spacer);
462 v.push(Section::Block {
463 label: "Tier".into(),
464 body: vec![if s.is_free_tier {
465 "free tier".into()
466 } else {
467 "paid tier".into()
468 }],
469 });
470 v
471}
472
473fn antigravity_sections(s: &crate::usage::AntigravitySnapshot, now: DateTime<Utc>) -> Vec<Section> {
477 use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
478
479 let mut v = vec![Section::Title {
480 left: s.plan.clone(),
481 right: None,
482 }];
483 for (heading, primary, third_party) in [
484 ("Session", &s.session, s.third_party_session.as_ref()),
485 ("Weekly", &s.weekly, s.third_party_weekly.as_ref()),
486 ] {
487 v.push(Section::Spacer);
488 v.push(Section::Text {
489 label: heading.into(),
490 value: String::new(),
491 });
492 push_window(&mut v, GROUP_PRIMARY, primary, now, 5, false);
493 if let Some(w) = third_party {
494 push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
495 }
496 }
497 v
498}
499
500fn cursor_sections(s: &crate::usage::CursorSnapshot, now: DateTime<Utc>) -> Vec<Section> {
501 let mut v = vec![Section::Title {
502 left: format!("Cursor {}", s.plan),
503 right: None,
504 }];
505 if s.unlimited {
506 v.push(Section::Spacer);
507 v.push(Section::Text {
508 label: "Plan".into(),
509 value: "Unlimited — pools don't cap".into(),
510 });
511 } else {
512 v.push(Section::Spacer);
514 v.push(Section::Metric {
515 label: "Cursor Models".into(),
516 pct: s.auto_pct.clamp(0, 100) as u16,
517 severity: severity_for(s.auto_pct),
518 value_label: format!("{}%", s.auto_pct),
519 footnote: "Auto + Composer".into(),
520 });
521 v.push(Section::Spacer);
522 v.push(Section::Metric {
523 label: "Other Models".into(),
524 pct: s.api_pct.clamp(0, 100) as u16,
525 severity: severity_for(s.api_pct),
526 value_label: format!("{}%", s.api_pct),
527 footnote: format!(
528 "Named / API models · on-demand {}",
529 if s.on_demand_enabled { "on" } else { "off" }
530 ),
531 });
532 }
533 v.push(Section::Spacer);
534 v.push(Section::Text {
535 label: "Resets".into(),
536 value: countdown::format(s.reset_at, now),
537 });
538 v
539}
540
541fn kilo_sections(s: &crate::usage::KiloSnapshot) -> Vec<Section> {
542 vec![
543 Section::Title {
544 left: s.label.clone(),
545 right: None,
546 },
547 Section::Spacer,
548 Section::Text {
549 label: "Balance".into(),
550 value: format!("${:.2}", s.balance),
551 },
552 ]
553}
554
555fn novita_sections(s: &crate::usage::NovitaSnapshot) -> Vec<Section> {
556 let mut v = vec![
557 Section::Title {
558 left: "Novita".into(),
559 right: None,
560 },
561 Section::Spacer,
562 Section::Text {
563 label: "Balance".into(),
564 value: format!("${:.2}", s.available),
565 },
566 Section::Block {
567 label: "Breakdown".into(),
568 body: vec![format!(
569 "top-up ${:.2} · credit limit ${:.2}",
570 s.cash, s.credit_limit
571 )],
572 },
573 ];
574 if s.outstanding > 0.0 {
575 v.push(Section::Spacer);
576 v.push(Section::Block {
577 label: "Owed".into(),
578 body: vec![format!("${:.2}", s.outstanding)],
579 });
580 }
581 v
582}
583
584fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> Vec<Section> {
585 let cur = &s.currency;
586 let fmt = |v: f64| match cur.as_str() {
587 "USD" => format!("${v:.2}"),
588 "CNY" => format!("¥{v:.2}"),
589 _ => format!("{v:.2} {cur}"),
590 };
591 vec![
592 Section::Title {
593 left: "Kimi (Moonshot)".into(),
594 right: None,
595 },
596 Section::Spacer,
597 Section::Text {
598 label: "Balance".into(),
599 value: fmt(s.available),
600 },
601 Section::Block {
602 label: "Breakdown".into(),
603 body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
604 },
605 ]
606}
607
608fn grok_sections(s: &crate::usage::GrokSnapshot) -> Vec<Section> {
609 vec![
610 Section::Title {
611 left: "Grok (xAI)".into(),
612 right: None,
613 },
614 Section::Spacer,
615 Section::Text {
616 label: "Prepaid balance".into(),
617 value: format!("${:.2}", s.balance),
618 },
619 ]
620}
621
622fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
623 let currency = &s.currency;
624 let fmt = |v: f64| match currency.as_str() {
625 "USD" => format!("${v:.2}"),
626 "CNY" => format!("¥{v:.2}"),
627 _ => format!("{v:.2} {currency}"),
628 };
629 let avail = if s.is_available {
630 "available"
631 } else {
632 "unavailable"
633 };
634 let mut v = vec![Section::Title {
635 left: "DeepSeek".into(),
636 right: None,
637 }];
638 v.push(Section::Spacer);
639 v.push(Section::Text {
640 label: "Balance".into(),
641 value: fmt(s.balance),
642 });
643 v.push(Section::Block {
644 label: "Breakdown".into(),
645 body: vec![format!(
646 "granted {} · topped-up {}",
647 fmt(s.granted),
648 fmt(s.topped_up)
649 )],
650 });
651 v.push(Section::Spacer);
652 v.push(Section::Block {
653 label: "API".into(),
654 body: vec![avail.into()],
655 });
656 v
657}
658
659fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> Vec<Section> {
660 let plan = s.plan.as_deref().unwrap_or("Kimi");
661 let mut v = vec![Section::Title {
662 left: plan.into(),
663 right: None,
664 }];
665
666 let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
667 v.push(Section::Spacer);
668 v.push(Section::Metric {
669 label: "Weekly quota".into(),
670 pct: weekly_pct,
671 severity: severity_for(s.weekly_pct()),
672 value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
673 footnote: format!(
674 "{} remaining · reset {}",
675 s.weekly_remaining,
676 countdown::format(s.weekly_reset_at, now)
677 ),
678 });
679
680 if s.window_limit > 0 {
681 let window_pct = s.window_pct().clamp(0, 100) as u16;
682 v.push(Section::Spacer);
683 v.push(Section::Metric {
684 label: "Rolling window (5h)".into(),
685 pct: window_pct,
686 severity: severity_for(s.window_pct()),
687 value_label: format!("{} / {}", s.window_used, s.window_limit),
688 footnote: format!(
689 "{} remaining · reset {}",
690 s.window_remaining,
691 countdown::format(s.window_reset_at, now)
692 ),
693 });
694 }
695
696 v
697}
698
699fn push_window(
700 sections: &mut Vec<Section>,
701 label: &str,
702 w: &crate::usage::UsageWindow,
703 now: DateTime<Utc>,
704 tol: u32,
705 show_pacing: bool,
706) {
707 let pct = w.utilization_pct.clamp(0, 100) as u16;
708 let reset_text = countdown::format(w.resets_at, now);
709 let footnote = if show_pacing {
710 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
711 format!(
712 "Resets in {} · {}% elapsed · {}",
713 reset_text, p.elapsed_pct, p.point_label
714 )
715 } else {
716 format!("Resets in {}", reset_text)
717 };
718 sections.push(Section::Spacer);
719 sections.push(Section::Metric {
720 label: label.into(),
721 pct,
722 severity: severity_for(pct as i32),
723 value_label: format!("{pct}%"),
724 footnote,
725 });
726}
727
728pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
736 if sections.is_empty() {
737 return;
738 }
739 let bubble = bubble_theme(theme);
740 let pin_last =
743 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
744
745 let body_end = if pin_last {
746 sections.len() - 1
747 } else {
748 sections.len()
749 };
750 let mut constraints: Vec<Constraint> =
751 sections[..body_end].iter().map(section_height).collect();
752
753 if pin_last {
754 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
756 } else {
757 constraints.push(Constraint::Min(0));
758 }
759
760 let chunks = Layout::default()
761 .direction(ratatui::layout::Direction::Vertical)
762 .constraints(constraints)
763 .split(area);
764
765 for (i, s) in sections[..body_end].iter().enumerate() {
766 render_section(f, chunks[i], theme, &bubble, s);
767 }
768 if pin_last {
769 render_section(
770 f,
771 chunks[chunks.len() - 1],
772 theme,
773 &bubble,
774 sections.last().unwrap(),
775 );
776 }
777}
778
779fn section_height(s: &Section) -> Constraint {
780 match s {
781 Section::Title { .. } => Constraint::Length(2),
782 Section::Metric { .. } => Constraint::Length(3),
783 Section::Text { .. } => Constraint::Length(1),
784 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
785 Section::Spacer => Constraint::Length(1),
786 }
787}
788
789fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
790 match s {
791 Section::Title { left, right } => {
792 let left_line = Line::from(Span::styled(
795 format!(" {} {left}", bubble.symbols.selected),
796 bubble.title,
797 ));
798 f.render_widget(Paragraph::new(left_line), area);
799 if let Some(rt) = right {
800 let right_line =
801 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
802 f.render_widget(Paragraph::new(right_line), area);
803 }
804 }
805 Section::Metric {
806 label,
807 pct,
808 severity,
809 value_label,
810 footnote,
811 } => render_metric(
812 f,
813 area,
814 theme,
815 bubble,
816 label,
817 *pct,
818 *severity,
819 value_label,
820 footnote,
821 ),
822 Section::Text { label, value } => {
823 if label.is_empty() && value.contains("Loading") {
824 render_loading(f, area, bubble);
825 return;
826 }
827 if label == "Error" {
828 let line = Line::from(vec![
829 bubble.error(format!(" {} ", bubble.symbols.cross)),
830 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
831 ]);
832 f.render_widget(Paragraph::new(line), area);
833 return;
834 }
835 let mut spans = Vec::new();
836 if !label.is_empty() {
837 spans.push(Span::styled(
838 format!(" {label} "),
839 bubble.text.add_modifier(Modifier::BOLD),
840 ));
841 }
842 spans.push(Span::styled(value.clone(), bubble.muted));
843 f.render_widget(Paragraph::new(Line::from(spans)), area);
844 }
845 Section::Block { label, body } => render_block(f, area, bubble, label, body),
846 Section::Spacer => {}
847 }
848}
849
850fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
851 let frames = SpinnerFrames::DOTS;
852 let frame_count = frames.frames().len().max(1);
853 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
854 let mut spinner = Spinner::new()
855 .frames(frames)
856 .label("Fetching usage data")
857 .theme(*bubble);
858 for _ in 0..(frame % frame_count) {
859 spinner.tick();
860 }
861 f.render_widget(&spinner, area);
862}
863
864#[allow(clippy::too_many_arguments)]
865fn render_metric(
866 f: &mut Frame,
867 area: Rect,
868 theme: &Theme,
869 bubble: &BubbleTheme,
870 label: &str,
871 pct: u16,
872 severity: PaceSeverity,
873 value_label: &str,
874 footnote: &str,
875) {
876 let bar_color = severity_color(theme, bubble, severity);
877 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
878
879 let inner = Layout::default()
880 .direction(ratatui::layout::Direction::Vertical)
881 .constraints([
882 Constraint::Length(1),
883 Constraint::Length(1),
884 Constraint::Length(1),
885 ])
886 .split(area);
887
888 let label_line = Line::from(Span::styled(
890 format!(" {label}"),
891 bubble.text.add_modifier(Modifier::BOLD),
892 ));
893 f.render_widget(Paragraph::new(label_line), inner[0]);
894
895 let row = inner[1];
897 let value_w = value_label.chars().count() as u16 + 2;
898 let gauge_area = Rect {
899 x: row.x + 2,
900 y: row.y,
901 width: row.width.saturating_sub(value_w + 4),
902 height: 1,
903 };
904 let value_area = Rect {
905 x: gauge_area.x + gauge_area.width + 1,
906 y: row.y,
907 width: value_w,
908 height: 1,
909 };
910 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
911 let progress = Progress::from_percent(pct)
912 .theme(progress_theme)
913 .show_percentage(false);
914 f.render_widget(&progress, gauge_area);
915 let value = Paragraph::new(Line::from(Span::styled(
916 value_label.to_string(),
917 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
918 )));
919 f.render_widget(value, value_area);
920
921 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
923 f.render_widget(Paragraph::new(foot), inner[2]);
924}
925
926fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
927 let mut lines = vec![Line::from(Span::styled(
928 format!(" {label}"),
929 bubble.text.add_modifier(Modifier::BOLD),
930 ))];
931 for b in body {
932 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
933 }
934 f.render_widget(Paragraph::new(lines), area);
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940 use crate::usage::{
941 AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
942 OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
943 };
944 use chrono::TimeZone;
945
946 fn now() -> DateTime<Utc> {
947 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
948 }
949
950 fn ready(snapshot: VendorSnapshot) -> TabState {
951 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
952 snapshot,
953 stale: false,
954 last_error: None,
955 fetched_at: Some(now() - chrono::Duration::seconds(15)),
956 }))
957 }
958
959 #[test]
960 fn anthropic_sections_include_all_three_windows_when_present() {
961 let snap = AnthropicSnapshot {
962 plan: "Max 20x".into(),
963 session: UsageWindow {
964 utilization_pct: 60,
965 resets_at: Some(now() + chrono::Duration::hours(1)),
966 window_duration: chrono::Duration::hours(5),
967 },
968 weekly: UsageWindow {
969 utilization_pct: 30,
970 resets_at: Some(now() + chrono::Duration::days(3)),
971 window_duration: chrono::Duration::days(7),
972 },
973 sonnet: Some(UsageWindow {
974 utilization_pct: 5,
975 resets_at: Some(now() + chrono::Duration::hours(2)),
976 window_duration: chrono::Duration::days(7),
977 }),
978 scoped: vec![],
979 extra: Some(ExtraUsage {
980 limit: Some(Cents(5000)),
981 spent: Cents(250),
982 currency: None,
983 decimal_places: Some(2),
984 }),
985 };
986 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
987 assert_eq!(sections.len(), 9);
990 assert!(matches!(sections[0], Section::Title { .. }));
991 if let Section::Title { right, .. } = §ions[0] {
993 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
994 } else {
995 panic!("expected first section to be Title");
996 }
997 let metric_count = sections
998 .iter()
999 .filter(|s| matches!(s, Section::Metric { .. }))
1000 .count();
1001 assert_eq!(metric_count, 4);
1002 }
1003
1004 #[test]
1005 fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
1006 let snap = AnthropicSnapshot {
1009 plan: "Pro".into(),
1010 session: UsageWindow {
1011 utilization_pct: 10,
1012 resets_at: None,
1013 window_duration: chrono::Duration::hours(5),
1014 },
1015 weekly: UsageWindow {
1016 utilization_pct: 20,
1017 resets_at: None,
1018 window_duration: chrono::Duration::days(7),
1019 },
1020 sonnet: None,
1021 scoped: vec![],
1022 extra: Some(ExtraUsage {
1023 limit: None,
1024 spent: Cents(14157),
1025 currency: Some("BRL".into()),
1026 decimal_places: Some(2),
1027 }),
1028 };
1029 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1030 let extra = sections
1031 .iter()
1032 .find_map(|s| match s {
1033 Section::Metric {
1034 label,
1035 pct,
1036 value_label,
1037 footnote,
1038 ..
1039 } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
1040 _ => None,
1041 })
1042 .expect("uncapped extra usage must still render a section");
1043 assert_eq!(extra.0, 0);
1044 assert_eq!(extra.1, "R$141.57");
1046 assert!(
1047 !extra.1.contains(" of "),
1048 "no denominator to show: {}",
1049 extra.1
1050 );
1051 assert_eq!(extra.2, "no monthly limit reported");
1052 }
1053
1054 #[test]
1055 fn anthropic_omits_sonnet_and_extra_when_absent() {
1056 let snap = AnthropicSnapshot {
1057 plan: "Pro".into(),
1058 session: UsageWindow {
1059 utilization_pct: 10,
1060 resets_at: None,
1061 window_duration: chrono::Duration::hours(5),
1062 },
1063 weekly: UsageWindow {
1064 utilization_pct: 5,
1065 resets_at: None,
1066 window_duration: chrono::Duration::days(7),
1067 },
1068 sonnet: None,
1069 scoped: vec![],
1070 extra: None,
1071 };
1072 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1073 let metric_count = sections
1074 .iter()
1075 .filter(|s| matches!(s, Section::Metric { .. }))
1076 .count();
1077 assert_eq!(metric_count, 2);
1078 }
1079
1080 #[test]
1081 fn openrouter_always_has_balance_metric_and_period_block() {
1082 let snap = OpenRouterSnapshot {
1083 label: "OR".into(),
1084 total_credits: 100.0,
1085 total_usage: 25.0,
1086 usage_daily: 1.0,
1087 usage_weekly: 5.0,
1088 usage_monthly: 25.0,
1089 is_free_tier: false,
1090 limit: None,
1091 limit_remaining: None,
1092 };
1093 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
1094 assert!(matches!(sections[0], Section::Title { .. }));
1095 assert!(
1096 sections
1097 .iter()
1098 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
1099 );
1100 assert!(
1101 sections
1102 .iter()
1103 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
1104 );
1105 }
1106
1107 #[test]
1108 fn zai_no_windows_renders_message() {
1109 let snap = ZaiSnapshot {
1110 plan: "GLM".into(),
1111 session: None,
1112 weekly: None,
1113 mcp: None,
1114 };
1115 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
1116 assert!(sections.iter().any(|s| matches!(
1117 s,
1118 Section::Text { value, .. } if value.contains("no usage windows reported")
1119 )));
1120 }
1121
1122 #[test]
1123 fn openai_no_windows_renders_message() {
1124 let snap = OpenAiSnapshot {
1125 plan: "ChatGPT Plus".into(),
1126 session: None,
1127 weekly: None,
1128 code_review: None,
1129 credits: None,
1130 source: OpenAiSource::CodexOauth,
1131 };
1132 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1133 assert!(sections.iter().any(|s| matches!(
1134 s,
1135 Section::Text { value, .. } if value.contains("no usage windows reported")
1136 )));
1137 }
1138
1139 #[test]
1140 fn loading_state_yields_loading_section() {
1141 let sections = sections_for(&TabState::Loading, now(), 5);
1142 assert!(sections.iter().any(|s| matches!(
1143 s,
1144 Section::Text { value, .. } if value.contains("Loading")
1145 )));
1146 }
1147
1148 #[test]
1149 fn error_state_includes_retry_hint() {
1150 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
1151 assert!(sections.iter().any(|s| matches!(
1152 s,
1153 Section::Text { value, .. } if value.contains("token expired")
1154 )));
1155 assert!(sections.iter().any(|s| matches!(
1156 s,
1157 Section::Text { value, .. } if value.contains("`r` to retry")
1158 )));
1159 }
1160
1161 #[test]
1162 fn openai_with_credits_renders_block() {
1163 let snap = OpenAiSnapshot {
1164 plan: "ChatGPT Plus".into(),
1165 session: Some(UsageWindow {
1166 utilization_pct: 1,
1167 resets_at: None,
1168 window_duration: chrono::Duration::hours(5),
1169 }),
1170 weekly: Some(UsageWindow {
1171 utilization_pct: 0,
1172 resets_at: None,
1173 window_duration: chrono::Duration::days(7),
1174 }),
1175 code_review: None,
1176 credits: Some(OpenAiCredits {
1177 balance: "$5.00".into(),
1178 has_credits: true,
1179 unlimited: false,
1180 approx_local_messages: Some((100, 200)),
1181 approx_cloud_messages: Some((30, 50)),
1182 }),
1183 source: OpenAiSource::CodexOauth,
1184 };
1185 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1186 assert!(
1187 sections
1188 .iter()
1189 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
1190 );
1191 }
1192
1193 #[test]
1194 fn openai_weekly_only_omits_session_section() {
1195 let snap = OpenAiSnapshot {
1196 plan: "ChatGPT Prolite".into(),
1197 session: None,
1198 weekly: Some(UsageWindow {
1199 utilization_pct: 66,
1200 resets_at: None,
1201 window_duration: chrono::Duration::days(7),
1202 }),
1203 code_review: None,
1204 credits: None,
1205 source: OpenAiSource::CodexOauth,
1206 };
1207 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1208 assert!(sections.iter().any(|section| matches!(
1209 section,
1210 Section::Metric { label, .. } if label == "Codex weekly"
1211 )));
1212 assert!(!sections.iter().any(|section| matches!(
1213 section,
1214 Section::Metric { label, .. } if label == "Codex 5h"
1215 )));
1216 }
1217
1218 #[test]
1219 fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
1220 let now = now();
1221 let snap = KimiSnapshot {
1222 plan: Some("LEVEL_INTERMEDIATE".into()),
1223 weekly_limit: 100,
1224 weekly_used: 26,
1225 weekly_remaining: 74,
1226 weekly_reset_at: Some(now + chrono::Duration::days(4)),
1227 window_limit: 100,
1228 window_used: 15,
1229 window_remaining: 85,
1230 window_reset_at: Some(now + chrono::Duration::hours(2)),
1231 };
1232 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
1233 let metrics: Vec<_> = sections
1234 .iter()
1235 .filter(|s| matches!(s, Section::Metric { .. }))
1236 .collect();
1237 assert_eq!(metrics.len(), 2);
1238 assert!(sections.iter().any(|s| matches!(
1239 s,
1240 Section::Metric { label, .. } if label == "Weekly quota"
1241 )));
1242 assert!(sections.iter().any(|s| matches!(
1243 s,
1244 Section::Metric { label, .. } if label == "Rolling window (5h)"
1245 )));
1246
1247 let find_footnote = |label: &str| -> (String, String) {
1248 sections
1249 .iter()
1250 .find_map(|s| match s {
1251 Section::Metric {
1252 label: l,
1253 value_label,
1254 footnote,
1255 ..
1256 } if l == label => Some((value_label.clone(), footnote.clone())),
1257 _ => None,
1258 })
1259 .unwrap_or_else(|| panic!("missing metric {label}"))
1260 };
1261
1262 let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
1263 assert_eq!(weekly_value, "26 / 100");
1264 assert!(weekly_footnote.contains("74 remaining"));
1265 assert!(
1266 weekly_footnote.contains("4d 0h"),
1267 "weekly reset countdown: {weekly_footnote}"
1268 );
1269 assert!(!weekly_footnote.contains("2026-05-27T")); let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
1272 assert_eq!(window_value, "15 / 100");
1273 assert!(window_footnote.contains("85 remaining"));
1274 assert!(
1275 window_footnote.contains("2h 00m"),
1276 "window reset countdown: {window_footnote}"
1277 );
1278 assert!(!window_footnote.contains("2026-05-23T14")); }
1280
1281 #[test]
1282 fn kimi_sections_omit_window_when_limit_zero() {
1283 let snap = KimiSnapshot {
1284 plan: None,
1285 weekly_limit: 100,
1286 weekly_used: 10,
1287 weekly_remaining: 90,
1288 weekly_reset_at: None,
1289 window_limit: 0,
1290 window_used: 0,
1291 window_remaining: 0,
1292 window_reset_at: None,
1293 };
1294 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
1295 let metric_count = sections
1296 .iter()
1297 .filter(|s| matches!(s, Section::Metric { .. }))
1298 .count();
1299 assert_eq!(metric_count, 1);
1300 }
1301
1302 fn cursor_snap() -> crate::usage::CursorSnapshot {
1303 crate::usage::CursorSnapshot {
1304 plan: "Ultra".into(),
1305 auto_pct: 98,
1306 api_pct: 100,
1307 total_pct: 99,
1308 unlimited: false,
1309 on_demand_enabled: false,
1310 reset_at: Some(now() + chrono::Duration::days(9)),
1311 }
1312 }
1313
1314 #[test]
1315 fn compact_cells_flatten_key_metrics_for_the_overview() {
1316 let (plan, cells) = compact_cells(&VendorSnapshot::Cursor(cursor_snap()));
1318 assert_eq!(plan, "Ultra");
1319 assert_eq!(cells[0].0, "auto 98%");
1320 assert_eq!(cells[1].0, "premium 100%");
1321 assert_eq!(cells[1].1, PaceSeverity::Critical); let (plan, cells) = compact_cells(&VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1325 label: "Kilo".into(),
1326 balance: 8.42,
1327 }));
1328 assert!(plan.is_empty());
1329 assert_eq!(cells, vec![("$8.42".to_string(), PaceSeverity::Low)]);
1330 }
1331
1332 #[test]
1333 fn headline_pct_is_the_worst_window_or_combined_total() {
1334 assert_eq!(
1336 headline_pct(&VendorSnapshot::Cursor(cursor_snap())),
1337 Some(99)
1338 );
1339
1340 let kilo = VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1342 label: "Kilo".into(),
1343 balance: 8.42,
1344 });
1345 assert_eq!(headline_pct(&kilo), None);
1346 }
1347
1348 #[test]
1349 fn cursor_sections_show_both_pools_and_reset() {
1350 let sections = sections_for(&ready(VendorSnapshot::Cursor(cursor_snap())), now(), 5);
1351 let metrics: Vec<_> = sections
1352 .iter()
1353 .filter_map(|s| match s {
1354 Section::Metric {
1355 label, value_label, ..
1356 } => Some((label.clone(), value_label.clone())),
1357 _ => None,
1358 })
1359 .collect();
1360 assert_eq!(metrics.len(), 2, "two pools");
1361 assert!(
1362 metrics
1363 .iter()
1364 .any(|(l, v)| l == "Cursor Models" && v == "98%")
1365 );
1366 assert!(
1367 metrics
1368 .iter()
1369 .any(|(l, v)| l == "Other Models" && v == "100%")
1370 );
1371 assert!(sections.iter().any(|s| matches!(
1372 s,
1373 Section::Text { label, value } if label == "Resets" && value.contains("9d")
1374 )));
1375 }
1376
1377 #[test]
1378 fn cursor_unlimited_plan_shows_no_pool_bars() {
1379 let mut snap = cursor_snap();
1380 snap.unlimited = true;
1381 let sections = sections_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
1382 let metric_count = sections
1383 .iter()
1384 .filter(|s| matches!(s, Section::Metric { .. }))
1385 .count();
1386 assert_eq!(metric_count, 0);
1387 assert!(sections.iter().any(|s| matches!(
1388 s,
1389 Section::Text { value, .. } if value.contains("Unlimited")
1390 )));
1391 }
1392
1393 #[test]
1394 fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
1395 let snap = KimiSnapshot {
1396 plan: None,
1397 weekly_limit: 100,
1398 weekly_used: 10,
1399 weekly_remaining: 90,
1400 weekly_reset_at: None,
1401 window_limit: 0,
1402 window_used: 0,
1403 window_remaining: 0,
1404 window_reset_at: None,
1405 };
1406 let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
1407 let TabState::Ready(tab) = &mut schema else {
1408 unreachable!()
1409 };
1410 tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
1411 let schema_sections = sections_for(&schema, now(), 5);
1412 assert!(schema_sections.iter().any(|section| matches!(
1413 section,
1414 Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
1415 )));
1416
1417 let mut generic = ready(VendorSnapshot::Kimi(snap));
1418 let TabState::Ready(tab) = &mut generic else {
1419 unreachable!()
1420 };
1421 tab.last_error = Some((0, "cache lock unavailable".into()));
1422 let generic_sections = sections_for(&generic, now(), 5);
1423 assert!(generic_sections.iter().any(|section| matches!(
1424 section,
1425 Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
1426 )));
1427 assert!(!generic_sections.iter().any(|section| matches!(
1428 section,
1429 Section::Text { label, .. } if label.starts_with("HTTP")
1430 )));
1431
1432 let http = warning_label(
1433 &VendorSnapshot::Kimi(KimiSnapshot {
1434 plan: None,
1435 weekly_limit: 0,
1436 weekly_used: 0,
1437 weekly_remaining: 0,
1438 weekly_reset_at: None,
1439 window_limit: 0,
1440 window_used: 0,
1441 window_remaining: 0,
1442 window_reset_at: None,
1443 }),
1444 &Some((503, "service unavailable".into())),
1445 );
1446 assert_eq!(
1447 http,
1448 Some(("HTTP 503".into(), "service unavailable".into()))
1449 );
1450 }
1451}