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 sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
56 match tab {
57 TabState::Loading => vec![
58 Section::Spacer,
59 Section::Text {
60 label: "".into(),
61 value: " Loading…".into(),
62 },
63 ],
64 TabState::Error(e) => vec![
65 Section::Spacer,
66 Section::Text {
67 label: "Error".into(),
68 value: e.clone(),
69 },
70 Section::Spacer,
71 Section::Text {
72 label: "".into(),
73 value: "Press `r` to retry, `q` to quit.".into(),
74 },
75 ],
76 TabState::Ready(r) => {
77 let snapshot = &r.snapshot;
78 let last_error = &r.last_error;
79 let mut sections = match snapshot {
80 VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
81 VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
82 VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
83 VendorSnapshot::Zai(s) => zai_sections(s, now),
84 VendorSnapshot::Openrouter(s) => openrouter_sections(s),
85 VendorSnapshot::Deepseek(s) => deepseek_sections(s),
86 VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
87 VendorSnapshot::Kilo(s) => kilo_sections(s),
88 VendorSnapshot::Novita(s) => novita_sections(s),
89 VendorSnapshot::Moonshot(s) => moonshot_sections(s),
90 VendorSnapshot::Grok(s) => grok_sections(s),
91 VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
92 };
93 let updated = match r.fetched_at {
97 Some(at) => format!("Updated {}", local_time_hms(at)),
98 None => "Updated —".to_string(),
99 };
100 if let Some(Section::Title { right, .. }) = sections.first_mut() {
101 *right = Some(updated);
102 }
103 if let Some((label, msg)) = warning_label(snapshot, last_error) {
105 sections.push(Section::Spacer);
106 sections.push(Section::Text { label, value: msg });
107 }
108 sections
109 }
110 }
111}
112
113fn warning_label(
117 snapshot: &VendorSnapshot,
118 last_error: &Option<(u16, String)>,
119) -> Option<(String, String)> {
120 let (code, message) = last_error.as_ref()?;
121 if *code != 0 {
122 return Some((format!("HTTP {code}"), message.clone()));
123 }
124 if message.is_empty() {
125 return None;
126 }
127 let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
128 && matches!(
129 crate::kimi::vendor::warning_kind(*code, message),
130 crate::kimi::vendor::WarningKind::SchemaDrift
131 ) {
132 "Kimi API schema drift"
133 } else {
134 "Warning"
135 };
136 let value = if label == message {
139 String::new()
140 } else {
141 message.clone()
142 };
143 Some((label.into(), value))
144}
145
146fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> Vec<Section> {
147 let mut v = vec![Section::Title {
148 left: "Anthropic API".into(),
149 right: None,
150 }];
151 match (s.limit.filter(|l| *l > 0.0), s.pct()) {
152 (Some(limit), Some(pct)) => {
153 let p = pct.clamp(0, 100) as u16;
154 v.push(Section::Metric {
155 label: "Spend (mo)".into(),
156 pct: p,
157 severity: severity_for(pct),
158 value_label: format!("${:.2} of ${:.0}", s.spent, limit),
159 footnote: format!("{pct}% of monthly limit"),
160 });
161 }
162 _ => {
163 v.push(Section::Text {
164 label: "Spend (mo)".into(),
165 value: format!("${:.2}", s.spent),
166 });
167 }
168 }
169 v.push(Section::Spacer);
170 v.push(Section::Text {
171 label: "".into(),
172 value: "Month-to-date cost via the Admin usage API.".into(),
173 });
174 v.push(Section::Text {
175 label: "".into(),
176 value: "Prepaid credit balance is Console-only (no API).".into(),
177 });
178 v.push(Section::Text {
179 label: "".into(),
180 value: "Excludes Priority Tier cost (not reported by this API).".into(),
181 });
182 v
183}
184
185fn anthropic_sections(
186 s: &crate::usage::AnthropicSnapshot,
187 now: DateTime<Utc>,
188 tol: u32,
189) -> Vec<Section> {
190 let mut v = vec![Section::Title {
191 left: format!("Claude {}", s.plan),
192 right: None,
193 }];
194
195 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
196 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
197 if let Some(w) = &s.sonnet {
198 push_window(&mut v, "Sonnet only", w, now, tol, false);
199 }
200 for sw in &s.scoped {
201 push_window(
202 &mut v,
203 &format!("{} (7d)", sw.label),
204 &sw.window,
205 now,
206 tol,
207 false,
208 );
209 }
210 if let Some(e) = &s.extra {
211 v.push(Section::Spacer);
212 let pct = e.percent().clamp(0, 100) as u16;
213 let (value_label, footnote) = match e.fmt_limit() {
217 Some(l) => (
218 format!("{} of {}", e.fmt_spent(), l),
219 format!("{pct}% of monthly limit consumed"),
220 ),
221 None => (e.fmt_spent(), "no monthly limit reported".to_string()),
222 };
223 v.push(Section::Metric {
224 label: "Extra usage".into(),
225 pct,
226 severity: severity_for(pct as i32),
227 value_label,
228 footnote,
229 });
230 }
231 v
232}
233
234fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
235 let mut v = vec![Section::Title {
236 left: s.plan.clone(),
237 right: None,
238 }];
239 if let Some(session) = &s.session {
240 push_window(&mut v, "Codex 5h", session, now, tol, true);
241 }
242 if let Some(weekly) = &s.weekly {
243 push_window(&mut v, "Codex weekly", weekly, now, tol, true);
244 }
245 if s.session.is_none() && s.weekly.is_none() {
246 v.push(Section::Spacer);
247 v.push(Section::Text {
248 label: "".into(),
249 value: " no usage windows reported".into(),
250 });
251 }
252 if let Some(cr) = &s.code_review {
253 push_window(&mut v, "Code review", cr, now, tol, false);
254 }
255 if let Some(c) = &s.credits {
256 v.push(Section::Spacer);
257 let balance = if c.unlimited {
258 "unlimited".into()
259 } else {
260 c.balance.clone()
261 };
262 let mut body = vec![format!("balance: {}", balance)];
263 if let Some((lo, hi)) = c.approx_local_messages {
264 body.push(format!("≈ {lo}-{hi} local messages"));
265 }
266 if let Some((lo, hi)) = c.approx_cloud_messages {
267 body.push(format!("≈ {lo}-{hi} cloud messages"));
268 }
269 v.push(Section::Block {
270 label: "Credits".into(),
271 body,
272 });
273 }
274 v
275}
276
277fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
278 let mut v = vec![Section::Title {
279 left: s.plan.clone(),
280 right: None,
281 }];
282 if let Some(w) = &s.session {
283 push_window(&mut v, "Session (5h)", w, now, 5, false);
284 }
285 if let Some(w) = &s.weekly {
286 push_window(&mut v, "Weekly", w, now, 5, false);
287 }
288 if let Some(w) = &s.mcp {
289 push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
290 }
291 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
292 v.push(Section::Spacer);
293 v.push(Section::Text {
294 label: "".into(),
295 value: " no usage windows reported".into(),
296 });
297 }
298 v
299}
300
301fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
302 let mut v = vec![Section::Title {
303 left: s.label.clone(),
304 right: None,
305 }];
306 let pct = s.consumed_pct().clamp(0, 100) as u16;
307 v.push(Section::Spacer);
308 v.push(Section::Metric {
309 label: "Credit balance".into(),
310 pct,
311 severity: severity_for(pct as i32),
312 value_label: format!("${:.2}", s.balance()),
313 footnote: format!(
314 "${:.2} of ${:.2} used ({pct}%)",
315 s.total_usage, s.total_credits
316 ),
317 });
318 v.push(Section::Spacer);
319 v.push(Section::Block {
320 label: "Usage by period".into(),
321 body: vec![format!(
322 "today ${:.2} · week ${:.2} · month ${:.2}",
323 s.usage_daily, s.usage_weekly, s.usage_monthly
324 )],
325 });
326 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
327 v.push(Section::Spacer);
328 v.push(Section::Block {
329 label: "Per-key limit".into(),
330 body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
331 });
332 }
333 v.push(Section::Spacer);
334 v.push(Section::Block {
335 label: "Tier".into(),
336 body: vec![if s.is_free_tier {
337 "free tier".into()
338 } else {
339 "paid tier".into()
340 }],
341 });
342 v
343}
344
345fn antigravity_sections(s: &crate::usage::AntigravitySnapshot, now: DateTime<Utc>) -> Vec<Section> {
349 use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
350
351 let mut v = vec![Section::Title {
352 left: s.plan.clone(),
353 right: None,
354 }];
355 for (heading, primary, third_party) in [
356 ("Session", &s.session, s.third_party_session.as_ref()),
357 ("Weekly", &s.weekly, s.third_party_weekly.as_ref()),
358 ] {
359 v.push(Section::Spacer);
360 v.push(Section::Text {
361 label: heading.into(),
362 value: String::new(),
363 });
364 push_window(&mut v, GROUP_PRIMARY, primary, now, 5, false);
365 if let Some(w) = third_party {
366 push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
367 }
368 }
369 v
370}
371
372fn kilo_sections(s: &crate::usage::KiloSnapshot) -> Vec<Section> {
373 vec![
374 Section::Title {
375 left: s.label.clone(),
376 right: None,
377 },
378 Section::Spacer,
379 Section::Text {
380 label: "Balance".into(),
381 value: format!("${:.2}", s.balance),
382 },
383 ]
384}
385
386fn novita_sections(s: &crate::usage::NovitaSnapshot) -> Vec<Section> {
387 let mut v = vec![
388 Section::Title {
389 left: "Novita".into(),
390 right: None,
391 },
392 Section::Spacer,
393 Section::Text {
394 label: "Balance".into(),
395 value: format!("${:.2}", s.available),
396 },
397 Section::Block {
398 label: "Breakdown".into(),
399 body: vec![format!(
400 "top-up ${:.2} · credit limit ${:.2}",
401 s.cash, s.credit_limit
402 )],
403 },
404 ];
405 if s.outstanding > 0.0 {
406 v.push(Section::Spacer);
407 v.push(Section::Block {
408 label: "Owed".into(),
409 body: vec![format!("${:.2}", s.outstanding)],
410 });
411 }
412 v
413}
414
415fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> Vec<Section> {
416 let cur = &s.currency;
417 let fmt = |v: f64| match cur.as_str() {
418 "USD" => format!("${v:.2}"),
419 "CNY" => format!("¥{v:.2}"),
420 _ => format!("{v:.2} {cur}"),
421 };
422 vec![
423 Section::Title {
424 left: "Kimi (Moonshot)".into(),
425 right: None,
426 },
427 Section::Spacer,
428 Section::Text {
429 label: "Balance".into(),
430 value: fmt(s.available),
431 },
432 Section::Block {
433 label: "Breakdown".into(),
434 body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
435 },
436 ]
437}
438
439fn grok_sections(s: &crate::usage::GrokSnapshot) -> Vec<Section> {
440 vec![
441 Section::Title {
442 left: "Grok (xAI)".into(),
443 right: None,
444 },
445 Section::Spacer,
446 Section::Text {
447 label: "Prepaid balance".into(),
448 value: format!("${:.2}", s.balance),
449 },
450 ]
451}
452
453fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
454 let currency = &s.currency;
455 let fmt = |v: f64| match currency.as_str() {
456 "USD" => format!("${v:.2}"),
457 "CNY" => format!("¥{v:.2}"),
458 _ => format!("{v:.2} {currency}"),
459 };
460 let avail = if s.is_available {
461 "available"
462 } else {
463 "unavailable"
464 };
465 let mut v = vec![Section::Title {
466 left: "DeepSeek".into(),
467 right: None,
468 }];
469 v.push(Section::Spacer);
470 v.push(Section::Text {
471 label: "Balance".into(),
472 value: fmt(s.balance),
473 });
474 v.push(Section::Block {
475 label: "Breakdown".into(),
476 body: vec![format!(
477 "granted {} · topped-up {}",
478 fmt(s.granted),
479 fmt(s.topped_up)
480 )],
481 });
482 v.push(Section::Spacer);
483 v.push(Section::Block {
484 label: "API".into(),
485 body: vec![avail.into()],
486 });
487 v
488}
489
490fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> Vec<Section> {
491 let plan = s.plan.as_deref().unwrap_or("Kimi");
492 let mut v = vec![Section::Title {
493 left: plan.into(),
494 right: None,
495 }];
496
497 let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
498 v.push(Section::Spacer);
499 v.push(Section::Metric {
500 label: "Weekly quota".into(),
501 pct: weekly_pct,
502 severity: severity_for(s.weekly_pct()),
503 value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
504 footnote: format!(
505 "{} remaining · reset {}",
506 s.weekly_remaining,
507 countdown::format(s.weekly_reset_at, now)
508 ),
509 });
510
511 if s.window_limit > 0 {
512 let window_pct = s.window_pct().clamp(0, 100) as u16;
513 v.push(Section::Spacer);
514 v.push(Section::Metric {
515 label: "Rolling window (5h)".into(),
516 pct: window_pct,
517 severity: severity_for(s.window_pct()),
518 value_label: format!("{} / {}", s.window_used, s.window_limit),
519 footnote: format!(
520 "{} remaining · reset {}",
521 s.window_remaining,
522 countdown::format(s.window_reset_at, now)
523 ),
524 });
525 }
526
527 v
528}
529
530fn push_window(
531 sections: &mut Vec<Section>,
532 label: &str,
533 w: &crate::usage::UsageWindow,
534 now: DateTime<Utc>,
535 tol: u32,
536 show_pacing: bool,
537) {
538 let pct = w.utilization_pct.clamp(0, 100) as u16;
539 let reset_text = countdown::format(w.resets_at, now);
540 let footnote = if show_pacing {
541 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
542 format!(
543 "Resets in {} · {}% elapsed · {}",
544 reset_text, p.elapsed_pct, p.point_label
545 )
546 } else {
547 format!("Resets in {}", reset_text)
548 };
549 sections.push(Section::Spacer);
550 sections.push(Section::Metric {
551 label: label.into(),
552 pct,
553 severity: severity_for(pct as i32),
554 value_label: format!("{pct}%"),
555 footnote,
556 });
557}
558
559pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
567 if sections.is_empty() {
568 return;
569 }
570 let bubble = bubble_theme(theme);
571 let pin_last =
574 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
575
576 let body_end = if pin_last {
577 sections.len() - 1
578 } else {
579 sections.len()
580 };
581 let mut constraints: Vec<Constraint> =
582 sections[..body_end].iter().map(section_height).collect();
583
584 if pin_last {
585 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
587 } else {
588 constraints.push(Constraint::Min(0));
589 }
590
591 let chunks = Layout::default()
592 .direction(ratatui::layout::Direction::Vertical)
593 .constraints(constraints)
594 .split(area);
595
596 for (i, s) in sections[..body_end].iter().enumerate() {
597 render_section(f, chunks[i], theme, &bubble, s);
598 }
599 if pin_last {
600 render_section(
601 f,
602 chunks[chunks.len() - 1],
603 theme,
604 &bubble,
605 sections.last().unwrap(),
606 );
607 }
608}
609
610fn section_height(s: &Section) -> Constraint {
611 match s {
612 Section::Title { .. } => Constraint::Length(2),
613 Section::Metric { .. } => Constraint::Length(3),
614 Section::Text { .. } => Constraint::Length(1),
615 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
616 Section::Spacer => Constraint::Length(1),
617 }
618}
619
620fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
621 match s {
622 Section::Title { left, right } => {
623 let left_line = Line::from(Span::styled(
626 format!(" {} {left}", bubble.symbols.selected),
627 bubble.title,
628 ));
629 f.render_widget(Paragraph::new(left_line), area);
630 if let Some(rt) = right {
631 let right_line =
632 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
633 f.render_widget(Paragraph::new(right_line), area);
634 }
635 }
636 Section::Metric {
637 label,
638 pct,
639 severity,
640 value_label,
641 footnote,
642 } => render_metric(
643 f,
644 area,
645 theme,
646 bubble,
647 label,
648 *pct,
649 *severity,
650 value_label,
651 footnote,
652 ),
653 Section::Text { label, value } => {
654 if label.is_empty() && value.contains("Loading") {
655 render_loading(f, area, bubble);
656 return;
657 }
658 if label == "Error" {
659 let line = Line::from(vec![
660 bubble.error(format!(" {} ", bubble.symbols.cross)),
661 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
662 ]);
663 f.render_widget(Paragraph::new(line), area);
664 return;
665 }
666 let mut spans = Vec::new();
667 if !label.is_empty() {
668 spans.push(Span::styled(
669 format!(" {label} "),
670 bubble.text.add_modifier(Modifier::BOLD),
671 ));
672 }
673 spans.push(Span::styled(value.clone(), bubble.muted));
674 f.render_widget(Paragraph::new(Line::from(spans)), area);
675 }
676 Section::Block { label, body } => render_block(f, area, bubble, label, body),
677 Section::Spacer => {}
678 }
679}
680
681fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
682 let frames = SpinnerFrames::DOTS;
683 let frame_count = frames.frames().len().max(1);
684 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
685 let mut spinner = Spinner::new()
686 .frames(frames)
687 .label("Fetching usage data")
688 .theme(*bubble);
689 for _ in 0..(frame % frame_count) {
690 spinner.tick();
691 }
692 f.render_widget(&spinner, area);
693}
694
695#[allow(clippy::too_many_arguments)]
696fn render_metric(
697 f: &mut Frame,
698 area: Rect,
699 theme: &Theme,
700 bubble: &BubbleTheme,
701 label: &str,
702 pct: u16,
703 severity: PaceSeverity,
704 value_label: &str,
705 footnote: &str,
706) {
707 let bar_color = severity_color(theme, bubble, severity);
708 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
709
710 let inner = Layout::default()
711 .direction(ratatui::layout::Direction::Vertical)
712 .constraints([
713 Constraint::Length(1),
714 Constraint::Length(1),
715 Constraint::Length(1),
716 ])
717 .split(area);
718
719 let label_line = Line::from(Span::styled(
721 format!(" {label}"),
722 bubble.text.add_modifier(Modifier::BOLD),
723 ));
724 f.render_widget(Paragraph::new(label_line), inner[0]);
725
726 let row = inner[1];
728 let value_w = value_label.chars().count() as u16 + 2;
729 let gauge_area = Rect {
730 x: row.x + 2,
731 y: row.y,
732 width: row.width.saturating_sub(value_w + 4),
733 height: 1,
734 };
735 let value_area = Rect {
736 x: gauge_area.x + gauge_area.width + 1,
737 y: row.y,
738 width: value_w,
739 height: 1,
740 };
741 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
742 let progress = Progress::from_percent(pct)
743 .theme(progress_theme)
744 .show_percentage(false);
745 f.render_widget(&progress, gauge_area);
746 let value = Paragraph::new(Line::from(Span::styled(
747 value_label.to_string(),
748 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
749 )));
750 f.render_widget(value, value_area);
751
752 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
754 f.render_widget(Paragraph::new(foot), inner[2]);
755}
756
757fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
758 let mut lines = vec![Line::from(Span::styled(
759 format!(" {label}"),
760 bubble.text.add_modifier(Modifier::BOLD),
761 ))];
762 for b in body {
763 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
764 }
765 f.render_widget(Paragraph::new(lines), area);
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use crate::usage::{
772 AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
773 OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
774 };
775 use chrono::TimeZone;
776
777 fn now() -> DateTime<Utc> {
778 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
779 }
780
781 fn ready(snapshot: VendorSnapshot) -> TabState {
782 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
783 snapshot,
784 stale: false,
785 last_error: None,
786 fetched_at: Some(now() - chrono::Duration::seconds(15)),
787 }))
788 }
789
790 #[test]
791 fn anthropic_sections_include_all_three_windows_when_present() {
792 let snap = AnthropicSnapshot {
793 plan: "Max 20x".into(),
794 session: UsageWindow {
795 utilization_pct: 60,
796 resets_at: Some(now() + chrono::Duration::hours(1)),
797 window_duration: chrono::Duration::hours(5),
798 },
799 weekly: UsageWindow {
800 utilization_pct: 30,
801 resets_at: Some(now() + chrono::Duration::days(3)),
802 window_duration: chrono::Duration::days(7),
803 },
804 sonnet: Some(UsageWindow {
805 utilization_pct: 5,
806 resets_at: Some(now() + chrono::Duration::hours(2)),
807 window_duration: chrono::Duration::days(7),
808 }),
809 scoped: vec![],
810 extra: Some(ExtraUsage {
811 limit: Some(Cents(5000)),
812 spent: Cents(250),
813 currency: None,
814 decimal_places: Some(2),
815 }),
816 };
817 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
818 assert_eq!(sections.len(), 9);
821 assert!(matches!(sections[0], Section::Title { .. }));
822 if let Section::Title { right, .. } = §ions[0] {
824 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
825 } else {
826 panic!("expected first section to be Title");
827 }
828 let metric_count = sections
829 .iter()
830 .filter(|s| matches!(s, Section::Metric { .. }))
831 .count();
832 assert_eq!(metric_count, 4);
833 }
834
835 #[test]
836 fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
837 let snap = AnthropicSnapshot {
840 plan: "Pro".into(),
841 session: UsageWindow {
842 utilization_pct: 10,
843 resets_at: None,
844 window_duration: chrono::Duration::hours(5),
845 },
846 weekly: UsageWindow {
847 utilization_pct: 20,
848 resets_at: None,
849 window_duration: chrono::Duration::days(7),
850 },
851 sonnet: None,
852 scoped: vec![],
853 extra: Some(ExtraUsage {
854 limit: None,
855 spent: Cents(14157),
856 currency: Some("BRL".into()),
857 decimal_places: Some(2),
858 }),
859 };
860 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
861 let extra = sections
862 .iter()
863 .find_map(|s| match s {
864 Section::Metric {
865 label,
866 pct,
867 value_label,
868 footnote,
869 ..
870 } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
871 _ => None,
872 })
873 .expect("uncapped extra usage must still render a section");
874 assert_eq!(extra.0, 0);
875 assert_eq!(extra.1, "R$141.57");
877 assert!(
878 !extra.1.contains(" of "),
879 "no denominator to show: {}",
880 extra.1
881 );
882 assert_eq!(extra.2, "no monthly limit reported");
883 }
884
885 #[test]
886 fn anthropic_omits_sonnet_and_extra_when_absent() {
887 let snap = AnthropicSnapshot {
888 plan: "Pro".into(),
889 session: UsageWindow {
890 utilization_pct: 10,
891 resets_at: None,
892 window_duration: chrono::Duration::hours(5),
893 },
894 weekly: UsageWindow {
895 utilization_pct: 5,
896 resets_at: None,
897 window_duration: chrono::Duration::days(7),
898 },
899 sonnet: None,
900 scoped: vec![],
901 extra: None,
902 };
903 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
904 let metric_count = sections
905 .iter()
906 .filter(|s| matches!(s, Section::Metric { .. }))
907 .count();
908 assert_eq!(metric_count, 2);
909 }
910
911 #[test]
912 fn openrouter_always_has_balance_metric_and_period_block() {
913 let snap = OpenRouterSnapshot {
914 label: "OR".into(),
915 total_credits: 100.0,
916 total_usage: 25.0,
917 usage_daily: 1.0,
918 usage_weekly: 5.0,
919 usage_monthly: 25.0,
920 is_free_tier: false,
921 limit: None,
922 limit_remaining: None,
923 };
924 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
925 assert!(matches!(sections[0], Section::Title { .. }));
926 assert!(
927 sections
928 .iter()
929 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
930 );
931 assert!(
932 sections
933 .iter()
934 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
935 );
936 }
937
938 #[test]
939 fn zai_no_windows_renders_message() {
940 let snap = ZaiSnapshot {
941 plan: "GLM".into(),
942 session: None,
943 weekly: None,
944 mcp: None,
945 };
946 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
947 assert!(sections.iter().any(|s| matches!(
948 s,
949 Section::Text { value, .. } if value.contains("no usage windows reported")
950 )));
951 }
952
953 #[test]
954 fn openai_no_windows_renders_message() {
955 let snap = OpenAiSnapshot {
956 plan: "ChatGPT Plus".into(),
957 session: None,
958 weekly: None,
959 code_review: None,
960 credits: None,
961 source: OpenAiSource::CodexOauth,
962 };
963 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
964 assert!(sections.iter().any(|s| matches!(
965 s,
966 Section::Text { value, .. } if value.contains("no usage windows reported")
967 )));
968 }
969
970 #[test]
971 fn loading_state_yields_loading_section() {
972 let sections = sections_for(&TabState::Loading, now(), 5);
973 assert!(sections.iter().any(|s| matches!(
974 s,
975 Section::Text { value, .. } if value.contains("Loading")
976 )));
977 }
978
979 #[test]
980 fn error_state_includes_retry_hint() {
981 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
982 assert!(sections.iter().any(|s| matches!(
983 s,
984 Section::Text { value, .. } if value.contains("token expired")
985 )));
986 assert!(sections.iter().any(|s| matches!(
987 s,
988 Section::Text { value, .. } if value.contains("`r` to retry")
989 )));
990 }
991
992 #[test]
993 fn openai_with_credits_renders_block() {
994 let snap = OpenAiSnapshot {
995 plan: "ChatGPT Plus".into(),
996 session: Some(UsageWindow {
997 utilization_pct: 1,
998 resets_at: None,
999 window_duration: chrono::Duration::hours(5),
1000 }),
1001 weekly: Some(UsageWindow {
1002 utilization_pct: 0,
1003 resets_at: None,
1004 window_duration: chrono::Duration::days(7),
1005 }),
1006 code_review: None,
1007 credits: Some(OpenAiCredits {
1008 balance: "$5.00".into(),
1009 has_credits: true,
1010 unlimited: false,
1011 approx_local_messages: Some((100, 200)),
1012 approx_cloud_messages: Some((30, 50)),
1013 }),
1014 source: OpenAiSource::CodexOauth,
1015 };
1016 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1017 assert!(
1018 sections
1019 .iter()
1020 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
1021 );
1022 }
1023
1024 #[test]
1025 fn openai_weekly_only_omits_session_section() {
1026 let snap = OpenAiSnapshot {
1027 plan: "ChatGPT Prolite".into(),
1028 session: None,
1029 weekly: Some(UsageWindow {
1030 utilization_pct: 66,
1031 resets_at: None,
1032 window_duration: chrono::Duration::days(7),
1033 }),
1034 code_review: None,
1035 credits: None,
1036 source: OpenAiSource::CodexOauth,
1037 };
1038 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1039 assert!(sections.iter().any(|section| matches!(
1040 section,
1041 Section::Metric { label, .. } if label == "Codex weekly"
1042 )));
1043 assert!(!sections.iter().any(|section| matches!(
1044 section,
1045 Section::Metric { label, .. } if label == "Codex 5h"
1046 )));
1047 }
1048
1049 #[test]
1050 fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
1051 let now = now();
1052 let snap = KimiSnapshot {
1053 plan: Some("LEVEL_INTERMEDIATE".into()),
1054 weekly_limit: 100,
1055 weekly_used: 26,
1056 weekly_remaining: 74,
1057 weekly_reset_at: Some(now + chrono::Duration::days(4)),
1058 window_limit: 100,
1059 window_used: 15,
1060 window_remaining: 85,
1061 window_reset_at: Some(now + chrono::Duration::hours(2)),
1062 };
1063 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
1064 let metrics: Vec<_> = sections
1065 .iter()
1066 .filter(|s| matches!(s, Section::Metric { .. }))
1067 .collect();
1068 assert_eq!(metrics.len(), 2);
1069 assert!(sections.iter().any(|s| matches!(
1070 s,
1071 Section::Metric { label, .. } if label == "Weekly quota"
1072 )));
1073 assert!(sections.iter().any(|s| matches!(
1074 s,
1075 Section::Metric { label, .. } if label == "Rolling window (5h)"
1076 )));
1077
1078 let find_footnote = |label: &str| -> (String, String) {
1079 sections
1080 .iter()
1081 .find_map(|s| match s {
1082 Section::Metric {
1083 label: l,
1084 value_label,
1085 footnote,
1086 ..
1087 } if l == label => Some((value_label.clone(), footnote.clone())),
1088 _ => None,
1089 })
1090 .unwrap_or_else(|| panic!("missing metric {label}"))
1091 };
1092
1093 let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
1094 assert_eq!(weekly_value, "26 / 100");
1095 assert!(weekly_footnote.contains("74 remaining"));
1096 assert!(
1097 weekly_footnote.contains("4d 0h"),
1098 "weekly reset countdown: {weekly_footnote}"
1099 );
1100 assert!(!weekly_footnote.contains("2026-05-27T")); let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
1103 assert_eq!(window_value, "15 / 100");
1104 assert!(window_footnote.contains("85 remaining"));
1105 assert!(
1106 window_footnote.contains("2h 00m"),
1107 "window reset countdown: {window_footnote}"
1108 );
1109 assert!(!window_footnote.contains("2026-05-23T14")); }
1111
1112 #[test]
1113 fn kimi_sections_omit_window_when_limit_zero() {
1114 let snap = KimiSnapshot {
1115 plan: None,
1116 weekly_limit: 100,
1117 weekly_used: 10,
1118 weekly_remaining: 90,
1119 weekly_reset_at: None,
1120 window_limit: 0,
1121 window_used: 0,
1122 window_remaining: 0,
1123 window_reset_at: None,
1124 };
1125 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
1126 let metric_count = sections
1127 .iter()
1128 .filter(|s| matches!(s, Section::Metric { .. }))
1129 .count();
1130 assert_eq!(metric_count, 1);
1131 }
1132
1133 #[test]
1134 fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
1135 let snap = KimiSnapshot {
1136 plan: None,
1137 weekly_limit: 100,
1138 weekly_used: 10,
1139 weekly_remaining: 90,
1140 weekly_reset_at: None,
1141 window_limit: 0,
1142 window_used: 0,
1143 window_remaining: 0,
1144 window_reset_at: None,
1145 };
1146 let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
1147 let TabState::Ready(tab) = &mut schema else {
1148 unreachable!()
1149 };
1150 tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
1151 let schema_sections = sections_for(&schema, now(), 5);
1152 assert!(schema_sections.iter().any(|section| matches!(
1153 section,
1154 Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
1155 )));
1156
1157 let mut generic = ready(VendorSnapshot::Kimi(snap));
1158 let TabState::Ready(tab) = &mut generic else {
1159 unreachable!()
1160 };
1161 tab.last_error = Some((0, "cache lock unavailable".into()));
1162 let generic_sections = sections_for(&generic, now(), 5);
1163 assert!(generic_sections.iter().any(|section| matches!(
1164 section,
1165 Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
1166 )));
1167 assert!(!generic_sections.iter().any(|section| matches!(
1168 section,
1169 Section::Text { label, .. } if label.starts_with("HTTP")
1170 )));
1171
1172 let http = warning_label(
1173 &VendorSnapshot::Kimi(KimiSnapshot {
1174 plan: None,
1175 weekly_limit: 0,
1176 weekly_used: 0,
1177 weekly_remaining: 0,
1178 weekly_reset_at: None,
1179 window_limit: 0,
1180 window_used: 0,
1181 window_remaining: 0,
1182 window_reset_at: None,
1183 }),
1184 &Some((503, "service unavailable".into())),
1185 );
1186 assert_eq!(
1187 http,
1188 Some(("HTTP 503".into(), "service unavailable".into()))
1189 );
1190 }
1191}