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