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, money, reset_credit_lines, usd};
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(crate) struct SectionProjection {
59 pub section: Section,
60 pub reset_at: Option<DateTime<Utc>>,
61 pub window: Option<chrono::Duration>,
66 pub group: Option<&'static str>,
71}
72
73struct SectionBuilder(Vec<SectionProjection>);
74
75impl SectionBuilder {
76 fn new(sections: Vec<Section>) -> Self {
77 Self(
78 sections
79 .into_iter()
80 .map(|section| {
81 assert!(
82 !matches!(section, Section::Metric { .. }),
83 "metric sections must declare reset metadata with push_metric"
84 );
85 SectionProjection {
86 section,
87 reset_at: None,
88 window: None,
89 group: None,
90 }
91 })
92 .collect(),
93 )
94 }
95
96 fn push(&mut self, section: Section) {
97 assert!(
98 !matches!(section, Section::Metric { .. }),
99 "metric sections must declare reset metadata with push_metric"
100 );
101 self.0.push(SectionProjection {
102 section,
103 reset_at: None,
104 window: None,
105 group: None,
106 });
107 }
108
109 fn push_metric(&mut self, section: Section, reset_at: Option<DateTime<Utc>>) {
112 assert!(matches!(section, Section::Metric { .. }));
113 self.0.push(SectionProjection {
114 section,
115 reset_at,
116 window: None,
117 group: None,
118 });
119 }
120
121 fn push_metric_in_window(
124 &mut self,
125 section: Section,
126 reset_at: Option<DateTime<Utc>>,
127 window: chrono::Duration,
128 ) {
129 assert!(matches!(section, Section::Metric { .. }));
130 self.0.push(SectionProjection {
131 section,
132 reset_at,
133 window: Some(window),
134 group: None,
135 });
136 }
137
138 fn push_metric_in_group(&mut self, section: Section, group: &'static str) {
143 assert!(matches!(section, Section::Metric { .. }));
144 self.0.push(SectionProjection {
145 section,
146 reset_at: None,
147 window: None,
148 group: Some(group),
149 });
150 }
151}
152
153pub fn compact_cells(snapshot: &VendorSnapshot) -> (String, Vec<(String, PaceSeverity)>) {
159 let pct = |label: &str, p: i32| (format!("{label} {p}%"), severity_for(p));
160 let usd_cell = |v: f64| (usd(v), PaceSeverity::Low);
163 let money_cell = |v: f64, c: &str| (money(v, c), PaceSeverity::Low);
164 let (plan, mut cells) = match snapshot {
165 VendorSnapshot::Anthropic(s) => {
166 let mut cells = vec![
167 pct("S", s.session.utilization_pct),
168 pct("W", s.weekly.utilization_pct),
169 ];
170 if let Some(sonnet) = &s.sonnet {
171 cells.push(pct("Son", sonnet.utilization_pct));
172 }
173 (s.plan.clone(), cells)
174 }
175 VendorSnapshot::AnthropicApi(s) => {
176 let cell = match s.pct() {
177 Some(p) => pct("spend", p),
178 None => (format!("{}/mo", usd(s.spent)), PaceSeverity::Low),
179 };
180 (String::new(), vec![cell])
181 }
182 VendorSnapshot::Openai(s) => {
183 let mut cells = Vec::new();
184 if let Some(w) = &s.session {
185 cells.push(pct("5h", w.utilization_pct));
186 }
187 if let Some(w) = &s.weekly {
188 cells.push(pct("7d", w.utilization_pct));
189 }
190 if cells.is_empty() {
191 cells.push(("—".into(), PaceSeverity::Low));
192 }
193 (s.plan.clone(), cells)
194 }
195 VendorSnapshot::Copilot(s) => (
196 s.plan.clone(),
197 s.quotas()
198 .map(|(label, quota)| pct(label, quota.used_pct()))
199 .collect(),
200 ),
201 VendorSnapshot::Zai(s) => {
202 let mut cells = Vec::new();
203 if let Some(w) = &s.session {
204 cells.push(pct("S", w.utilization_pct));
205 }
206 if let Some(w) = &s.weekly {
207 cells.push(pct("W", w.utilization_pct));
208 }
209 if cells.is_empty() {
210 cells.push(("—".into(), PaceSeverity::Low));
211 }
212 (s.plan.clone(), cells)
213 }
214 VendorSnapshot::Openrouter(s) => (String::new(), vec![usd_cell(s.balance())]),
215 VendorSnapshot::Deepseek(s) => (String::new(), vec![money_cell(s.balance, &s.currency)]),
216 VendorSnapshot::Kimi(s) => {
217 let mut cells = vec![pct("5h", s.window_pct())];
218 if s.has_weekly {
219 cells.push(pct("wk", s.weekly_pct()));
220 }
221 if let Some(monthly) = s.monthly_pct {
222 cells.push(pct("mo", monthly));
223 }
224 (s.plan.clone().unwrap_or_default(), cells)
225 }
226 VendorSnapshot::Kilo(s) => (String::new(), vec![usd_cell(s.balance)]),
227 VendorSnapshot::Novita(s) => (String::new(), vec![usd_cell(s.available)]),
228 VendorSnapshot::Moonshot(s) => (String::new(), vec![money_cell(s.available, &s.currency)]),
229 VendorSnapshot::Grok(s) => (String::new(), vec![usd_cell(s.balance)]),
230 VendorSnapshot::SuperGrok(s) => (s.plan.clone(), vec![pct(s.period.short(), s.weekly_pct)]),
231 VendorSnapshot::Grokbot(s) => {
232 let cells = if s.has_included_allowance {
234 vec![pct("wk", s.weekly_pct)]
235 } else {
236 vec![("—".into(), PaceSeverity::Low)]
237 };
238 (s.plan.clone(), cells)
239 }
240 VendorSnapshot::Antigravity(s) => (
241 s.plan.clone(),
242 [
243 s.session.as_ref().map(|w| pct("S", w.utilization_pct)),
244 s.weekly.as_ref().map(|w| pct("W", w.utilization_pct)),
245 ]
246 .into_iter()
247 .flatten()
248 .collect(),
249 ),
250 VendorSnapshot::Cursor(s) => (
251 s.plan.clone(),
252 vec![pct("auto", s.auto_pct), pct("premium", s.api_pct)],
253 ),
254 VendorSnapshot::Minimax(s) => (
255 s.plan.clone(),
256 vec![
257 pct("S", s.session.utilization_pct),
258 pct("W", s.weekly.utilization_pct),
259 ],
260 ),
261 VendorSnapshot::Kiro(s) => (s.plan.clone(), vec![pct("credits", s.pct())]),
262 VendorSnapshot::NousResearch(s) => {
263 let cell = s
264 .usage_percent()
265 .map(|value| pct("usage", value.round().clamp(0.0, 100.0) as i32))
266 .unwrap_or_else(|| ("—".into(), PaceSeverity::Low));
267 (s.plan.clone().unwrap_or_default(), vec![cell])
268 }
269 VendorSnapshot::CommandCode(s) => {
270 let cells = [
271 ("session", s.five_hour.as_ref()),
272 ("weekly", s.weekly.as_ref()),
273 ("monthly", s.monthly_window().as_ref()),
274 ]
275 .into_iter()
276 .filter_map(|(label, window)| window.map(|window| pct(label, window.pct())))
277 .collect();
278 (s.plan.clone().unwrap_or_default(), cells)
279 }
280 VendorSnapshot::OpenCodeGo(s) => {
281 let cells = [
282 ("rolling", s.rolling.as_ref()),
283 ("weekly", s.weekly.as_ref()),
284 ("monthly", s.monthly.as_ref()),
285 ]
286 .into_iter()
287 .filter_map(|(label, window)| {
288 window.map(|window| pct(label, window.percent.round().clamp(0.0, 100.0) as i32))
289 })
290 .collect();
291 ("OpenCode Go".into(), cells)
292 }
293 VendorSnapshot::Ollama(s) => {
294 let cells = [
295 ("5h", s.session.as_ref()),
296 ("wk", s.weekly.as_ref()),
297 ("mo", s.monthly.as_ref()),
298 ]
299 .into_iter()
300 .filter_map(|(label, window)| {
301 window.map(|window| pct(label, window.utilization_pct.clamp(0, 100)))
302 })
303 .collect();
304 (s.plan.clone(), cells)
305 }
306 VendorSnapshot::Custom(s) => (
307 s.plan.clone().unwrap_or_default(),
308 s.metrics
309 .iter()
310 .take(3)
311 .map(|metric| pct(&metric.label, i32::from(metric.pct)))
312 .collect(),
313 ),
314 };
315
316 for (text, _) in &mut cells {
317 *text = crate::display::sanitize_untrusted_field(text);
318 }
319 (crate::display::sanitize_untrusted_field(&plan), cells)
320}
321
322pub fn headline_pct(snapshot: &VendorSnapshot) -> Option<i32> {
327 match snapshot {
328 VendorSnapshot::Anthropic(s) => [
329 Some(s.session.utilization_pct),
330 Some(s.weekly.utilization_pct),
331 s.sonnet.as_ref().map(|w| w.utilization_pct),
332 ]
333 .into_iter()
334 .flatten()
335 .max(),
336 VendorSnapshot::AnthropicApi(s) => s.pct(),
337 VendorSnapshot::Openai(s) => [
338 s.session.as_ref().map(|w| w.utilization_pct),
339 s.weekly.as_ref().map(|w| w.utilization_pct),
340 ]
341 .into_iter()
342 .flatten()
343 .max(),
344 VendorSnapshot::Copilot(s) => s.quotas().map(|(_, quota)| quota.used_pct()).max(),
345 VendorSnapshot::Zai(s) => [
346 s.session.as_ref().map(|w| w.utilization_pct),
347 s.weekly.as_ref().map(|w| w.utilization_pct),
348 ]
349 .into_iter()
350 .flatten()
351 .max(),
352 VendorSnapshot::Kimi(s) => Some(s.worst_pct()),
353 VendorSnapshot::Antigravity(s) => [
354 s.session.as_ref().map(|w| w.utilization_pct),
355 s.weekly.as_ref().map(|w| w.utilization_pct),
356 s.third_party_session.as_ref().map(|w| w.utilization_pct),
357 s.third_party_weekly.as_ref().map(|w| w.utilization_pct),
358 ]
359 .into_iter()
360 .flatten()
361 .max(),
362 VendorSnapshot::Cursor(s) => (!s.unlimited).then_some(s.total_pct),
363 VendorSnapshot::Minimax(s) => Some(s.session.utilization_pct.max(s.weekly.utilization_pct)),
364 VendorSnapshot::Kiro(s) => Some(s.pct()),
365 VendorSnapshot::NousResearch(s) => s
366 .usage_percent()
367 .map(|value| value.round().clamp(0.0, 100.0) as i32),
368 VendorSnapshot::CommandCode(s) => {
369 let worst = s.worst_pct();
370 (s.five_hour.is_some() || s.weekly.is_some()).then_some(worst)
371 }
372 VendorSnapshot::OpenCodeGo(s) => [
373 s.rolling
374 .as_ref()
375 .map(|window| window.percent.round() as i32),
376 s.weekly
377 .as_ref()
378 .map(|window| window.percent.round() as i32),
379 s.monthly
380 .as_ref()
381 .map(|window| window.percent.round() as i32),
382 ]
383 .into_iter()
384 .flatten()
385 .max(),
386 VendorSnapshot::SuperGrok(s) => Some(s.weekly_pct),
387 VendorSnapshot::Grokbot(s) => s.has_included_allowance.then_some(s.weekly_pct),
388 VendorSnapshot::Ollama(s) => [
389 s.session.as_ref().map(|w| w.utilization_pct),
390 s.weekly.as_ref().map(|w| w.utilization_pct),
391 s.monthly.as_ref().map(|w| w.utilization_pct),
392 ]
393 .into_iter()
394 .flatten()
395 .max(),
396 VendorSnapshot::Custom(s) => s.metrics.first().map(|metric| i32::from(metric.pct)),
397 VendorSnapshot::Openrouter(_)
398 | VendorSnapshot::Deepseek(_)
399 | VendorSnapshot::Kilo(_)
400 | VendorSnapshot::Novita(_)
401 | VendorSnapshot::Moonshot(_)
402 | VendorSnapshot::Grok(_) => None,
403 }
404}
405
406pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
408 sections_with_metadata_for(tab, now, pace_tolerance)
409 .into_iter()
410 .map(|projected| projected.section)
411 .collect()
412}
413
414pub(crate) fn sections_with_metadata_for(
417 tab: &TabState,
418 now: DateTime<Utc>,
419 pace_tolerance: u32,
420) -> Vec<SectionProjection> {
421 let mut sections = match tab {
422 TabState::Loading => SectionBuilder::new(vec![
423 Section::Spacer,
424 Section::Text {
425 label: "".into(),
426 value: " Loading…".into(),
427 },
428 ]),
429 TabState::Error { message: e, plan } => {
430 let mut rows = Vec::new();
431 if let Some(plan) = plan {
432 rows.push(Section::Title {
433 left: plan.clone(),
434 right: None,
435 });
436 }
437 rows.extend([
438 Section::Spacer,
439 Section::Text {
440 label: "Error".into(),
441 value: e.clone(),
442 },
443 Section::Spacer,
444 Section::Text {
445 label: "".into(),
446 value: "Press `r` to retry, `q` to quit.".into(),
447 },
448 ]);
449 SectionBuilder::new(rows)
450 }
451 TabState::Ready(r) => {
452 let snapshot = &r.snapshot;
453 let last_error = &r.last_error;
454 let mut sections = match snapshot {
455 VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
456 VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
457 VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
458 VendorSnapshot::Copilot(s) => copilot_sections(s, now),
459 VendorSnapshot::Zai(s) => zai_sections(s, now, pace_tolerance),
460 VendorSnapshot::Openrouter(s) => openrouter_sections(s),
461 VendorSnapshot::Deepseek(s) => deepseek_sections(s),
462 VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
463 VendorSnapshot::Kilo(s) => kilo_sections(s),
464 VendorSnapshot::Novita(s) => novita_sections(s),
465 VendorSnapshot::Moonshot(s) => moonshot_sections(s),
466 VendorSnapshot::Grok(s) => grok_sections(s),
467 VendorSnapshot::SuperGrok(s) => supergrok_sections(s, now),
468 VendorSnapshot::Grokbot(s) => grokbot_sections(s, now),
469 VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
470 VendorSnapshot::Cursor(s) => cursor_sections(s, now),
471 VendorSnapshot::Minimax(s) => minimax_sections(s, now, pace_tolerance),
472 VendorSnapshot::Kiro(s) => kiro_sections(s, now),
473 VendorSnapshot::NousResearch(s) => nous_sections(s, now),
474 VendorSnapshot::OpenCodeGo(s) => opencode_go_sections(s, now, pace_tolerance),
475 VendorSnapshot::CommandCode(s) => commandcode_sections(s, now),
476 VendorSnapshot::Ollama(s) => ollama_sections(s, now, pace_tolerance),
477 VendorSnapshot::Custom(s) => custom_sections(s),
478 };
479 let updated = match r.fetched_at {
483 Some(at) => format!("Updated {}", local_time_hms(at)),
484 None => "Updated —".to_string(),
485 };
486 if let Some(SectionProjection {
487 section: Section::Title { right, .. },
488 ..
489 }) = sections.0.first_mut()
490 {
491 *right = Some(updated);
492 }
493 if let Some((label, msg)) = warning_label(snapshot, last_error) {
495 sections.push(Section::Spacer);
496 sections.push(Section::Text { label, value: msg });
497 }
498 sections
499 }
500 };
501 for projected in &mut sections.0 {
502 sanitize_section(&mut projected.section);
503 }
504 sections.0
505}
506
507fn sanitize_section(section: &mut Section) {
510 let clean = |value: &mut String| {
511 *value = crate::display::sanitize_untrusted_field(value);
512 };
513 match section {
514 Section::Title { left, right } => {
515 clean(left);
516 if let Some(right) = right {
517 clean(right);
518 }
519 }
520 Section::Metric {
521 label,
522 value_label,
523 footnote,
524 ..
525 } => {
526 clean(label);
527 clean(value_label);
528 clean(footnote);
529 }
530 Section::Text { label, value } => {
531 clean(label);
532 clean(value);
533 }
534 Section::Block { label, body } => {
535 clean(label);
536 for line in body {
537 clean(line);
538 }
539 }
540 Section::Spacer => {}
541 }
542}
543
544fn warning_label(
548 snapshot: &VendorSnapshot,
549 last_error: &Option<(u16, String)>,
550) -> Option<(String, String)> {
551 let (code, message) = last_error.as_ref()?;
552 if *code != 0 {
553 return Some((format!("HTTP {code}"), message.clone()));
554 }
555 if message.is_empty() {
556 return None;
557 }
558 let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
559 && matches!(
560 crate::kimi::vendor::warning_kind(*code, message),
561 crate::kimi::vendor::WarningKind::SchemaDrift
562 ) {
563 "Kimi API schema drift"
564 } else {
565 "Warning"
566 };
567 let value = if label == message {
570 String::new()
571 } else {
572 message.clone()
573 };
574 Some((label.into(), value))
575}
576
577fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> SectionBuilder {
578 let mut v = SectionBuilder::new(vec![Section::Title {
579 left: "Anthropic API".into(),
580 right: None,
581 }]);
582 match (s.limit.filter(|l| *l > 0.0), s.pct()) {
583 (Some(limit), Some(pct)) => {
584 let p = pct.clamp(0, 100) as u16;
585 v.push_metric(
586 Section::Metric {
587 label: "Spend (mo)".into(),
588 pct: p,
589 severity: severity_for(pct),
590 value_label: format!("{} of ${:.0}", usd(s.spent), limit),
591 footnote: format!("{pct}% of monthly limit"),
592 },
593 None,
594 );
595 }
596 _ => {
597 v.push(Section::Text {
598 label: "Spend (mo)".into(),
599 value: usd(s.spent),
600 });
601 }
602 }
603 v.push(Section::Spacer);
604 v.push(Section::Text {
605 label: "".into(),
606 value: "Month-to-date cost via the Admin usage API.".into(),
607 });
608 v.push(Section::Text {
609 label: "".into(),
610 value: "Prepaid credit balance is Console-only (no API).".into(),
611 });
612 v.push(Section::Text {
613 label: "".into(),
614 value: "Excludes Priority Tier cost (not reported by this API).".into(),
615 });
616 v
617}
618
619fn anthropic_sections(
620 s: &crate::usage::AnthropicSnapshot,
621 now: DateTime<Utc>,
622 tol: u32,
623) -> SectionBuilder {
624 let mut v = SectionBuilder::new(vec![Section::Title {
625 left: format!("Claude {}", s.plan),
626 right: None,
627 }]);
628
629 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
630 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
631 if let Some(w) = &s.sonnet {
632 push_window(&mut v, "Sonnet only", w, now, tol, false);
633 }
634 for sw in &s.scoped {
635 push_window(
636 &mut v,
637 &format!("{} (7d)", sw.label),
638 &sw.window,
639 now,
640 tol,
641 false,
642 );
643 }
644 if let Some(e) = &s.extra {
645 v.push(Section::Spacer);
646 let pct = e.percent().clamp(0, 100) as u16;
647 let (value_label, footnote) = match e.fmt_limit() {
651 Some(l) => (
652 format!("{} of {}", e.fmt_spent(), l),
653 format!("{pct}% of monthly limit consumed"),
654 ),
655 None => (e.fmt_spent(), "no monthly limit reported".to_string()),
656 };
657 v.push_metric(
658 Section::Metric {
659 label: "Extra usage".into(),
660 pct,
661 severity: severity_for(pct as i32),
662 value_label,
663 footnote,
664 },
665 None,
666 );
667 }
668 v
669}
670
671fn openai_sections(
672 s: &crate::usage::OpenAiSnapshot,
673 now: DateTime<Utc>,
674 tol: u32,
675) -> SectionBuilder {
676 let mut v = SectionBuilder::new(vec![Section::Title {
677 left: s.plan.clone(),
678 right: None,
679 }]);
680 if let Some(session) = &s.session {
681 push_window(&mut v, "Codex 5h", session, now, tol, true);
682 }
683 if let Some(weekly) = &s.weekly {
684 push_window(&mut v, "Codex weekly", weekly, now, tol, true);
685 }
686 if s.session.is_none() && s.weekly.is_none() {
687 v.push(Section::Spacer);
688 v.push(Section::Text {
689 label: "".into(),
690 value: " no usage windows reported".into(),
691 });
692 }
693 if let Some(cr) = &s.code_review {
694 push_window(&mut v, "Code review", cr, now, tol, false);
695 }
696 for limit in &s.additional_limits {
699 if let Some(w) = &limit.session {
700 push_window(&mut v, &format!("{} (5h)", limit.name), w, now, tol, false);
701 }
702 if let Some(w) = &limit.weekly {
703 push_window(&mut v, &format!("{} (7d)", limit.name), w, now, tol, false);
704 }
705 }
706 if !s.unavailable_models.is_empty() {
709 v.push(Section::Spacer);
710 v.push(Section::Block {
711 label: "Unavailable".into(),
712 body: s
713 .unavailable_models
714 .iter()
715 .map(|m| match m.available_at {
716 Some(at) => format!("{} — back {}", m.model, countdown::format(Some(at), now)),
717 None => format!("{} — at capacity", m.model),
718 })
719 .collect(),
720 });
721 }
722 if let Some(c) = &s.credits {
723 v.push(Section::Spacer);
724 let balance = if c.unlimited {
725 "unlimited".into()
726 } else {
727 c.balance.clone()
728 };
729 let mut body = vec![format!("balance: {}", balance)];
730 if let Some((lo, hi)) = c.approx_local_messages {
731 body.push(format!("≈ {lo}-{hi} local messages"));
732 }
733 if let Some((lo, hi)) = c.approx_cloud_messages {
734 body.push(format!("≈ {lo}-{hi} cloud messages"));
735 }
736 v.push(Section::Block {
737 label: "Credits".into(),
738 body,
739 });
740 }
741 push_reset_credits(&mut v, &s.reset_credits, now);
742 v
743}
744
745fn copilot_sections(s: &crate::copilot::types::Snapshot, now: DateTime<Utc>) -> SectionBuilder {
746 let mut sections = SectionBuilder::new(vec![Section::Title {
747 left: format!("GitHub Copilot {}", s.plan),
748 right: None,
749 }]);
750 for (label, quota) in s.quotas() {
751 let pct = quota.used_pct();
752 let detail = if quota.unlimited {
753 "Unlimited".to_string()
754 } else {
755 quota
756 .used_and_entitlement()
757 .map(|(used, entitlement)| format!("{used} of {entitlement} used"))
758 .unwrap_or_else(|| format!("{}% remaining", quota.percent_remaining))
759 };
760 sections.push(Section::Spacer);
761 sections.push_metric(
762 Section::Metric {
763 label: label.to_string(),
764 pct: pct.clamp(0, 100) as u16,
765 severity: severity_for(pct),
766 value_label: if quota.unlimited {
767 "Unlimited".to_string()
768 } else {
769 format!("{pct}%")
770 },
771 footnote: detail,
772 },
773 s.reset_at,
774 );
775 }
776 sections.push(Section::Spacer);
777 sections.push(Section::Text {
778 label: "Resets".into(),
779 value: countdown::format(s.reset_at, now),
780 });
781 sections
782}
783
784fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>, tol: u32) -> SectionBuilder {
785 let mut v = SectionBuilder::new(vec![Section::Title {
786 left: s.plan.clone(),
787 right: None,
788 }]);
789 if let Some(w) = &s.session {
790 push_window(&mut v, "Session (5h)", w, now, tol, true);
791 }
792 if let Some(w) = &s.weekly {
793 push_window(&mut v, "Weekly", w, now, tol, true);
794 }
795 if let Some(w) = &s.mcp {
796 push_window(&mut v, "MCP tools (monthly)", w, now, tol, true);
797 }
798 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
799 v.push(Section::Spacer);
800 v.push(Section::Text {
801 label: "".into(),
802 value: " no usage windows reported".into(),
803 });
804 }
805 v
806}
807
808fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> SectionBuilder {
809 let mut v = SectionBuilder::new(vec![Section::Title {
810 left: s.label.clone(),
811 right: None,
812 }]);
813 let pct = s.consumed_pct().clamp(0, 100) as u16;
814 v.push(Section::Spacer);
815 v.push_metric(
816 Section::Metric {
817 label: "Credit balance".into(),
818 pct,
819 severity: crate::openrouter::vendor::severity(s),
823 value_label: usd(s.balance()),
824 footnote: format!(
825 "{} of {} used ({pct}%)",
826 usd(s.total_usage),
827 usd(s.total_credits)
828 ),
829 },
830 None,
831 );
832 v.push(Section::Spacer);
833 v.push(Section::Block {
834 label: "Usage by period".into(),
835 body: vec![format!(
836 "today ${:.2} · week ${:.2} · month ${:.2}",
837 s.usage_daily, s.usage_weekly, s.usage_monthly
838 )],
839 });
840 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
841 v.push(Section::Spacer);
842 v.push(Section::Block {
843 label: "Per-key limit".into(),
844 body: vec![format!("{} of {} remaining", usd(rem), usd(limit))],
845 });
846 }
847 v.push(Section::Spacer);
848 v.push(Section::Block {
849 label: "Tier".into(),
850 body: vec![if s.is_free_tier {
851 "free tier".into()
852 } else {
853 "paid tier".into()
854 }],
855 });
856 v
857}
858
859fn antigravity_sections(
863 s: &crate::usage::AntigravitySnapshot,
864 now: DateTime<Utc>,
865) -> SectionBuilder {
866 use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
867
868 let mut v = SectionBuilder::new(vec![Section::Title {
869 left: s.plan.clone(),
870 right: None,
871 }]);
872 for (heading, primary, third_party) in [
873 (
874 "Session",
875 s.session.as_ref(),
876 s.third_party_session.as_ref(),
877 ),
878 ("Weekly", s.weekly.as_ref(), s.third_party_weekly.as_ref()),
879 ] {
880 if primary.is_none() && third_party.is_none() {
883 continue;
884 }
885 v.push(Section::Spacer);
886 v.push(Section::Text {
887 label: heading.into(),
888 value: String::new(),
889 });
890 if let Some(w) = primary {
891 push_window(&mut v, GROUP_PRIMARY, w, now, 5, false);
892 }
893 if let Some(w) = third_party {
894 push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
895 }
896 }
897 if s.source == crate::usage::AntigravitySource::Remote {
900 v.push(Section::Spacer);
901 v.push(Section::Text {
902 label: "Source".into(),
903 value: "Google API".into(),
904 });
905 }
906 v
907}
908
909fn push_cursor_pool(v: &mut SectionBuilder, section: Section, s: &crate::usage::CursorSnapshot) {
913 match s.cycle_window() {
914 Some(window) => v.push_metric_in_window(section, s.reset_at, window),
915 None => v.push_metric(section, s.reset_at),
916 }
917}
918
919fn cursor_sections(s: &crate::usage::CursorSnapshot, now: DateTime<Utc>) -> SectionBuilder {
920 let mut v = SectionBuilder::new(vec![Section::Title {
921 left: format!("Cursor {}", s.plan),
922 right: None,
923 }]);
924 if s.unlimited {
925 v.push(Section::Spacer);
926 v.push(Section::Text {
927 label: "Plan".into(),
928 value: "Unlimited — pools don't cap".into(),
929 });
930 } else {
931 v.push(Section::Spacer);
933 push_cursor_pool(
934 &mut v,
935 Section::Metric {
936 label: "Cursor Models".into(),
937 pct: s.auto_pct.clamp(0, 100) as u16,
938 severity: severity_for(s.auto_pct),
939 value_label: format!("{}%", s.auto_pct),
940 footnote: "Auto + Composer".into(),
941 },
942 s,
943 );
944 v.push(Section::Spacer);
945 push_cursor_pool(
946 &mut v,
947 Section::Metric {
948 label: "Other Models".into(),
949 pct: s.api_pct.clamp(0, 100) as u16,
950 severity: severity_for(s.api_pct),
951 value_label: format!("{}%", s.api_pct),
952 footnote: format!(
953 "Named / API models · on-demand {}",
954 if s.on_demand_enabled { "on" } else { "off" }
955 ),
956 },
957 s,
958 );
959 if let Some(used) = s.on_demand_used_cents {
960 v.push(Section::Spacer);
961 v.push(Section::Text {
962 label: "On-Demand".into(),
963 value: match s.on_demand_limit_cents {
964 Some(limit) => format!(
965 "{} / {}",
966 crate::usage::fmt_minor(used, 2, Some("USD")),
967 crate::usage::fmt_minor(limit, 2, Some("USD"))
968 ),
969 None => crate::usage::fmt_minor(used, 2, Some("USD")),
970 },
971 });
972 }
973 }
974 v.push(Section::Spacer);
975 v.push(Section::Text {
976 label: "Resets".into(),
977 value: countdown::format(s.reset_at, now),
978 });
979 v
980}
981
982fn nous_sections(s: &crate::nous::types::AccountSnapshot, now: DateTime<Utc>) -> SectionBuilder {
983 let mut sections = SectionBuilder::new(vec![Section::Title {
984 left: "Nous Research".into(),
985 right: None,
986 }]);
987 if let Some(value) = s.usage_percent() {
988 let pct = value.round().clamp(0.0, 100.0) as i32;
989 sections.push_metric(
990 Section::Metric {
991 label: "Usage".into(),
992 pct: pct as u16,
993 severity: severity_for(pct),
994 value_label: format!("{pct}%"),
995 footnote: "current period".into(),
996 },
997 s.current_period_end,
998 );
999 }
1000 sections.push(Section::Spacer);
1001 if let Some(remaining) = s.credits_remaining {
1002 sections.push(Section::Text {
1003 label: "Subscription credits".into(),
1004 value: format!("{remaining:.2} remaining"),
1005 });
1006 }
1007 if let Some(purchased) = s.purchased_credits_remaining {
1008 sections.push(Section::Text {
1009 label: "Top-up credits".into(),
1010 value: format!("{purchased:.2} remaining"),
1011 });
1012 }
1013 if let Some(total_usable) = s.total_usable_credits {
1014 sections.push(Section::Text {
1015 label: "Total usable credits".into(),
1016 value: format!("{total_usable:.2}"),
1017 });
1018 }
1019 if let Some(period_end) = s.current_period_end {
1020 sections.push(Section::Text {
1021 label: "Renews".into(),
1022 value: countdown::format(Some(period_end), now),
1023 });
1024 }
1025 sections
1026}
1027
1028fn commandcode_sections(
1029 s: &crate::commandcode::types::Snapshot,
1030 now: DateTime<Utc>,
1031) -> SectionBuilder {
1032 let title = match s.plan.as_deref() {
1033 Some(plan) if !plan.is_empty() => format!("Command Code {plan}"),
1034 _ => "Command Code".to_string(),
1035 };
1036 let mut sections = SectionBuilder::new(vec![Section::Title {
1037 left: title,
1038 right: None,
1039 }]);
1040 for (label, window) in [
1041 ("Session (5h)", s.five_hour.as_ref()),
1042 ("Weekly", s.weekly.as_ref()),
1043 ("Monthly", s.monthly_window().as_ref()),
1044 ] {
1045 if let Some(window) = window {
1046 let pct = window.pct();
1047 sections.push_metric(
1048 Section::Metric {
1049 label: label.into(),
1050 pct: pct.clamp(0, 100) as u16,
1051 severity: severity_for(pct),
1052 value_label: format!("{pct}%"),
1053 footnote: format!("{} of {}", usd(window.used), usd(window.cap)),
1054 },
1055 window.resets_at,
1056 );
1057 sections.push(Section::Text {
1058 label: "Resets".into(),
1059 value: countdown::format(window.resets_at, now),
1060 });
1061 }
1062 }
1063 if let Some(credits) = s.credits.as_ref() {
1064 sections.push(Section::Spacer);
1065 sections.push(Section::Text {
1066 label: "Credits".into(),
1067 value: usd(credits.remaining()),
1068 });
1069 }
1070 sections
1071}
1072
1073fn opencode_go_sections(
1074 s: &crate::opencode_go::types::Usage,
1075 now: DateTime<Utc>,
1076 tol: u32,
1077) -> SectionBuilder {
1078 use crate::opencode_go::vendor::{ROLLING_WINDOW, WEEKLY_WINDOW};
1079
1080 let mut sections = SectionBuilder::new(vec![Section::Title {
1081 left: "OpenCode Go".into(),
1082 right: None,
1083 }]);
1084 let mut any = false;
1085 for (label, window, duration) in [
1086 ("Rolling (5h)", s.rolling.as_ref(), ROLLING_WINDOW),
1087 ("Weekly (7d)", s.weekly.as_ref(), WEEKLY_WINDOW),
1088 ] {
1089 let Some(window) = window else {
1090 continue;
1091 };
1092 any = true;
1093 let pct = window.percent.round().clamp(0.0, 100.0) as i32;
1094 let projected = crate::usage::UsageWindow {
1095 utilization_pct: pct,
1096 resets_at: Some(window.resets_at),
1097 window_duration: duration,
1098 };
1099 push_window(&mut sections, label, &projected, now, tol, true);
1100 }
1101 if let Some(window) = s.monthly.as_ref() {
1106 any = true;
1107 let pct = window.percent.round().clamp(0.0, 100.0) as i32;
1108 sections.push_metric(
1109 Section::Metric {
1110 label: "Monthly".into(),
1111 pct: pct as u16,
1112 severity: severity_for(pct),
1113 value_label: format!("{pct}%"),
1114 footnote: format!(
1115 "Resets in {}",
1116 countdown::format(Some(window.resets_at), now)
1117 ),
1118 },
1119 Some(window.resets_at),
1120 );
1121 }
1122 if !any {
1123 sections.push(Section::Spacer);
1124 sections.push(Section::Text {
1125 label: "".into(),
1126 value: " no usage windows reported".into(),
1127 });
1128 }
1129 sections
1130}
1131
1132fn kiro_sections(s: &crate::usage::KiroSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1137 let pct = s.pct();
1138 let mut v = SectionBuilder::new(vec![
1139 Section::Title {
1140 left: format!("Kiro {}", s.plan),
1141 right: None,
1142 },
1143 Section::Spacer,
1144 ]);
1145 v.push_metric(
1146 Section::Metric {
1147 label: "Credits".into(),
1148 pct: pct.clamp(0, 100) as u16,
1149 severity: severity_for(pct),
1150 value_label: format!("{pct}%"),
1151 footnote: format!("{:.2} of {:.0}", s.used, s.limit),
1152 },
1153 s.reset_at,
1154 );
1155 v.push(Section::Spacer);
1156 v.push(Section::Text {
1157 label: "Resets".into(),
1158 value: countdown::format(s.reset_at, now),
1159 });
1160 v
1161}
1162
1163fn minimax_sections(
1168 s: &crate::usage::MinimaxSnapshot,
1169 now: DateTime<Utc>,
1170 tol: u32,
1171) -> SectionBuilder {
1172 use crate::minimax::vendor::{POOL_GENERAL, POOL_VIDEO};
1173
1174 let mut v = SectionBuilder::new(vec![Section::Title {
1175 left: s.plan.clone(),
1176 right: None,
1177 }]);
1178 for (heading, general, video) in [
1179 ("Session", &s.session, s.video_session.as_ref()),
1180 ("Weekly", &s.weekly, s.video_weekly.as_ref()),
1181 ] {
1182 v.push(Section::Spacer);
1183 v.push(Section::Text {
1184 label: heading.into(),
1185 value: String::new(),
1186 });
1187 push_window(&mut v, POOL_GENERAL, general, now, tol, true);
1188 if let Some(w) = video {
1189 push_window(&mut v, POOL_VIDEO, w, now, tol, true);
1190 }
1191 }
1192 v
1193}
1194
1195fn kilo_sections(s: &crate::usage::KiloSnapshot) -> SectionBuilder {
1196 SectionBuilder::new(vec![
1197 Section::Title {
1198 left: s.label.clone(),
1199 right: None,
1200 },
1201 Section::Spacer,
1202 Section::Text {
1203 label: "Balance".into(),
1204 value: usd(s.balance),
1205 },
1206 ])
1207}
1208
1209fn novita_sections(s: &crate::usage::NovitaSnapshot) -> SectionBuilder {
1210 let mut v = SectionBuilder::new(vec![
1211 Section::Title {
1212 left: "Novita".into(),
1213 right: None,
1214 },
1215 Section::Spacer,
1216 Section::Text {
1217 label: "Balance".into(),
1218 value: usd(s.available),
1219 },
1220 Section::Block {
1221 label: "Breakdown".into(),
1222 body: vec![format!(
1223 "top-up ${:.2} · credit limit ${:.2}",
1224 s.cash, s.credit_limit
1225 )],
1226 },
1227 ]);
1228 if s.outstanding > 0.0 {
1229 v.push(Section::Spacer);
1230 v.push(Section::Block {
1231 label: "Owed".into(),
1232 body: vec![usd(s.outstanding)],
1233 });
1234 }
1235 v
1236}
1237
1238fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> SectionBuilder {
1239 let cur = &s.currency;
1240 let fmt = |v: f64| money(v, cur);
1241 SectionBuilder::new(vec![
1242 Section::Title {
1243 left: "Kimi (Moonshot)".into(),
1244 right: None,
1245 },
1246 Section::Spacer,
1247 Section::Text {
1248 label: "Balance".into(),
1249 value: fmt(s.available),
1250 },
1251 Section::Block {
1252 label: "Breakdown".into(),
1253 body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
1254 },
1255 ])
1256}
1257
1258fn grok_sections(s: &crate::usage::GrokSnapshot) -> SectionBuilder {
1259 SectionBuilder::new(vec![
1260 Section::Title {
1261 left: "Grok (xAI)".into(),
1262 right: None,
1263 },
1264 Section::Spacer,
1265 Section::Text {
1266 label: "Prepaid balance".into(),
1267 value: usd(s.balance),
1268 },
1269 ])
1270}
1271
1272fn custom_sections(s: &crate::custom::types::CustomSnapshot) -> SectionBuilder {
1278 let mut v = SectionBuilder::new(vec![
1279 Section::Title {
1280 left: s.plan.clone().unwrap_or_default(),
1281 right: None,
1282 },
1283 Section::Spacer,
1284 ]);
1285 for metric in &s.metrics {
1286 let pct = metric.pct.min(100);
1287 let section = Section::Metric {
1288 label: metric.label.clone(),
1289 pct,
1290 severity: severity_for(i32::from(pct)),
1291 value_label: format!("{pct}%"),
1292 footnote: metric.footnote.clone(),
1293 };
1294 match metric.window_secs {
1295 Some(secs) => v.push_metric_in_window(
1296 section,
1297 metric.resets_at,
1298 chrono::Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX)),
1299 ),
1300 None => v.push_metric(section, metric.resets_at),
1301 }
1302 }
1303 if !s.texts.is_empty() {
1304 v.push(Section::Spacer);
1305 for text in &s.texts {
1306 v.push(Section::Text {
1307 label: text.label.clone(),
1308 value: text.value.clone(),
1309 });
1310 }
1311 }
1312 v
1313}
1314
1315fn supergrok_sections(s: &crate::usage::SuperGrokSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1316 let pct = s.weekly_pct;
1317 let mut v = SectionBuilder::new(vec![
1318 Section::Title {
1319 left: s.plan.clone(),
1320 right: None,
1321 },
1322 Section::Spacer,
1323 ]);
1324 let metric = Section::Metric {
1325 label: format!("{} usage", s.period.label()),
1326 pct: pct.clamp(0, 100) as u16,
1327 severity: severity_for(pct),
1328 value_label: format!("{pct}%"),
1329 footnote: String::new(),
1330 };
1331 if s.period == crate::usage::SuperGrokPeriod::Weekly {
1334 v.push_metric_in_window(metric, s.reset_at, chrono::Duration::days(7));
1335 } else {
1336 v.push_metric(metric, s.reset_at);
1337 }
1338 for product in &s.products {
1339 v.push_metric_in_group(
1344 Section::Metric {
1345 label: product.label.clone(),
1346 pct: product.percent.clamp(0, 100) as u16,
1347 severity: PaceSeverity::Low,
1348 value_label: format!("{}%", product.percent),
1349 footnote: String::new(),
1350 },
1351 "Breakdown",
1352 );
1353 }
1354 if let Some(bal) = s.prepaid_balance.filter(|bal| *bal > 0.0) {
1359 v.push(Section::Spacer);
1360 v.push(Section::Text {
1361 label: "Prepaid API".into(),
1362 value: usd(bal),
1363 });
1364 }
1365 push_reset_credits(&mut v, &s.reset_credits, now);
1366 v
1367}
1368
1369fn ollama_sections(
1370 s: &crate::usage::OllamaSnapshot,
1371 now: DateTime<Utc>,
1372 pace_tolerance: u32,
1373) -> SectionBuilder {
1374 let mut v = SectionBuilder::new(vec![Section::Title {
1375 left: format!("Ollama Cloud {}", s.plan),
1376 right: None,
1377 }]);
1378 if let Some(w) = &s.session {
1379 push_window(&mut v, "Session (5h)", w, now, pace_tolerance, true);
1380 }
1381 if let Some(w) = &s.weekly {
1382 push_window(&mut v, "Weekly", w, now, pace_tolerance, true);
1383 }
1384 if let Some(w) = &s.monthly {
1385 push_window(&mut v, "Monthly", w, now, pace_tolerance, true);
1386 }
1387 push_top_models(&mut v, &s.session_models, "Top models (5h)");
1388 push_top_models(&mut v, &s.weekly_models, "Top models (weekly)");
1389 push_top_models(&mut v, &s.monthly_models, "Top models (monthly)");
1390 if let Some(cost) = &s.activity_cost {
1391 v.push(Section::Spacer);
1392 v.push(Section::Block {
1393 label: "Activity".into(),
1394 body: vec![format!(
1395 "{} · {}",
1396 usd_str(cost),
1397 s.activity_period.as_deref().unwrap_or("last 4 weeks")
1398 )],
1399 });
1400 }
1401 v
1402}
1403
1404fn push_top_models(
1405 sections: &mut SectionBuilder,
1406 models: &[crate::usage::OllamaModelUsage],
1407 label: &str,
1408) {
1409 if models.is_empty() {
1410 return;
1411 }
1412 let mut sorted: Vec<&crate::usage::OllamaModelUsage> = models.iter().collect();
1413 sorted.sort_by_key(|m| std::cmp::Reverse(m.request_count));
1414 let body: Vec<String> = sorted
1415 .into_iter()
1416 .take(5)
1417 .map(|m| format!("{}: {} requests", m.name, m.request_count))
1418 .collect();
1419 sections.push(Section::Spacer);
1420 sections.push(Section::Block {
1421 label: label.into(),
1422 body,
1423 });
1424}
1425
1426fn usd_str(cost: &str) -> String {
1427 cost.parse::<f64>()
1428 .map(usd)
1429 .unwrap_or_else(|_| cost.to_string())
1430}
1431
1432fn push_reset_credits(
1433 v: &mut SectionBuilder,
1434 credits: &crate::usage::ResetCredits,
1435 now: DateTime<Utc>,
1436) {
1437 if credits.is_empty() {
1438 return;
1439 }
1440 v.push(Section::Spacer);
1441 v.push(Section::Block {
1442 label: "Reset credits".into(),
1443 body: reset_credit_lines(credits, now),
1444 });
1445}
1446
1447fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> SectionBuilder {
1448 let currency = &s.currency;
1449 let fmt = |v: f64| money(v, currency);
1450 let avail = if s.is_available {
1451 "available"
1452 } else {
1453 "unavailable"
1454 };
1455 let mut v = SectionBuilder::new(vec![Section::Title {
1456 left: "DeepSeek".into(),
1457 right: None,
1458 }]);
1459 v.push(Section::Spacer);
1460 v.push(Section::Text {
1461 label: "Balance".into(),
1462 value: fmt(s.balance),
1463 });
1464 v.push(Section::Block {
1465 label: "Breakdown".into(),
1466 body: vec![format!(
1467 "granted {} · topped-up {}",
1468 fmt(s.granted),
1469 fmt(s.topped_up)
1470 )],
1471 });
1472 v.push(Section::Spacer);
1473 v.push(Section::Block {
1474 label: "API".into(),
1475 body: vec![avail.into()],
1476 });
1477 v
1478}
1479
1480fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, tol: u32) -> SectionBuilder {
1485 use crate::kimi::vendor::{ROLLING_WINDOW, WEEKLY_WINDOW};
1486
1487 let plan = s.plan.as_deref().unwrap_or("Kimi");
1488 let mut v = SectionBuilder::new(vec![Section::Title {
1489 left: plan.into(),
1490 right: None,
1491 }]);
1492 let window = |pct, resets_at, window_duration| crate::usage::UsageWindow {
1493 utilization_pct: pct,
1494 resets_at,
1495 window_duration,
1496 };
1497
1498 if s.window_limit > 0 {
1499 let w = window(s.window_pct(), s.window_reset_at, ROLLING_WINDOW);
1500 push_window(&mut v, "Rolling window (5h)", &w, now, tol, false);
1501 }
1502 if s.has_weekly {
1503 let w = window(s.weekly_pct(), s.weekly_reset_at, WEEKLY_WINDOW);
1504 push_window(&mut v, "Weekly quota", &w, now, tol, false);
1505 }
1506 if let Some(monthly_pct) = s.monthly_pct {
1507 v.push(Section::Spacer);
1511 v.push_metric(
1512 Section::Metric {
1513 label: "Monthly".into(),
1514 pct: monthly_pct.clamp(0, 100) as u16,
1515 severity: severity_for(monthly_pct),
1516 value_label: format!("{monthly_pct}%"),
1517 footnote: format!("Resets in {}", countdown::format(s.monthly_reset_at, now)),
1518 },
1519 s.monthly_reset_at,
1520 );
1521 }
1522
1523 v
1524}
1525
1526fn grokbot_sections(s: &crate::usage::GrokbotSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1531 let mut v = SectionBuilder::new(vec![Section::Title {
1532 left: s.plan.clone(),
1533 right: None,
1534 }]);
1535 v.push(Section::Spacer);
1536 if !s.has_included_allowance {
1537 v.push(Section::Text {
1538 label: "Included usage".into(),
1539 value: "no included allowance on this account".into(),
1540 });
1541 return v;
1542 }
1543 let metric = Section::Metric {
1544 label: "Weekly".into(),
1545 pct: s.weekly_pct.clamp(0, 100) as u16,
1546 severity: severity_for(s.weekly_pct),
1547 value_label: format!("{}%", s.weekly_pct),
1548 footnote: format!("Resets in {}", countdown::format(s.reset_at, now)),
1549 };
1550 match s.window {
1551 Some(window) => v.push_metric_in_window(metric, s.reset_at, window),
1552 None => v.push_metric(metric, s.reset_at),
1553 }
1554 if let Some(note) = s.on_demand_note() {
1557 v.push(Section::Spacer);
1558 v.push(Section::Text {
1559 label: "On-demand".into(),
1560 value: note.into(),
1561 });
1562 }
1563 v
1564}
1565
1566fn push_window(
1567 sections: &mut SectionBuilder,
1568 label: &str,
1569 w: &crate::usage::UsageWindow,
1570 now: DateTime<Utc>,
1571 tol: u32,
1572 show_pacing: bool,
1573) {
1574 let pct = w.utilization_pct.clamp(0, 100) as u16;
1575 let reset_text = countdown::format(w.resets_at, now);
1576 let footnote = if show_pacing {
1577 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
1578 format!(
1579 "Resets in {} · {}% elapsed · {}",
1580 reset_text, p.elapsed_pct, p.point_label
1581 )
1582 } else {
1583 format!("Resets in {}", reset_text)
1584 };
1585 sections.push(Section::Spacer);
1586 sections.push_metric_in_window(
1587 Section::Metric {
1588 label: label.into(),
1589 pct,
1590 severity: severity_for(pct as i32),
1591 value_label: format!("{pct}%"),
1592 footnote,
1593 },
1594 w.resets_at,
1595 w.window_duration,
1596 );
1597}
1598
1599pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
1607 if sections.is_empty() {
1608 return;
1609 }
1610 let bubble = bubble_theme(theme);
1611 let pin_last =
1614 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
1615
1616 let body_end = if pin_last {
1617 sections.len() - 1
1618 } else {
1619 sections.len()
1620 };
1621 let mut constraints: Vec<Constraint> =
1622 sections[..body_end].iter().map(section_height).collect();
1623
1624 if pin_last {
1625 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
1627 } else {
1628 constraints.push(Constraint::Min(0));
1629 }
1630
1631 let chunks = Layout::default()
1632 .direction(ratatui::layout::Direction::Vertical)
1633 .constraints(constraints)
1634 .split(area);
1635
1636 for (i, s) in sections[..body_end].iter().enumerate() {
1637 render_section(f, chunks[i], theme, &bubble, s);
1638 }
1639 if pin_last {
1640 render_section(
1641 f,
1642 chunks[chunks.len() - 1],
1643 theme,
1644 &bubble,
1645 sections.last().unwrap(),
1646 );
1647 }
1648}
1649
1650fn section_height(s: &Section) -> Constraint {
1651 match s {
1652 Section::Title { .. } => Constraint::Length(2),
1653 Section::Metric { .. } => Constraint::Length(3),
1654 Section::Text { .. } => Constraint::Length(1),
1655 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
1656 Section::Spacer => Constraint::Length(1),
1657 }
1658}
1659
1660fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
1661 match s {
1662 Section::Title { left, right } => {
1663 let left_line = Line::from(Span::styled(
1666 format!(" {} {left}", bubble.symbols.selected),
1667 bubble.title,
1668 ));
1669 f.render_widget(Paragraph::new(left_line), area);
1670 if let Some(rt) = right {
1671 let right_line =
1672 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
1673 f.render_widget(Paragraph::new(right_line), area);
1674 }
1675 }
1676 Section::Metric {
1677 label,
1678 pct,
1679 severity,
1680 value_label,
1681 footnote,
1682 } => render_metric(
1683 f,
1684 area,
1685 theme,
1686 bubble,
1687 label,
1688 *pct,
1689 *severity,
1690 value_label,
1691 footnote,
1692 ),
1693 Section::Text { label, value } => {
1694 if label.is_empty() && value.contains("Loading") {
1695 render_loading(f, area, bubble);
1696 return;
1697 }
1698 if label == "Error" {
1699 let line = Line::from(vec![
1700 bubble.error(format!(" {} ", bubble.symbols.cross)),
1701 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
1702 ]);
1703 f.render_widget(Paragraph::new(line), area);
1704 return;
1705 }
1706 let mut spans = Vec::new();
1707 if !label.is_empty() {
1708 spans.push(Span::styled(
1709 format!(" {label} "),
1710 bubble.text.add_modifier(Modifier::BOLD),
1711 ));
1712 }
1713 spans.push(Span::styled(value.clone(), bubble.muted));
1714 f.render_widget(Paragraph::new(Line::from(spans)), area);
1715 }
1716 Section::Block { label, body } => render_block(f, area, bubble, label, body),
1717 Section::Spacer => {}
1718 }
1719}
1720
1721fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
1722 let frames = SpinnerFrames::DOTS;
1723 let frame_count = frames.frames().len().max(1);
1724 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
1725 let mut spinner = Spinner::new()
1726 .frames(frames)
1727 .label("Fetching usage data")
1728 .theme(*bubble);
1729 for _ in 0..(frame % frame_count) {
1730 spinner.tick();
1731 }
1732 f.render_widget(&spinner, area);
1733}
1734
1735#[allow(clippy::too_many_arguments)]
1736fn render_metric(
1737 f: &mut Frame,
1738 area: Rect,
1739 theme: &Theme,
1740 bubble: &BubbleTheme,
1741 label: &str,
1742 pct: u16,
1743 severity: PaceSeverity,
1744 value_label: &str,
1745 footnote: &str,
1746) {
1747 let bar_color = severity_color(theme, bubble, severity);
1748 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
1749
1750 let inner = Layout::default()
1751 .direction(ratatui::layout::Direction::Vertical)
1752 .constraints([
1753 Constraint::Length(1),
1754 Constraint::Length(1),
1755 Constraint::Length(1),
1756 ])
1757 .split(area);
1758
1759 let label_line = Line::from(Span::styled(
1761 format!(" {label}"),
1762 bubble.text.add_modifier(Modifier::BOLD),
1763 ));
1764 f.render_widget(Paragraph::new(label_line), inner[0]);
1765
1766 let row = inner[1];
1768 let value_w = crate::display::text_width(value_label) as u16 + 2;
1769 let gauge_area = Rect {
1770 x: row.x + 2,
1771 y: row.y,
1772 width: row.width.saturating_sub(value_w + 4),
1773 height: 1,
1774 };
1775 let value_area = Rect {
1776 x: gauge_area.x + gauge_area.width + 1,
1777 y: row.y,
1778 width: value_w,
1779 height: 1,
1780 };
1781 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
1782 let progress = Progress::from_percent(pct)
1783 .theme(progress_theme)
1784 .show_percentage(false);
1785 f.render_widget(&progress, gauge_area);
1786 let value = Paragraph::new(Line::from(Span::styled(
1787 value_label.to_string(),
1788 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
1789 )));
1790 f.render_widget(value, value_area);
1791
1792 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
1794 f.render_widget(Paragraph::new(foot), inner[2]);
1795}
1796
1797fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
1798 let mut lines = vec![Line::from(Span::styled(
1799 format!(" {label}"),
1800 bubble.text.add_modifier(Modifier::BOLD),
1801 ))];
1802 for b in body {
1803 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
1804 }
1805 f.render_widget(Paragraph::new(lines), area);
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810 use super::*;
1811 use crate::usage::{
1812 AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
1813 OpenAiSource, OpenRouterSnapshot, ResetCredit, ResetCredits, UsageWindow, ZaiSnapshot,
1814 };
1815 use chrono::TimeZone;
1816
1817 fn now() -> DateTime<Utc> {
1818 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
1819 }
1820
1821 fn ready(snapshot: VendorSnapshot) -> TabState {
1822 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
1823 snapshot,
1824 stale: false,
1825 last_error: None,
1826 fetched_at: Some(now() - chrono::Duration::seconds(15)),
1827 }))
1828 }
1829
1830 fn supergrok(period: crate::usage::SuperGrokPeriod) -> VendorSnapshot {
1831 VendorSnapshot::SuperGrok(crate::usage::SuperGrokSnapshot {
1832 plan: "SuperGrok".into(),
1833 account: "digest".into(),
1834 weekly_pct: 40,
1835 period,
1836 reset_at: Some(now() + chrono::Duration::days(2)),
1837 prepaid_balance: None,
1838 reset_credits: crate::usage::ResetCredits::default(),
1839 products: Vec::new(),
1840 })
1841 }
1842
1843 fn only_metric(sections: &[SectionProjection]) -> &SectionProjection {
1844 let mut metrics = sections
1845 .iter()
1846 .filter(|projection| matches!(projection.section, Section::Metric { .. }));
1847 let metric = metrics.next().expect("one metric row");
1848 assert!(metrics.next().is_none(), "expected exactly one metric row");
1849 metric
1850 }
1851
1852 #[test]
1856 fn window_length_is_reported_only_when_exact() {
1857 use crate::usage::SuperGrokPeriod;
1858
1859 let weekly =
1860 sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Weekly)), now(), 5);
1861 assert_eq!(only_metric(&weekly).window, Some(chrono::Duration::days(7)));
1862
1863 let monthly =
1864 sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Monthly)), now(), 5);
1865 assert_eq!(only_metric(&monthly).window, None);
1866
1867 let unknown =
1868 sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Unknown)), now(), 5);
1869 assert_eq!(only_metric(&unknown).window, None);
1870
1871 let kimi = sections_with_metadata_for(
1872 &ready(VendorSnapshot::Kimi(KimiSnapshot {
1873 plan: None,
1874 weekly_limit: 100,
1875 weekly_used: 10,
1876 weekly_remaining: 90,
1877 weekly_reset_at: Some(now() + chrono::Duration::days(3)),
1878 has_weekly: true,
1879 monthly_pct: None,
1880 monthly_reset_at: None,
1881 window_limit: 0,
1882 window_used: 0,
1883 window_remaining: 0,
1884 window_reset_at: None,
1885 })),
1886 now(),
1887 5,
1888 );
1889 assert_eq!(
1890 only_metric(&kimi).window,
1891 Some(crate::kimi::vendor::WEEKLY_WINDOW)
1892 );
1893 }
1894
1895 #[test]
1896 fn cursor_pools_carry_the_billing_cycle_window_only_when_it_is_exact() {
1897 let mut snap = cursor_snap();
1898 snap.cycle_start = Some(now() - chrono::Duration::days(22));
1899 let exact =
1900 sections_with_metadata_for(&ready(VendorSnapshot::Cursor(snap.clone())), now(), 5);
1901 let windows: Vec<_> = exact
1902 .iter()
1903 .filter(|p| matches!(p.section, Section::Metric { .. }))
1904 .map(|p| p.window)
1905 .collect();
1906 assert_eq!(
1907 windows,
1908 vec![
1909 Some(chrono::Duration::days(31)),
1910 Some(chrono::Duration::days(31))
1911 ]
1912 );
1913
1914 snap.cycle_start = None;
1919 let unknown = sections_with_metadata_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
1920 let pools: Vec<_> = unknown
1921 .iter()
1922 .filter(|p| matches!(p.section, Section::Metric { .. }))
1923 .collect();
1924 assert_eq!(pools.len(), 2);
1925 assert!(
1926 pools.iter().all(|p| p.window.is_none()),
1927 "an unstated billing cycle must not report a window length"
1928 );
1929 assert!(
1930 pools.iter().all(|p| p.reset_at.is_some()),
1931 "the reset time still travels with the row"
1932 );
1933 }
1934
1935 #[test]
1936 fn copilot_sections_carry_quota_reset_metadata() {
1937 let reset_at = now() + chrono::Duration::days(4);
1938 let snapshot = VendorSnapshot::Copilot(crate::copilot::types::Snapshot {
1939 plan: "Pro".into(),
1940 premium: Some(crate::copilot::types::Quota {
1941 percent_remaining: 25,
1942 entitlement: Some(300),
1943 remaining: Some(75),
1944 unlimited: false,
1945 }),
1946 chat: None,
1947 completions: None,
1948 reset_at: Some(reset_at),
1949 });
1950 let sections = sections_with_metadata_for(&ready(snapshot), now(), 5);
1951 assert!(matches!(
1952 §ions[0].section,
1953 Section::Title { left, .. } if left == "GitHub Copilot Pro"
1954 ));
1955 let metric = sections
1956 .iter()
1957 .find(|projection| matches!(&projection.section, Section::Metric { .. }))
1958 .expect("premium metric");
1959 assert_eq!(metric.reset_at, Some(reset_at));
1960 assert!(matches!(
1961 &metric.section,
1962 Section::Metric { label, pct, value_label, footnote, .. }
1963 if label == "Premium requests"
1964 && *pct == 75
1965 && value_label == "75%"
1966 && footnote == "225 of 300 used"
1967 ));
1968 }
1969
1970 #[test]
1971 fn anthropic_sections_include_all_three_windows_when_present() {
1972 let snap = AnthropicSnapshot {
1973 plan: "Max 20x".into(),
1974 session: UsageWindow {
1975 utilization_pct: 60,
1976 resets_at: Some(now() + chrono::Duration::hours(1)),
1977 window_duration: chrono::Duration::hours(5),
1978 },
1979 weekly: UsageWindow {
1980 utilization_pct: 30,
1981 resets_at: Some(now() + chrono::Duration::days(3)),
1982 window_duration: chrono::Duration::days(7),
1983 },
1984 sonnet: Some(UsageWindow {
1985 utilization_pct: 5,
1986 resets_at: Some(now() + chrono::Duration::hours(2)),
1987 window_duration: chrono::Duration::days(7),
1988 }),
1989 scoped: vec![],
1990 extra: Some(ExtraUsage {
1991 limit: Some(Cents(5000)),
1992 spent: Cents(250),
1993 currency: None,
1994 decimal_places: Some(2),
1995 }),
1996 };
1997 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1998 assert_eq!(sections.len(), 9);
2001 assert!(matches!(sections[0], Section::Title { .. }));
2002 if let Section::Title { right, .. } = §ions[0] {
2004 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
2005 } else {
2006 panic!("expected first section to be Title");
2007 }
2008 let metric_count = sections
2009 .iter()
2010 .filter(|s| matches!(s, Section::Metric { .. }))
2011 .count();
2012 assert_eq!(metric_count, 4);
2013 }
2014
2015 #[test]
2016 fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
2017 let snap = AnthropicSnapshot {
2020 plan: "Pro".into(),
2021 session: UsageWindow {
2022 utilization_pct: 10,
2023 resets_at: None,
2024 window_duration: chrono::Duration::hours(5),
2025 },
2026 weekly: UsageWindow {
2027 utilization_pct: 20,
2028 resets_at: None,
2029 window_duration: chrono::Duration::days(7),
2030 },
2031 sonnet: None,
2032 scoped: vec![],
2033 extra: Some(ExtraUsage {
2034 limit: None,
2035 spent: Cents(14157),
2036 currency: Some("BRL".into()),
2037 decimal_places: Some(2),
2038 }),
2039 };
2040 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
2041 let extra = sections
2042 .iter()
2043 .find_map(|s| match s {
2044 Section::Metric {
2045 label,
2046 pct,
2047 value_label,
2048 footnote,
2049 ..
2050 } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
2051 _ => None,
2052 })
2053 .expect("uncapped extra usage must still render a section");
2054 assert_eq!(extra.0, 0);
2055 assert_eq!(extra.1, "R$141.57");
2057 assert!(
2058 !extra.1.contains(" of "),
2059 "no denominator to show: {}",
2060 extra.1
2061 );
2062 assert_eq!(extra.2, "no monthly limit reported");
2063 }
2064
2065 #[test]
2066 fn anthropic_omits_sonnet_and_extra_when_absent() {
2067 let snap = AnthropicSnapshot {
2068 plan: "Pro".into(),
2069 session: UsageWindow {
2070 utilization_pct: 10,
2071 resets_at: None,
2072 window_duration: chrono::Duration::hours(5),
2073 },
2074 weekly: UsageWindow {
2075 utilization_pct: 5,
2076 resets_at: None,
2077 window_duration: chrono::Duration::days(7),
2078 },
2079 sonnet: None,
2080 scoped: vec![],
2081 extra: None,
2082 };
2083 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
2084 let metric_count = sections
2085 .iter()
2086 .filter(|s| matches!(s, Section::Metric { .. }))
2087 .count();
2088 assert_eq!(metric_count, 2);
2089 }
2090
2091 #[test]
2092 fn openrouter_always_has_balance_metric_and_period_block() {
2093 let snap = OpenRouterSnapshot {
2094 label: "OR".into(),
2095 total_credits: 100.0,
2096 total_usage: 25.0,
2097 usage_daily: 1.0,
2098 usage_weekly: 5.0,
2099 usage_monthly: 25.0,
2100 is_free_tier: false,
2101 limit: None,
2102 limit_remaining: None,
2103 };
2104 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
2105 assert!(matches!(sections[0], Section::Title { .. }));
2106 assert!(
2107 sections
2108 .iter()
2109 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
2110 );
2111 assert!(
2112 sections
2113 .iter()
2114 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
2115 );
2116 }
2117
2118 #[test]
2122 fn openrouter_debt_reaches_the_panel_row_red_and_signed() {
2123 let snap = OpenRouterSnapshot {
2124 label: "OR".into(),
2125 total_credits: 0.0,
2126 total_usage: 5.71,
2127 usage_daily: 1.0,
2128 usage_weekly: 5.0,
2129 usage_monthly: 5.71,
2130 is_free_tier: false,
2131 limit: None,
2132 limit_remaining: None,
2133 };
2134 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap.clone())), now(), 5);
2135 let metric = sections
2136 .iter()
2137 .find_map(|s| match s {
2138 Section::Metric {
2139 label,
2140 value_label,
2141 severity,
2142 footnote,
2143 ..
2144 } if label == "Credit balance" => Some((value_label, severity, footnote)),
2145 _ => None,
2146 })
2147 .expect("no credit balance metric");
2148 assert_eq!(metric.0, "-$5.71");
2149 assert_eq!(*metric.1, PaceSeverity::Critical);
2150 assert_eq!(metric.2, "$5.71 of $0.00 used (0%)");
2151
2152 let (_, cells) = compact_cells(&VendorSnapshot::Openrouter(snap));
2154 assert_eq!(cells[0].0, "-$5.71");
2155 }
2156
2157 #[test]
2161 fn zai_windows_are_paced_like_every_other_percentage_vendor() {
2162 let window = |pct: i32, hours: i64, span: chrono::Duration| crate::usage::UsageWindow {
2163 utilization_pct: pct,
2164 resets_at: Some(now() + chrono::Duration::hours(hours)),
2165 window_duration: span,
2166 };
2167 let snap = ZaiSnapshot {
2168 plan: "GLM Coding Pro".into(),
2169 session: Some(window(40, 2, chrono::Duration::hours(5))),
2170 weekly: Some(window(60, 48, chrono::Duration::days(7))),
2171 mcp: Some(window(10, 200, chrono::Duration::days(30))),
2172 };
2173
2174 let footnotes: Vec<String> = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5)
2175 .into_iter()
2176 .filter_map(|section| match section {
2177 Section::Metric { footnote, .. } => Some(footnote),
2178 _ => None,
2179 })
2180 .collect();
2181
2182 assert_eq!(footnotes.len(), 3, "{footnotes:?}");
2183 for footnote in &footnotes {
2184 assert!(footnote.contains("% elapsed"), "{footnote}");
2185 }
2186 assert_eq!(
2188 footnotes[0], "Resets in 2h 00m · 60% elapsed · 20pts under",
2189 "{footnotes:?}"
2190 );
2191 }
2192
2193 #[test]
2194 fn zai_no_windows_renders_message() {
2195 let snap = ZaiSnapshot {
2196 plan: "GLM".into(),
2197 session: None,
2198 weekly: None,
2199 mcp: None,
2200 };
2201 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
2202 assert!(sections.iter().any(|s| matches!(
2203 s,
2204 Section::Text { value, .. } if value.contains("no usage windows reported")
2205 )));
2206 }
2207
2208 #[test]
2209 fn openai_no_windows_renders_message() {
2210 let snap = OpenAiSnapshot {
2211 plan: "ChatGPT Plus".into(),
2212 session: None,
2213 weekly: None,
2214 code_review: None,
2215 additional_limits: Vec::new(),
2216 unavailable_models: Vec::new(),
2217 credits: None,
2218 reset_credits: ResetCredits::default(),
2219 source: OpenAiSource::CodexOauth,
2220 };
2221 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2222 assert!(sections.iter().any(|s| matches!(
2223 s,
2224 Section::Text { value, .. } if value.contains("no usage windows reported")
2225 )));
2226 }
2227
2228 #[test]
2229 fn loading_state_yields_loading_section() {
2230 let sections = sections_for(&TabState::Loading, now(), 5);
2231 assert!(sections.iter().any(|s| matches!(
2232 s,
2233 Section::Text { value, .. } if value.contains("Loading")
2234 )));
2235 }
2236
2237 #[test]
2238 fn error_state_includes_retry_hint() {
2239 let sections = sections_for(&TabState::error("token expired"), now(), 5);
2240 assert!(sections.iter().any(|s| matches!(
2241 s,
2242 Section::Text { value, .. } if value.contains("token expired")
2243 )));
2244 assert!(sections.iter().any(|s| matches!(
2245 s,
2246 Section::Text { value, .. } if value.contains("`r` to retry")
2247 )));
2248 }
2249
2250 #[test]
2251 fn error_state_keeps_oauth_plan_as_title() {
2252 let sections = sections_for(
2253 &TabState::error_with_plan("HTTP 401", Some("Claude Max 5x".into())),
2254 now(),
2255 5,
2256 );
2257 assert!(matches!(
2258 §ions[0],
2259 Section::Title { left, .. } if left == "Claude Max 5x"
2260 ));
2261 assert!(sections.iter().any(|s| matches!(
2262 s,
2263 Section::Text { value, .. } if value.contains("HTTP 401")
2264 )));
2265 assert!(!sections.iter().any(|s| matches!(s, Section::Metric { .. })));
2266 }
2267
2268 #[test]
2269 fn openai_with_credits_renders_block() {
2270 let snap = OpenAiSnapshot {
2271 plan: "ChatGPT Plus".into(),
2272 session: Some(UsageWindow {
2273 utilization_pct: 1,
2274 resets_at: None,
2275 window_duration: chrono::Duration::hours(5),
2276 }),
2277 weekly: Some(UsageWindow {
2278 utilization_pct: 0,
2279 resets_at: None,
2280 window_duration: chrono::Duration::days(7),
2281 }),
2282 code_review: None,
2283 additional_limits: Vec::new(),
2284 unavailable_models: Vec::new(),
2285 credits: Some(OpenAiCredits {
2286 balance: "$5.00".into(),
2287 has_credits: true,
2288 unlimited: false,
2289 approx_local_messages: Some((100, 200)),
2290 approx_cloud_messages: Some((30, 50)),
2291 }),
2292 reset_credits: ResetCredits::default(),
2293 source: OpenAiSource::CodexOauth,
2294 };
2295 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2296 assert!(
2297 sections
2298 .iter()
2299 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
2300 );
2301 }
2302
2303 #[test]
2304 fn openai_weekly_only_omits_session_section() {
2305 let snap = OpenAiSnapshot {
2306 plan: "ChatGPT Prolite".into(),
2307 session: None,
2308 weekly: Some(UsageWindow {
2309 utilization_pct: 66,
2310 resets_at: None,
2311 window_duration: chrono::Duration::days(7),
2312 }),
2313 code_review: None,
2314 additional_limits: Vec::new(),
2315 unavailable_models: Vec::new(),
2316 credits: None,
2317 reset_credits: ResetCredits::default(),
2318 source: OpenAiSource::CodexOauth,
2319 };
2320 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2321 assert!(sections.iter().any(|section| matches!(
2322 section,
2323 Section::Metric { label, .. } if label == "Codex weekly"
2324 )));
2325 assert!(!sections.iter().any(|section| matches!(
2326 section,
2327 Section::Metric { label, .. } if label == "Codex 5h"
2328 )));
2329 }
2330
2331 #[test]
2335 fn banked_resets_reach_the_panel_for_both_providers() {
2336 let now = now();
2337 let credits = ResetCredits {
2338 available: 2,
2339 credits: vec![
2340 ResetCredit {
2341 title: Some("Full reset (Weekly + 5 hr)".into()),
2342 expires_at: Some(now + chrono::Duration::days(13)),
2343 },
2344 ResetCredit {
2345 title: Some("Full reset (Weekly + 5 hr)".into()),
2346 expires_at: Some(now + chrono::Duration::days(13) + chrono::Duration::hours(6)),
2347 },
2348 ],
2349 };
2350 let codex = OpenAiSnapshot {
2351 plan: "ChatGPT Plus".into(),
2352 session: None,
2353 weekly: None,
2354 code_review: None,
2355 additional_limits: Vec::new(),
2356 unavailable_models: Vec::new(),
2357 credits: None,
2358 reset_credits: credits.clone(),
2359 source: OpenAiSource::CodexOauth,
2360 };
2361 let supergrok = crate::usage::SuperGrokSnapshot {
2362 plan: "SuperGrok".into(),
2363 account: "scope".into(),
2364 weekly_pct: 30,
2365 period: crate::usage::SuperGrokPeriod::Weekly,
2366 reset_at: Some(now + chrono::Duration::days(3)),
2367 prepaid_balance: None,
2368 reset_credits: credits,
2369 products: Vec::new(),
2370 };
2371
2372 for snapshot in [
2373 VendorSnapshot::Openai(codex),
2374 VendorSnapshot::SuperGrok(supergrok),
2375 ] {
2376 let sections = sections_for(&ready(snapshot), now, 5);
2377 let body = sections.iter().find_map(|section| match section {
2378 Section::Block { label, body } if label == "Reset credits" => Some(body.clone()),
2379 _ => None,
2380 });
2381 let body = body.expect("reset credits block");
2382 assert_eq!(body.len(), 2, "{body:?}");
2383 assert!(
2384 body.iter()
2385 .all(|line| line.contains("Full reset (Weekly + 5 hr)")),
2386 "{body:?}"
2387 );
2388 }
2389 }
2390
2391 #[test]
2394 fn a_provider_with_no_banked_resets_shows_no_reset_row() {
2395 let snap = OpenAiSnapshot {
2396 plan: "ChatGPT Plus".into(),
2397 session: None,
2398 weekly: None,
2399 code_review: None,
2400 additional_limits: Vec::new(),
2401 unavailable_models: Vec::new(),
2402 credits: None,
2403 reset_credits: ResetCredits::default(),
2404 source: OpenAiSource::CodexOauth,
2405 };
2406 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2407 assert!(!sections.iter().any(|section| matches!(
2408 section,
2409 Section::Block { label, .. } if label == "Reset credits"
2410 )));
2411 }
2412
2413 #[test]
2414 fn supergrok_does_not_repeat_the_window_reset_as_its_own_section() {
2415 let now = now();
2416 let snap = crate::usage::SuperGrokSnapshot {
2417 plan: "SuperGrok".into(),
2418 account: "scope".into(),
2419 weekly_pct: 0,
2420 period: crate::usage::SuperGrokPeriod::Weekly,
2421 reset_at: Some(now + chrono::Duration::days(6)),
2422 prepaid_balance: Some(0.0),
2423 reset_credits: ResetCredits::default(),
2424 products: Vec::new(),
2425 };
2426 let sections = sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5);
2427 assert!(!sections.iter().any(|section| matches!(
2428 section,
2429 Section::Text { label, .. } if label == "Resets"
2430 )));
2431 assert!(!sections.iter().any(|section| matches!(
2434 section,
2435 Section::Text { label, .. } if label == "Prepaid API"
2436 )));
2437 }
2438
2439 #[test]
2443 fn supergrok_hides_a_zero_prepaid_balance_but_keeps_a_real_one() {
2444 let now = now();
2445 let base = |prepaid: Option<f64>| crate::usage::SuperGrokSnapshot {
2446 plan: "SuperGrok".into(),
2447 account: "scope".into(),
2448 weekly_pct: 40,
2449 period: crate::usage::SuperGrokPeriod::Weekly,
2450 reset_at: Some(now + chrono::Duration::days(6)),
2451 prepaid_balance: prepaid,
2452 reset_credits: ResetCredits::default(),
2453 products: Vec::new(),
2454 };
2455 let labels = |snap| {
2456 sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5)
2457 .into_iter()
2458 .filter_map(|section| match section {
2459 Section::Text { label, value, .. } => Some((label, value)),
2460 _ => None,
2461 })
2462 .collect::<Vec<_>>()
2463 };
2464 assert!(
2465 !labels(base(Some(0.0)))
2466 .iter()
2467 .any(|(label, _)| label == "Prepaid API")
2468 );
2469 assert_eq!(
2470 labels(base(Some(4.22))).last(),
2471 Some(&("Prepaid API".to_string(), "$4.22".to_string()))
2472 );
2473 }
2474
2475 #[test]
2476 fn supergrok_lists_product_slices_beside_the_overall_meter() {
2477 let now = now();
2478 let snap = crate::usage::SuperGrokSnapshot {
2479 plan: "SuperGrok".into(),
2480 account: "scope".into(),
2481 weekly_pct: 90,
2482 period: crate::usage::SuperGrokPeriod::Weekly,
2483 reset_at: Some(now + chrono::Duration::days(3)),
2484 prepaid_balance: None,
2485 reset_credits: ResetCredits::default(),
2486 products: vec![
2487 crate::usage::SuperGrokProduct {
2488 label: "Grok Build".into(),
2489 percent: 87,
2490 },
2491 crate::usage::SuperGrokProduct {
2492 label: "Grok Chat".into(),
2493 percent: 3,
2494 },
2495 ],
2496 };
2497 let labels: Vec<_> = sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5)
2498 .into_iter()
2499 .filter_map(|section| match section {
2500 Section::Metric { label, pct, .. } => Some((label, pct)),
2501 _ => None,
2502 })
2503 .collect();
2504 assert_eq!(
2505 labels,
2506 vec![
2507 ("Weekly usage".into(), 90),
2508 ("Grok Build".into(), 87),
2509 ("Grok Chat".into(), 3),
2510 ]
2511 );
2512 }
2513
2514 #[test]
2518 fn supergrok_product_slices_carry_the_breakdown_group() {
2519 let now = now();
2520 let snap = crate::usage::SuperGrokSnapshot {
2521 plan: "SuperGrok".into(),
2522 account: "scope".into(),
2523 weekly_pct: 90,
2524 period: crate::usage::SuperGrokPeriod::Weekly,
2525 reset_at: Some(now + chrono::Duration::days(3)),
2526 prepaid_balance: None,
2527 reset_credits: ResetCredits::default(),
2528 products: vec![crate::usage::SuperGrokProduct {
2529 label: "Grok Build".into(),
2530 percent: 87,
2531 }],
2532 };
2533 let projected = sections_with_metadata_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5);
2534 let mut metrics = projected
2535 .iter()
2536 .filter(|p| matches!(p.section, Section::Metric { .. }));
2537 let overall = metrics.next().expect("overall usage metric");
2538 let build = metrics.next().expect("one product slice metric");
2539 assert!(metrics.next().is_none(), "expected exactly two metric rows");
2540 assert_eq!(overall.group, None);
2541 assert!(overall.reset_at.is_some());
2542 assert_eq!(build.group, Some("Breakdown"));
2543 assert_eq!(build.reset_at, None);
2544 assert_eq!(build.window, None);
2545 }
2546
2547 #[test]
2548 fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
2549 let now = now();
2550 let snap = KimiSnapshot {
2551 plan: Some("LEVEL_INTERMEDIATE".into()),
2552 weekly_limit: 100,
2553 weekly_used: 26,
2554 weekly_remaining: 74,
2555 weekly_reset_at: Some(now + chrono::Duration::days(4)),
2556 has_weekly: true,
2557 monthly_pct: None,
2558 monthly_reset_at: None,
2559 window_limit: 100,
2560 window_used: 15,
2561 window_remaining: 85,
2562 window_reset_at: Some(now + chrono::Duration::hours(2)),
2563 };
2564 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
2565 let metrics: Vec<_> = sections
2566 .iter()
2567 .filter(|s| matches!(s, Section::Metric { .. }))
2568 .collect();
2569 assert_eq!(metrics.len(), 2);
2570 assert!(sections.iter().any(|s| matches!(
2571 s,
2572 Section::Metric { label, .. } if label == "Weekly quota"
2573 )));
2574 assert!(sections.iter().any(|s| matches!(
2575 s,
2576 Section::Metric { label, .. } if label == "Rolling window (5h)"
2577 )));
2578
2579 let find_footnote = |label: &str| -> (String, String) {
2580 sections
2581 .iter()
2582 .find_map(|s| match s {
2583 Section::Metric {
2584 label: l,
2585 value_label,
2586 footnote,
2587 ..
2588 } if l == label => Some((value_label.clone(), footnote.clone())),
2589 _ => None,
2590 })
2591 .unwrap_or_else(|| panic!("missing metric {label}"))
2592 };
2593
2594 let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
2598 assert_eq!(weekly_value, "26%");
2599 assert_eq!(weekly_footnote, "Resets in 4d 0h");
2600
2601 let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
2602 assert_eq!(window_value, "15%");
2603 assert_eq!(window_footnote, "Resets in 2h 00m");
2604 }
2605
2606 #[test]
2613 fn kimi_leads_with_the_rolling_window_like_every_other_two_window_vendor() {
2614 let now = now();
2615 let snap = KimiSnapshot {
2616 plan: Some("LEVEL_INTERMEDIATE".into()),
2617 weekly_limit: 100,
2618 weekly_used: 26,
2619 weekly_remaining: 74,
2620 weekly_reset_at: Some(now + chrono::Duration::days(4)),
2621 has_weekly: true,
2622 monthly_pct: None,
2623 monthly_reset_at: None,
2624 window_limit: 100,
2625 window_used: 15,
2626 window_remaining: 85,
2627 window_reset_at: Some(now + chrono::Duration::hours(2)),
2628 };
2629 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap.clone())), now, 5);
2630 let labels: Vec<&str> = sections
2631 .iter()
2632 .filter_map(|s| match s {
2633 Section::Metric { label, .. } => Some(label.as_str()),
2634 _ => None,
2635 })
2636 .collect();
2637 assert_eq!(labels, ["Rolling window (5h)", "Weekly quota"]);
2638
2639 let (_, cells) = compact_cells(&VendorSnapshot::Kimi(snap));
2640 let texts: Vec<&str> = cells.iter().map(|(text, _)| text.as_str()).collect();
2641 assert_eq!(texts, ["5h 15%", "wk 26%"]);
2642 }
2643
2644 #[test]
2645 fn kimi_sections_omit_window_when_limit_zero() {
2646 let snap = KimiSnapshot {
2647 plan: None,
2648 weekly_limit: 100,
2649 weekly_used: 10,
2650 weekly_remaining: 90,
2651 weekly_reset_at: None,
2652 has_weekly: true,
2653 monthly_pct: None,
2654 monthly_reset_at: None,
2655 window_limit: 0,
2656 window_used: 0,
2657 window_remaining: 0,
2658 window_reset_at: None,
2659 };
2660 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
2661 let metric_count = sections
2662 .iter()
2663 .filter(|s| matches!(s, Section::Metric { .. }))
2664 .count();
2665 assert_eq!(metric_count, 1);
2666 }
2667
2668 #[test]
2673 fn kimi_sections_on_the_monthly_shape_drop_weekly_and_add_monthly() {
2674 let now = now();
2675 let snap = KimiSnapshot {
2676 plan: Some("Allegretto".into()),
2677 weekly_limit: 0,
2678 weekly_used: 0,
2679 weekly_remaining: 0,
2680 weekly_reset_at: None,
2681 has_weekly: false,
2682 monthly_pct: Some(42),
2683 monthly_reset_at: Some(now + chrono::Duration::days(30)),
2684 window_limit: 100,
2685 window_used: 15,
2686 window_remaining: 85,
2687 window_reset_at: Some(now + chrono::Duration::hours(2)),
2688 };
2689 let projected =
2690 sections_with_metadata_for(&ready(VendorSnapshot::Kimi(snap.clone())), now, 5);
2691 let metrics: Vec<_> = projected
2692 .iter()
2693 .filter(|p| matches!(p.section, Section::Metric { .. }))
2694 .collect();
2695 assert_eq!(metrics.len(), 2);
2696 let monthly = metrics
2697 .iter()
2698 .find(|p| matches!(&p.section, Section::Metric { label, .. } if label == "Monthly"))
2699 .expect("a Monthly metric");
2700 assert!(matches!(
2701 &monthly.section,
2702 Section::Metric { value_label, footnote, .. }
2703 if value_label == "42%" && footnote == "Resets in 30d 0h"
2704 ));
2705 assert_eq!(monthly.reset_at, Some(now + chrono::Duration::days(30)));
2706 assert_eq!(monthly.window, None, "no fixed window length, no pacing");
2707 assert!(!metrics.iter().any(
2708 |p| matches!(&p.section, Section::Metric { label, .. } if label == "Weekly quota")
2709 ));
2710
2711 let (_, cells) = compact_cells(&VendorSnapshot::Kimi(snap));
2713 let texts: Vec<&str> = cells.iter().map(|(text, _)| text.as_str()).collect();
2714 assert_eq!(texts, ["5h 15%", "mo 42%"]);
2715 }
2716
2717 fn cursor_snap() -> crate::usage::CursorSnapshot {
2718 crate::usage::CursorSnapshot {
2719 plan: "Ultra".into(),
2720 auto_pct: 98,
2721 api_pct: 100,
2722 total_pct: 99,
2723 unlimited: false,
2724 on_demand_enabled: false,
2725 on_demand_used_cents: None,
2726 on_demand_limit_cents: None,
2727 reset_at: Some(now() + chrono::Duration::days(9)),
2728 cycle_start: None,
2729 }
2730 }
2731
2732 #[test]
2733 fn compact_cells_flatten_key_metrics_for_the_overview() {
2734 let (plan, cells) = compact_cells(&VendorSnapshot::Cursor(cursor_snap()));
2736 assert_eq!(plan, "Ultra");
2737 assert_eq!(cells[0].0, "auto 98%");
2738 assert_eq!(cells[1].0, "premium 100%");
2739 assert_eq!(cells[1].1, PaceSeverity::Critical); let (plan, cells) = compact_cells(&VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
2743 label: "Kilo".into(),
2744 balance: 8.42,
2745 }));
2746 assert!(plan.is_empty());
2747 assert_eq!(cells, vec![("$8.42".to_string(), PaceSeverity::Low)]);
2748 }
2749
2750 #[test]
2751 fn terminal_controls_are_removed_from_detail_and_overview_fields() {
2752 let error = TabState::error("bad\x1b]52;c;Y2FuYXJ5\x07 value");
2753 let sections = sections_for(&error, now(), 5);
2754 assert!(matches!(
2755 §ions[1],
2756 Section::Text { value, .. }
2757 if value == "bad]52;c;Y2FuYXJ5 value"
2758 && !value.chars().any(|ch| ch.is_control())
2759 ));
2760
2761 let mut snapshot = cursor_snap();
2762 snapshot.plan = "Ultra\x1b[2J\x07".into();
2763 let (plan, _) = compact_cells(&VendorSnapshot::Cursor(snapshot));
2764 assert_eq!(plan, "Ultra[2J");
2765 assert!(!plan.chars().any(char::is_control));
2766 }
2767
2768 #[test]
2769 fn headline_pct_is_the_worst_window_or_combined_total() {
2770 assert_eq!(
2772 headline_pct(&VendorSnapshot::Cursor(cursor_snap())),
2773 Some(99)
2774 );
2775
2776 let kilo = VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
2778 label: "Kilo".into(),
2779 balance: 8.42,
2780 });
2781 assert_eq!(headline_pct(&kilo), None);
2782 }
2783
2784 #[test]
2785 fn cursor_sections_show_both_pools_and_reset() {
2786 let mut snapshot = cursor_snap();
2787 snapshot.on_demand_enabled = true;
2788 snapshot.on_demand_used_cents = Some(1785);
2789 snapshot.on_demand_limit_cents = Some(35000);
2790 let sections = sections_for(&ready(VendorSnapshot::Cursor(snapshot)), now(), 5);
2791 let metrics: Vec<_> = sections
2792 .iter()
2793 .filter_map(|s| match s {
2794 Section::Metric {
2795 label, value_label, ..
2796 } => Some((label.clone(), value_label.clone())),
2797 _ => None,
2798 })
2799 .collect();
2800 assert_eq!(metrics.len(), 2, "two pools");
2801 assert!(
2802 metrics
2803 .iter()
2804 .any(|(l, v)| l == "Cursor Models" && v == "98%")
2805 );
2806 assert!(
2807 metrics
2808 .iter()
2809 .any(|(l, v)| l == "Other Models" && v == "100%")
2810 );
2811 assert!(sections.iter().any(|section| matches!(
2812 section,
2813 Section::Text { label, value }
2814 if label == "On-Demand" && value == "$17.85 / $350.00"
2815 )));
2816 assert!(sections.iter().any(|s| matches!(
2817 s,
2818 Section::Text { label, value } if label == "Resets" && value.contains("9d")
2819 )));
2820 }
2821
2822 #[test]
2823 fn cursor_unlimited_plan_shows_no_pool_bars() {
2824 let mut snap = cursor_snap();
2825 snap.unlimited = true;
2826 let sections = sections_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
2827 let metric_count = sections
2828 .iter()
2829 .filter(|s| matches!(s, Section::Metric { .. }))
2830 .count();
2831 assert_eq!(metric_count, 0);
2832 assert!(sections.iter().any(|s| matches!(
2833 s,
2834 Section::Text { value, .. } if value.contains("Unlimited")
2835 )));
2836 }
2837
2838 fn kiro_snap() -> crate::usage::KiroSnapshot {
2839 crate::usage::KiroSnapshot {
2840 plan: "KIRO POWER".into(),
2841 used: 9943.38,
2842 limit: 10000.0,
2843 reset_at: Some(now() + chrono::Duration::days(1)),
2844 }
2845 }
2846
2847 #[test]
2848 fn kiro_compact_cell_shows_the_credit_percentage() {
2849 let (plan, cells) = compact_cells(&VendorSnapshot::Kiro(kiro_snap()));
2850 assert_eq!(plan, "KIRO POWER");
2851 assert_eq!(
2852 cells,
2853 vec![("credits 99%".to_string(), PaceSeverity::Critical)]
2854 );
2855 }
2856
2857 #[test]
2858 fn kiro_headline_pct_is_the_credit_percentage() {
2859 assert_eq!(headline_pct(&VendorSnapshot::Kiro(kiro_snap())), Some(99));
2860 }
2861
2862 #[test]
2863 fn kiro_sections_show_the_credit_metric_and_reset() {
2864 let sections = sections_for(&ready(VendorSnapshot::Kiro(kiro_snap())), now(), 5);
2865 let metrics: Vec<_> = sections
2866 .iter()
2867 .filter_map(|s| match s {
2868 Section::Metric {
2869 label, value_label, ..
2870 } => Some((label.clone(), value_label.clone())),
2871 _ => None,
2872 })
2873 .collect();
2874 assert_eq!(metrics, vec![("Credits".to_string(), "99%".to_string())]);
2875 assert!(sections.iter().any(|s| matches!(
2876 s,
2877 Section::Text { label, value } if label == "Resets" && value.contains("1d")
2878 )));
2879 }
2880
2881 fn grokbot_snap() -> crate::usage::GrokbotSnapshot {
2882 crate::usage::GrokbotSnapshot {
2883 plan: "Grok Bot Plan".into(),
2884 has_included_allowance: true,
2885 weekly_pct: 42,
2886 has_available_usage: true,
2887 on_demand_enabled: false,
2888 period_start: Some(now() - chrono::Duration::days(3)),
2889 reset_at: Some(now() + chrono::Duration::days(4)),
2890 window: Some(chrono::Duration::days(7)),
2891 }
2892 }
2893
2894 #[test]
2895 fn grokbot_sections_show_one_weekly_meter_with_the_derived_window() {
2896 let sections =
2897 sections_with_metadata_for(&ready(VendorSnapshot::Grokbot(grokbot_snap())), now(), 5);
2898 let metric = only_metric(§ions);
2899 let Section::Metric {
2900 label,
2901 value_label,
2902 footnote,
2903 ..
2904 } = &metric.section
2905 else {
2906 unreachable!()
2907 };
2908 assert_eq!(label, "Weekly");
2909 assert_eq!(value_label, "42%");
2910 assert!(footnote.contains("Resets in"), "{footnote}");
2911 assert_eq!(metric.window, Some(chrono::Duration::days(7)));
2913 assert_eq!(metric.reset_at, grokbot_snap().reset_at);
2914 }
2915
2916 #[test]
2917 fn grokbot_no_allowance_state_is_a_text_row_not_a_meter() {
2918 let snap = crate::usage::GrokbotSnapshot {
2919 has_included_allowance: false,
2920 weekly_pct: 0,
2921 ..grokbot_snap()
2922 };
2923 let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap.clone())), now(), 5);
2924 assert!(
2925 sections
2926 .iter()
2927 .all(|s| !matches!(s, Section::Metric { .. })),
2928 "no meter without an included allowance"
2929 );
2930 assert!(sections.iter().any(|s| matches!(
2931 s,
2932 Section::Text { value, .. } if value.contains("no included allowance")
2933 )));
2934 assert_eq!(headline_pct(&VendorSnapshot::Grokbot(snap)), None);
2935 let (_, cells) = compact_cells(&VendorSnapshot::Grokbot(grokbot_snap()));
2936 assert_eq!(cells.len(), 1);
2937 assert!(cells[0].0.contains("42%"), "{cells:?}");
2938 }
2939
2940 #[test]
2941 fn grokbot_on_demand_footnote_is_a_text_row_when_it_applies() {
2942 let mut snap = grokbot_snap();
2943 snap.weekly_pct = 100;
2944 snap.has_available_usage = true;
2945 snap.on_demand_enabled = true;
2946 let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2947 assert!(sections.iter().any(|s| matches!(
2948 s,
2949 Section::Text { label, value } if label == "On-demand" && value.contains("on-demand")
2950 )));
2951
2952 let mut snap = grokbot_snap();
2953 snap.weekly_pct = 100;
2954 snap.has_available_usage = true;
2955 snap.on_demand_enabled = false;
2956 let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2957 assert!(
2958 sections
2959 .iter()
2960 .all(|s| !matches!(s, Section::Text { label, .. } if label == "On-demand")),
2961 "on-demand off: no footnote"
2962 );
2963 }
2964
2965 #[test]
2966 fn grokbot_without_a_period_start_reports_no_window() {
2967 let mut snap = grokbot_snap();
2968 snap.period_start = None;
2969 snap.window = None;
2970 let sections = sections_with_metadata_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2971 assert_eq!(only_metric(§ions).window, None);
2972 }
2973
2974 #[test]
2975 fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
2976 let snap = KimiSnapshot {
2977 plan: None,
2978 weekly_limit: 100,
2979 weekly_used: 10,
2980 weekly_remaining: 90,
2981 weekly_reset_at: None,
2982 has_weekly: true,
2983 monthly_pct: None,
2984 monthly_reset_at: None,
2985 window_limit: 0,
2986 window_used: 0,
2987 window_remaining: 0,
2988 window_reset_at: None,
2989 };
2990 let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
2991 let TabState::Ready(tab) = &mut schema else {
2992 unreachable!()
2993 };
2994 tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
2995 let schema_sections = sections_for(&schema, now(), 5);
2996 assert!(schema_sections.iter().any(|section| matches!(
2997 section,
2998 Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
2999 )));
3000
3001 let mut generic = ready(VendorSnapshot::Kimi(snap));
3002 let TabState::Ready(tab) = &mut generic else {
3003 unreachable!()
3004 };
3005 tab.last_error = Some((0, "cache lock unavailable".into()));
3006 let generic_sections = sections_for(&generic, now(), 5);
3007 assert!(generic_sections.iter().any(|section| matches!(
3008 section,
3009 Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
3010 )));
3011 assert!(!generic_sections.iter().any(|section| matches!(
3012 section,
3013 Section::Text { label, .. } if label.starts_with("HTTP")
3014 )));
3015
3016 let http = warning_label(
3017 &VendorSnapshot::Kimi(KimiSnapshot {
3018 plan: None,
3019 weekly_limit: 0,
3020 weekly_used: 0,
3021 weekly_remaining: 0,
3022 weekly_reset_at: None,
3023 has_weekly: true,
3024 monthly_pct: None,
3025 monthly_reset_at: None,
3026 window_limit: 0,
3027 window_used: 0,
3028 window_remaining: 0,
3029 window_reset_at: None,
3030 }),
3031 &Some((503, "service unavailable".into())),
3032 );
3033 assert_eq!(
3034 http,
3035 Some(("HTTP 503".into(), "service unavailable".into()))
3036 );
3037 }
3038
3039 fn antigravity_snap(source: crate::usage::AntigravitySource) -> VendorSnapshot {
3040 VendorSnapshot::Antigravity(crate::usage::AntigravitySnapshot {
3041 plan: "Pro".into(),
3042 account: "acct:test".into(),
3043 source,
3044 session: Some(UsageWindow {
3045 utilization_pct: 43,
3046 resets_at: Some(now() + chrono::Duration::hours(2)),
3047 window_duration: chrono::Duration::hours(5),
3048 }),
3049 weekly: None,
3050 third_party_session: None,
3051 third_party_weekly: None,
3052 })
3053 }
3054
3055 #[test]
3058 fn antigravity_names_the_remote_source_and_only_that() {
3059 use crate::usage::AntigravitySource;
3060
3061 let remote = sections_for(
3062 &ready(antigravity_snap(AntigravitySource::Remote)),
3063 now(),
3064 5,
3065 );
3066 let n = remote.len();
3067 assert!(matches!(remote[n - 2], Section::Spacer));
3068 assert!(matches!(
3069 &remote[n - 1],
3070 Section::Text { label, value }
3071 if label == "Source" && value == "Google API"
3072 ));
3073
3074 let local = sections_for(&ready(antigravity_snap(AntigravitySource::Local)), now(), 5);
3075 assert!(
3076 !local
3077 .iter()
3078 .any(|s| matches!(s, Section::Text { label, .. } if label == "Source"))
3079 );
3080 }
3081
3082 #[test]
3086 fn custom_sections_follow_declaration_order_and_carry_reset_metadata() {
3087 use crate::custom::types::{CustomMetric, CustomSnapshot, CustomText};
3088
3089 let session_reset = now() + chrono::Duration::hours(3);
3090 let monthly_reset = now() + chrono::Duration::days(12);
3091 let snapshot = VendorSnapshot::Custom(CustomSnapshot {
3092 plan: Some("Team".into()),
3093 metrics: vec![
3094 CustomMetric {
3095 label: "Session".into(),
3096 pct: 40,
3097 footnote: "40 of 100".into(),
3098 resets_at: Some(session_reset),
3099 window_secs: Some(18_000),
3100 },
3101 CustomMetric {
3102 label: "Monthly".into(),
3103 pct: 120,
3104 footnote: String::new(),
3105 resets_at: Some(monthly_reset),
3106 window_secs: None,
3107 },
3108 ],
3109 texts: vec![CustomText {
3110 label: "Region".into(),
3111 value: "eu".into(),
3112 }],
3113 });
3114
3115 let sections = sections_with_metadata_for(&ready(snapshot.clone()), now(), 5);
3116 assert!(matches!(
3117 §ions[0].section,
3118 Section::Title { left, right } if left == "Team" && right.is_some()
3119 ));
3120 assert!(matches!(sections[1].section, Section::Spacer));
3121 assert!(matches!(
3122 §ions[2].section,
3123 Section::Metric { label, pct, value_label, footnote, .. }
3124 if label == "Session" && *pct == 40 && value_label == "40%" && footnote == "40 of 100"
3125 ));
3126 assert_eq!(sections[2].reset_at, Some(session_reset));
3127 assert_eq!(sections[2].window, Some(chrono::Duration::hours(5)));
3128 assert!(matches!(
3130 §ions[3].section,
3131 Section::Metric { label, pct, value_label, .. }
3132 if label == "Monthly" && *pct == 100 && value_label == "100%"
3133 ));
3134 assert_eq!(sections[3].reset_at, Some(monthly_reset));
3135 assert_eq!(sections[3].window, None);
3136 assert!(matches!(sections[4].section, Section::Spacer));
3137 assert!(matches!(
3138 §ions[5].section,
3139 Section::Text { label, value } if label == "Region" && value == "eu"
3140 ));
3141 assert_eq!(sections.len(), 6);
3142
3143 let (plan, cells) = compact_cells(&snapshot);
3144 assert_eq!(plan, "Team");
3145 assert_eq!(cells[0].0, "Session 40%");
3146 assert_eq!(cells[1].0, "Monthly 120%");
3147 assert_eq!(headline_pct(&snapshot), Some(40));
3148
3149 let bare = VendorSnapshot::Custom(CustomSnapshot {
3151 plan: None,
3152 metrics: vec![],
3153 texts: vec![],
3154 });
3155 let sections = sections_with_metadata_for(&ready(bare.clone()), now(), 5);
3156 assert!(matches!(§ions[0].section, Section::Title { left, .. } if left.is_empty()));
3157 assert_eq!(sections.len(), 2);
3158 assert_eq!(compact_cells(&bare), (String::new(), vec![]));
3159 assert_eq!(headline_pct(&bare), None);
3160 }
3161}