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