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, 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}
62
63struct SectionBuilder(Vec<SectionProjection>);
64
65impl SectionBuilder {
66 fn new(sections: Vec<Section>) -> Self {
67 Self(
68 sections
69 .into_iter()
70 .map(|section| {
71 assert!(
72 !matches!(section, Section::Metric { .. }),
73 "metric sections must declare reset metadata with push_metric"
74 );
75 SectionProjection {
76 section,
77 reset_at: None,
78 }
79 })
80 .collect(),
81 )
82 }
83
84 fn push(&mut self, section: Section) {
85 assert!(
86 !matches!(section, Section::Metric { .. }),
87 "metric sections must declare reset metadata with push_metric"
88 );
89 self.0.push(SectionProjection {
90 section,
91 reset_at: None,
92 });
93 }
94
95 fn push_metric(&mut self, section: Section, reset_at: Option<DateTime<Utc>>) {
96 assert!(matches!(section, Section::Metric { .. }));
97 self.0.push(SectionProjection { section, reset_at });
98 }
99}
100
101pub fn compact_cells(snapshot: &VendorSnapshot) -> (String, Vec<(String, PaceSeverity)>) {
107 let pct = |label: &str, p: i32| (format!("{label} {p}%"), severity_for(p));
108 let usd_cell = |v: f64| (usd(v), PaceSeverity::Low);
111 let money_cell = |v: f64, c: &str| (money(v, c), PaceSeverity::Low);
112 let (plan, mut cells) = match snapshot {
113 VendorSnapshot::Anthropic(s) => {
114 let mut cells = vec![
115 pct("S", s.session.utilization_pct),
116 pct("W", s.weekly.utilization_pct),
117 ];
118 if let Some(sonnet) = &s.sonnet {
119 cells.push(pct("Son", sonnet.utilization_pct));
120 }
121 (s.plan.clone(), cells)
122 }
123 VendorSnapshot::AnthropicApi(s) => {
124 let cell = match s.pct() {
125 Some(p) => pct("spend", p),
126 None => (format!("{}/mo", usd(s.spent)), PaceSeverity::Low),
127 };
128 (String::new(), vec![cell])
129 }
130 VendorSnapshot::Openai(s) => {
131 let mut cells = Vec::new();
132 if let Some(w) = &s.session {
133 cells.push(pct("5h", w.utilization_pct));
134 }
135 if let Some(w) = &s.weekly {
136 cells.push(pct("7d", w.utilization_pct));
137 }
138 if cells.is_empty() {
139 cells.push(("—".into(), PaceSeverity::Low));
140 }
141 (s.plan.clone(), cells)
142 }
143 VendorSnapshot::Zai(s) => {
144 let mut cells = Vec::new();
145 if let Some(w) = &s.session {
146 cells.push(pct("S", w.utilization_pct));
147 }
148 if let Some(w) = &s.weekly {
149 cells.push(pct("W", w.utilization_pct));
150 }
151 if cells.is_empty() {
152 cells.push(("—".into(), PaceSeverity::Low));
153 }
154 (s.plan.clone(), cells)
155 }
156 VendorSnapshot::Openrouter(s) => (String::new(), vec![usd_cell(s.balance())]),
157 VendorSnapshot::Deepseek(s) => (String::new(), vec![money_cell(s.balance, &s.currency)]),
158 VendorSnapshot::Kimi(s) => (
159 s.plan.clone().unwrap_or_default(),
160 vec![pct("wk", s.weekly_pct()), pct("5h", s.window_pct())],
161 ),
162 VendorSnapshot::Kilo(s) => (String::new(), vec![usd_cell(s.balance)]),
163 VendorSnapshot::Novita(s) => (String::new(), vec![usd_cell(s.available)]),
164 VendorSnapshot::Moonshot(s) => (String::new(), vec![money_cell(s.available, &s.currency)]),
165 VendorSnapshot::Grok(s) => (String::new(), vec![usd_cell(s.balance)]),
166 VendorSnapshot::SuperGrok(s) => (s.plan.clone(), vec![pct(s.period.short(), s.weekly_pct)]),
167 VendorSnapshot::Antigravity(s) => (
168 s.plan.clone(),
169 vec![
170 pct("S", s.session.utilization_pct),
171 pct("W", s.weekly.utilization_pct),
172 ],
173 ),
174 VendorSnapshot::Cursor(s) => (
175 s.plan.clone(),
176 vec![pct("auto", s.auto_pct), pct("premium", s.api_pct)],
177 ),
178 VendorSnapshot::Minimax(s) => (
179 s.plan.clone(),
180 vec![
181 pct("S", s.session.utilization_pct),
182 pct("W", s.weekly.utilization_pct),
183 ],
184 ),
185 VendorSnapshot::Kiro(s) => (s.plan.clone(), vec![pct("credits", s.pct())]),
186 VendorSnapshot::NousResearch(s) => {
187 let cell = s
188 .usage_percent()
189 .map(|value| pct("usage", value.round().clamp(0.0, 100.0) as i32))
190 .unwrap_or_else(|| ("—".into(), PaceSeverity::Low));
191 (s.plan.clone().unwrap_or_default(), vec![cell])
192 }
193 VendorSnapshot::OpenCodeGo(s) => {
194 let cells = [
195 ("rolling", s.rolling.as_ref()),
196 ("weekly", s.weekly.as_ref()),
197 ("monthly", s.monthly.as_ref()),
198 ]
199 .into_iter()
200 .filter_map(|(label, window)| {
201 window.map(|window| pct(label, window.percent.round().clamp(0.0, 100.0) as i32))
202 })
203 .collect();
204 ("OpenCode Go".into(), cells)
205 }
206 };
207
208 for (text, _) in &mut cells {
209 *text = crate::display::sanitize_untrusted_field(text);
210 }
211 (crate::display::sanitize_untrusted_field(&plan), cells)
212}
213
214pub fn headline_pct(snapshot: &VendorSnapshot) -> Option<i32> {
219 match snapshot {
220 VendorSnapshot::Anthropic(s) => [
221 Some(s.session.utilization_pct),
222 Some(s.weekly.utilization_pct),
223 s.sonnet.as_ref().map(|w| w.utilization_pct),
224 ]
225 .into_iter()
226 .flatten()
227 .max(),
228 VendorSnapshot::AnthropicApi(s) => s.pct(),
229 VendorSnapshot::Openai(s) => [
230 s.session.as_ref().map(|w| w.utilization_pct),
231 s.weekly.as_ref().map(|w| w.utilization_pct),
232 ]
233 .into_iter()
234 .flatten()
235 .max(),
236 VendorSnapshot::Zai(s) => [
237 s.session.as_ref().map(|w| w.utilization_pct),
238 s.weekly.as_ref().map(|w| w.utilization_pct),
239 ]
240 .into_iter()
241 .flatten()
242 .max(),
243 VendorSnapshot::Kimi(s) => Some(s.weekly_pct().max(s.window_pct())),
244 VendorSnapshot::Antigravity(s) => {
245 Some(s.session.utilization_pct.max(s.weekly.utilization_pct))
246 }
247 VendorSnapshot::Cursor(s) => (!s.unlimited).then_some(s.total_pct),
248 VendorSnapshot::Minimax(s) => Some(s.session.utilization_pct.max(s.weekly.utilization_pct)),
249 VendorSnapshot::Kiro(s) => Some(s.pct()),
250 VendorSnapshot::NousResearch(s) => s
251 .usage_percent()
252 .map(|value| value.round().clamp(0.0, 100.0) as i32),
253 VendorSnapshot::OpenCodeGo(s) => [
254 s.rolling
255 .as_ref()
256 .map(|window| window.percent.round() as i32),
257 s.weekly
258 .as_ref()
259 .map(|window| window.percent.round() as i32),
260 s.monthly
261 .as_ref()
262 .map(|window| window.percent.round() as i32),
263 ]
264 .into_iter()
265 .flatten()
266 .max(),
267 VendorSnapshot::SuperGrok(s) => Some(s.weekly_pct),
268 VendorSnapshot::Openrouter(_)
269 | VendorSnapshot::Deepseek(_)
270 | VendorSnapshot::Kilo(_)
271 | VendorSnapshot::Novita(_)
272 | VendorSnapshot::Moonshot(_)
273 | VendorSnapshot::Grok(_) => None,
274 }
275}
276
277pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
279 sections_with_metadata_for(tab, now, pace_tolerance)
280 .into_iter()
281 .map(|projected| projected.section)
282 .collect()
283}
284
285pub(crate) fn sections_with_metadata_for(
288 tab: &TabState,
289 now: DateTime<Utc>,
290 pace_tolerance: u32,
291) -> Vec<SectionProjection> {
292 let mut sections = match tab {
293 TabState::Loading => SectionBuilder::new(vec![
294 Section::Spacer,
295 Section::Text {
296 label: "".into(),
297 value: " Loading…".into(),
298 },
299 ]),
300 TabState::Error(e) => SectionBuilder::new(vec![
301 Section::Spacer,
302 Section::Text {
303 label: "Error".into(),
304 value: e.clone(),
305 },
306 Section::Spacer,
307 Section::Text {
308 label: "".into(),
309 value: "Press `r` to retry, `q` to quit.".into(),
310 },
311 ]),
312 TabState::Ready(r) => {
313 let snapshot = &r.snapshot;
314 let last_error = &r.last_error;
315 let mut sections = match snapshot {
316 VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
317 VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
318 VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
319 VendorSnapshot::Zai(s) => zai_sections(s, now),
320 VendorSnapshot::Openrouter(s) => openrouter_sections(s),
321 VendorSnapshot::Deepseek(s) => deepseek_sections(s),
322 VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
323 VendorSnapshot::Kilo(s) => kilo_sections(s),
324 VendorSnapshot::Novita(s) => novita_sections(s),
325 VendorSnapshot::Moonshot(s) => moonshot_sections(s),
326 VendorSnapshot::Grok(s) => grok_sections(s),
327 VendorSnapshot::SuperGrok(s) => supergrok_sections(s, now),
328 VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
329 VendorSnapshot::Cursor(s) => cursor_sections(s, now),
330 VendorSnapshot::Minimax(s) => minimax_sections(s, now, pace_tolerance),
331 VendorSnapshot::Kiro(s) => kiro_sections(s, now),
332 VendorSnapshot::NousResearch(s) => nous_sections(s, now),
333 VendorSnapshot::OpenCodeGo(s) => opencode_go_sections(s, now),
334 };
335 let updated = match r.fetched_at {
339 Some(at) => format!("Updated {}", local_time_hms(at)),
340 None => "Updated —".to_string(),
341 };
342 if let Some(SectionProjection {
343 section: Section::Title { right, .. },
344 ..
345 }) = sections.0.first_mut()
346 {
347 *right = Some(updated);
348 }
349 if let Some((label, msg)) = warning_label(snapshot, last_error) {
351 sections.push(Section::Spacer);
352 sections.push(Section::Text { label, value: msg });
353 }
354 sections
355 }
356 };
357 for projected in &mut sections.0 {
358 sanitize_section(&mut projected.section);
359 }
360 sections.0
361}
362
363fn sanitize_section(section: &mut Section) {
366 let clean = |value: &mut String| {
367 *value = crate::display::sanitize_untrusted_field(value);
368 };
369 match section {
370 Section::Title { left, right } => {
371 clean(left);
372 if let Some(right) = right {
373 clean(right);
374 }
375 }
376 Section::Metric {
377 label,
378 value_label,
379 footnote,
380 ..
381 } => {
382 clean(label);
383 clean(value_label);
384 clean(footnote);
385 }
386 Section::Text { label, value } => {
387 clean(label);
388 clean(value);
389 }
390 Section::Block { label, body } => {
391 clean(label);
392 for line in body {
393 clean(line);
394 }
395 }
396 Section::Spacer => {}
397 }
398}
399
400fn warning_label(
404 snapshot: &VendorSnapshot,
405 last_error: &Option<(u16, String)>,
406) -> Option<(String, String)> {
407 let (code, message) = last_error.as_ref()?;
408 if *code != 0 {
409 return Some((format!("HTTP {code}"), message.clone()));
410 }
411 if message.is_empty() {
412 return None;
413 }
414 let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
415 && matches!(
416 crate::kimi::vendor::warning_kind(*code, message),
417 crate::kimi::vendor::WarningKind::SchemaDrift
418 ) {
419 "Kimi API schema drift"
420 } else {
421 "Warning"
422 };
423 let value = if label == message {
426 String::new()
427 } else {
428 message.clone()
429 };
430 Some((label.into(), value))
431}
432
433fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> SectionBuilder {
434 let mut v = SectionBuilder::new(vec![Section::Title {
435 left: "Anthropic API".into(),
436 right: None,
437 }]);
438 match (s.limit.filter(|l| *l > 0.0), s.pct()) {
439 (Some(limit), Some(pct)) => {
440 let p = pct.clamp(0, 100) as u16;
441 v.push_metric(
442 Section::Metric {
443 label: "Spend (mo)".into(),
444 pct: p,
445 severity: severity_for(pct),
446 value_label: format!("{} of ${:.0}", usd(s.spent), limit),
447 footnote: format!("{pct}% of monthly limit"),
448 },
449 None,
450 );
451 }
452 _ => {
453 v.push(Section::Text {
454 label: "Spend (mo)".into(),
455 value: usd(s.spent),
456 });
457 }
458 }
459 v.push(Section::Spacer);
460 v.push(Section::Text {
461 label: "".into(),
462 value: "Month-to-date cost via the Admin usage API.".into(),
463 });
464 v.push(Section::Text {
465 label: "".into(),
466 value: "Prepaid credit balance is Console-only (no API).".into(),
467 });
468 v.push(Section::Text {
469 label: "".into(),
470 value: "Excludes Priority Tier cost (not reported by this API).".into(),
471 });
472 v
473}
474
475fn anthropic_sections(
476 s: &crate::usage::AnthropicSnapshot,
477 now: DateTime<Utc>,
478 tol: u32,
479) -> SectionBuilder {
480 let mut v = SectionBuilder::new(vec![Section::Title {
481 left: format!("Claude {}", s.plan),
482 right: None,
483 }]);
484
485 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
486 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
487 if let Some(w) = &s.sonnet {
488 push_window(&mut v, "Sonnet only", w, now, tol, false);
489 }
490 for sw in &s.scoped {
491 push_window(
492 &mut v,
493 &format!("{} (7d)", sw.label),
494 &sw.window,
495 now,
496 tol,
497 false,
498 );
499 }
500 if let Some(e) = &s.extra {
501 v.push(Section::Spacer);
502 let pct = e.percent().clamp(0, 100) as u16;
503 let (value_label, footnote) = match e.fmt_limit() {
507 Some(l) => (
508 format!("{} of {}", e.fmt_spent(), l),
509 format!("{pct}% of monthly limit consumed"),
510 ),
511 None => (e.fmt_spent(), "no monthly limit reported".to_string()),
512 };
513 v.push_metric(
514 Section::Metric {
515 label: "Extra usage".into(),
516 pct,
517 severity: severity_for(pct as i32),
518 value_label,
519 footnote,
520 },
521 None,
522 );
523 }
524 v
525}
526
527fn openai_sections(
528 s: &crate::usage::OpenAiSnapshot,
529 now: DateTime<Utc>,
530 tol: u32,
531) -> SectionBuilder {
532 let mut v = SectionBuilder::new(vec![Section::Title {
533 left: s.plan.clone(),
534 right: None,
535 }]);
536 if let Some(session) = &s.session {
537 push_window(&mut v, "Codex 5h", session, now, tol, true);
538 }
539 if let Some(weekly) = &s.weekly {
540 push_window(&mut v, "Codex weekly", weekly, now, tol, true);
541 }
542 if s.session.is_none() && s.weekly.is_none() {
543 v.push(Section::Spacer);
544 v.push(Section::Text {
545 label: "".into(),
546 value: " no usage windows reported".into(),
547 });
548 }
549 if let Some(cr) = &s.code_review {
550 push_window(&mut v, "Code review", cr, now, tol, false);
551 }
552 if let Some(c) = &s.credits {
553 v.push(Section::Spacer);
554 let balance = if c.unlimited {
555 "unlimited".into()
556 } else {
557 c.balance.clone()
558 };
559 let mut body = vec![format!("balance: {}", balance)];
560 if let Some((lo, hi)) = c.approx_local_messages {
561 body.push(format!("≈ {lo}-{hi} local messages"));
562 }
563 if let Some((lo, hi)) = c.approx_cloud_messages {
564 body.push(format!("≈ {lo}-{hi} cloud messages"));
565 }
566 v.push(Section::Block {
567 label: "Credits".into(),
568 body,
569 });
570 }
571 v
572}
573
574fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> SectionBuilder {
575 let mut v = SectionBuilder::new(vec![Section::Title {
576 left: s.plan.clone(),
577 right: None,
578 }]);
579 if let Some(w) = &s.session {
580 push_window(&mut v, "Session (5h)", w, now, 5, false);
581 }
582 if let Some(w) = &s.weekly {
583 push_window(&mut v, "Weekly", w, now, 5, false);
584 }
585 if let Some(w) = &s.mcp {
586 push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
587 }
588 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
589 v.push(Section::Spacer);
590 v.push(Section::Text {
591 label: "".into(),
592 value: " no usage windows reported".into(),
593 });
594 }
595 v
596}
597
598fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> SectionBuilder {
599 let mut v = SectionBuilder::new(vec![Section::Title {
600 left: s.label.clone(),
601 right: None,
602 }]);
603 let pct = s.consumed_pct().clamp(0, 100) as u16;
604 v.push(Section::Spacer);
605 v.push_metric(
606 Section::Metric {
607 label: "Credit balance".into(),
608 pct,
609 severity: crate::openrouter::vendor::severity(s),
613 value_label: usd(s.balance()),
614 footnote: format!(
615 "{} of {} used ({pct}%)",
616 usd(s.total_usage),
617 usd(s.total_credits)
618 ),
619 },
620 None,
621 );
622 v.push(Section::Spacer);
623 v.push(Section::Block {
624 label: "Usage by period".into(),
625 body: vec![format!(
626 "today ${:.2} · week ${:.2} · month ${:.2}",
627 s.usage_daily, s.usage_weekly, s.usage_monthly
628 )],
629 });
630 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
631 v.push(Section::Spacer);
632 v.push(Section::Block {
633 label: "Per-key limit".into(),
634 body: vec![format!("{} of {} remaining", usd(rem), usd(limit))],
635 });
636 }
637 v.push(Section::Spacer);
638 v.push(Section::Block {
639 label: "Tier".into(),
640 body: vec![if s.is_free_tier {
641 "free tier".into()
642 } else {
643 "paid tier".into()
644 }],
645 });
646 v
647}
648
649fn antigravity_sections(
653 s: &crate::usage::AntigravitySnapshot,
654 now: DateTime<Utc>,
655) -> SectionBuilder {
656 use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
657
658 let mut v = SectionBuilder::new(vec![Section::Title {
659 left: s.plan.clone(),
660 right: None,
661 }]);
662 for (heading, primary, third_party) in [
663 ("Session", &s.session, s.third_party_session.as_ref()),
664 ("Weekly", &s.weekly, s.third_party_weekly.as_ref()),
665 ] {
666 v.push(Section::Spacer);
667 v.push(Section::Text {
668 label: heading.into(),
669 value: String::new(),
670 });
671 push_window(&mut v, GROUP_PRIMARY, primary, now, 5, false);
672 if let Some(w) = third_party {
673 push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
674 }
675 }
676 v
677}
678
679fn cursor_sections(s: &crate::usage::CursorSnapshot, now: DateTime<Utc>) -> SectionBuilder {
680 let mut v = SectionBuilder::new(vec![Section::Title {
681 left: format!("Cursor {}", s.plan),
682 right: None,
683 }]);
684 if s.unlimited {
685 v.push(Section::Spacer);
686 v.push(Section::Text {
687 label: "Plan".into(),
688 value: "Unlimited — pools don't cap".into(),
689 });
690 } else {
691 v.push(Section::Spacer);
693 v.push_metric(
694 Section::Metric {
695 label: "Cursor Models".into(),
696 pct: s.auto_pct.clamp(0, 100) as u16,
697 severity: severity_for(s.auto_pct),
698 value_label: format!("{}%", s.auto_pct),
699 footnote: "Auto + Composer".into(),
700 },
701 s.reset_at,
702 );
703 v.push(Section::Spacer);
704 v.push_metric(
705 Section::Metric {
706 label: "Other Models".into(),
707 pct: s.api_pct.clamp(0, 100) as u16,
708 severity: severity_for(s.api_pct),
709 value_label: format!("{}%", s.api_pct),
710 footnote: format!(
711 "Named / API models · on-demand {}",
712 if s.on_demand_enabled { "on" } else { "off" }
713 ),
714 },
715 s.reset_at,
716 );
717 }
718 v.push(Section::Spacer);
719 v.push(Section::Text {
720 label: "Resets".into(),
721 value: countdown::format(s.reset_at, now),
722 });
723 v
724}
725
726fn nous_sections(s: &crate::nous::types::AccountSnapshot, now: DateTime<Utc>) -> SectionBuilder {
727 let mut sections = SectionBuilder::new(vec![Section::Title {
728 left: "Nous Research".into(),
729 right: None,
730 }]);
731 if let Some(value) = s.usage_percent() {
732 let pct = value.round().clamp(0.0, 100.0) as i32;
733 sections.push_metric(
734 Section::Metric {
735 label: "Usage".into(),
736 pct: pct as u16,
737 severity: severity_for(pct),
738 value_label: format!("{pct}%"),
739 footnote: "current period".into(),
740 },
741 s.current_period_end,
742 );
743 }
744 sections.push(Section::Spacer);
745 if let Some(remaining) = s.credits_remaining {
746 sections.push(Section::Text {
747 label: "Subscription credits".into(),
748 value: format!("{remaining:.2} remaining"),
749 });
750 }
751 if let Some(purchased) = s.purchased_credits_remaining {
752 sections.push(Section::Text {
753 label: "Top-up credits".into(),
754 value: format!("{purchased:.2} remaining"),
755 });
756 }
757 if let Some(total_usable) = s.total_usable_credits {
758 sections.push(Section::Text {
759 label: "Total usable credits".into(),
760 value: format!("{total_usable:.2}"),
761 });
762 }
763 if let Some(period_end) = s.current_period_end {
764 sections.push(Section::Text {
765 label: "Renews".into(),
766 value: countdown::format(Some(period_end), now),
767 });
768 }
769 sections
770}
771
772fn opencode_go_sections(
773 s: &crate::opencode_go::types::Usage,
774 now: DateTime<Utc>,
775) -> SectionBuilder {
776 let mut sections = SectionBuilder::new(vec![Section::Title {
777 left: "OpenCode Go".into(),
778 right: None,
779 }]);
780 for (label, window) in [
781 ("Rolling", s.rolling.as_ref()),
782 ("Weekly", s.weekly.as_ref()),
783 ("Monthly", s.monthly.as_ref()),
784 ] {
785 if let Some(window) = window {
786 let pct = window.percent.round().clamp(0.0, 100.0) as i32;
787 sections.push_metric(
788 Section::Metric {
789 label: label.into(),
790 pct: pct as u16,
791 severity: severity_for(pct),
792 value_label: format!("{pct}%"),
793 footnote: String::new(),
794 },
795 Some(window.resets_at),
796 );
797 sections.push(Section::Text {
798 label: "Resets".into(),
799 value: countdown::format(Some(window.resets_at), now),
800 });
801 }
802 }
803 sections
804}
805
806fn kiro_sections(s: &crate::usage::KiroSnapshot, now: DateTime<Utc>) -> SectionBuilder {
811 let pct = s.pct();
812 let mut v = SectionBuilder::new(vec![
813 Section::Title {
814 left: format!("Kiro {}", s.plan),
815 right: None,
816 },
817 Section::Spacer,
818 ]);
819 v.push_metric(
820 Section::Metric {
821 label: "Credits".into(),
822 pct: pct.clamp(0, 100) as u16,
823 severity: severity_for(pct),
824 value_label: format!("{pct}%"),
825 footnote: format!("{:.2} of {:.0}", s.used, s.limit),
826 },
827 s.reset_at,
828 );
829 v.push(Section::Spacer);
830 v.push(Section::Text {
831 label: "Resets".into(),
832 value: countdown::format(s.reset_at, now),
833 });
834 v
835}
836
837fn minimax_sections(
842 s: &crate::usage::MinimaxSnapshot,
843 now: DateTime<Utc>,
844 tol: u32,
845) -> SectionBuilder {
846 use crate::minimax::vendor::{POOL_GENERAL, POOL_VIDEO};
847
848 let mut v = SectionBuilder::new(vec![Section::Title {
849 left: s.plan.clone(),
850 right: None,
851 }]);
852 for (heading, general, video) in [
853 ("Session", &s.session, s.video_session.as_ref()),
854 ("Weekly", &s.weekly, s.video_weekly.as_ref()),
855 ] {
856 v.push(Section::Spacer);
857 v.push(Section::Text {
858 label: heading.into(),
859 value: String::new(),
860 });
861 push_window(&mut v, POOL_GENERAL, general, now, tol, true);
862 if let Some(w) = video {
863 push_window(&mut v, POOL_VIDEO, w, now, tol, true);
864 }
865 }
866 v
867}
868
869fn kilo_sections(s: &crate::usage::KiloSnapshot) -> SectionBuilder {
870 SectionBuilder::new(vec![
871 Section::Title {
872 left: s.label.clone(),
873 right: None,
874 },
875 Section::Spacer,
876 Section::Text {
877 label: "Balance".into(),
878 value: usd(s.balance),
879 },
880 ])
881}
882
883fn novita_sections(s: &crate::usage::NovitaSnapshot) -> SectionBuilder {
884 let mut v = SectionBuilder::new(vec![
885 Section::Title {
886 left: "Novita".into(),
887 right: None,
888 },
889 Section::Spacer,
890 Section::Text {
891 label: "Balance".into(),
892 value: usd(s.available),
893 },
894 Section::Block {
895 label: "Breakdown".into(),
896 body: vec![format!(
897 "top-up ${:.2} · credit limit ${:.2}",
898 s.cash, s.credit_limit
899 )],
900 },
901 ]);
902 if s.outstanding > 0.0 {
903 v.push(Section::Spacer);
904 v.push(Section::Block {
905 label: "Owed".into(),
906 body: vec![usd(s.outstanding)],
907 });
908 }
909 v
910}
911
912fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> SectionBuilder {
913 let cur = &s.currency;
914 let fmt = |v: f64| money(v, cur);
915 SectionBuilder::new(vec![
916 Section::Title {
917 left: "Kimi (Moonshot)".into(),
918 right: None,
919 },
920 Section::Spacer,
921 Section::Text {
922 label: "Balance".into(),
923 value: fmt(s.available),
924 },
925 Section::Block {
926 label: "Breakdown".into(),
927 body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
928 },
929 ])
930}
931
932fn grok_sections(s: &crate::usage::GrokSnapshot) -> SectionBuilder {
933 SectionBuilder::new(vec![
934 Section::Title {
935 left: "Grok (xAI)".into(),
936 right: None,
937 },
938 Section::Spacer,
939 Section::Text {
940 label: "Prepaid balance".into(),
941 value: usd(s.balance),
942 },
943 ])
944}
945
946fn supergrok_sections(s: &crate::usage::SuperGrokSnapshot, now: DateTime<Utc>) -> SectionBuilder {
947 let pct = s.weekly_pct;
948 let mut v = SectionBuilder::new(vec![
949 Section::Title {
950 left: s.plan.clone(),
951 right: None,
952 },
953 Section::Spacer,
954 ]);
955 v.push_metric(
956 Section::Metric {
957 label: format!("{} Build credits", s.period.label()),
958 pct: pct.clamp(0, 100) as u16,
959 severity: severity_for(pct),
960 value_label: format!("{pct}%"),
961 footnote: String::new(),
962 },
963 s.reset_at,
964 );
965 v.push(Section::Spacer);
966 v.push(Section::Text {
967 label: "Resets".into(),
968 value: countdown::format(s.reset_at, now),
969 });
970 if let Some(bal) = s.prepaid_balance {
971 v.push(Section::Spacer);
972 v.push(Section::Text {
973 label: "Prepaid API".into(),
974 value: usd(bal),
975 });
976 }
977 v
978}
979
980fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> SectionBuilder {
981 let currency = &s.currency;
982 let fmt = |v: f64| money(v, currency);
983 let avail = if s.is_available {
984 "available"
985 } else {
986 "unavailable"
987 };
988 let mut v = SectionBuilder::new(vec![Section::Title {
989 left: "DeepSeek".into(),
990 right: None,
991 }]);
992 v.push(Section::Spacer);
993 v.push(Section::Text {
994 label: "Balance".into(),
995 value: fmt(s.balance),
996 });
997 v.push(Section::Block {
998 label: "Breakdown".into(),
999 body: vec![format!(
1000 "granted {} · topped-up {}",
1001 fmt(s.granted),
1002 fmt(s.topped_up)
1003 )],
1004 });
1005 v.push(Section::Spacer);
1006 v.push(Section::Block {
1007 label: "API".into(),
1008 body: vec![avail.into()],
1009 });
1010 v
1011}
1012
1013fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> SectionBuilder {
1014 let plan = s.plan.as_deref().unwrap_or("Kimi");
1015 let mut v = SectionBuilder::new(vec![Section::Title {
1016 left: plan.into(),
1017 right: None,
1018 }]);
1019
1020 let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
1021 v.push(Section::Spacer);
1022 v.push_metric(
1023 Section::Metric {
1024 label: "Weekly quota".into(),
1025 pct: weekly_pct,
1026 severity: severity_for(s.weekly_pct()),
1027 value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
1028 footnote: format!(
1029 "{} remaining · reset {}",
1030 s.weekly_remaining,
1031 countdown::format(s.weekly_reset_at, now)
1032 ),
1033 },
1034 s.weekly_reset_at,
1035 );
1036
1037 if s.window_limit > 0 {
1038 let window_pct = s.window_pct().clamp(0, 100) as u16;
1039 v.push(Section::Spacer);
1040 v.push_metric(
1041 Section::Metric {
1042 label: "Rolling window (5h)".into(),
1043 pct: window_pct,
1044 severity: severity_for(s.window_pct()),
1045 value_label: format!("{} / {}", s.window_used, s.window_limit),
1046 footnote: format!(
1047 "{} remaining · reset {}",
1048 s.window_remaining,
1049 countdown::format(s.window_reset_at, now)
1050 ),
1051 },
1052 s.window_reset_at,
1053 );
1054 }
1055
1056 v
1057}
1058
1059fn push_window(
1060 sections: &mut SectionBuilder,
1061 label: &str,
1062 w: &crate::usage::UsageWindow,
1063 now: DateTime<Utc>,
1064 tol: u32,
1065 show_pacing: bool,
1066) {
1067 let pct = w.utilization_pct.clamp(0, 100) as u16;
1068 let reset_text = countdown::format(w.resets_at, now);
1069 let footnote = if show_pacing {
1070 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
1071 format!(
1072 "Resets in {} · {}% elapsed · {}",
1073 reset_text, p.elapsed_pct, p.point_label
1074 )
1075 } else {
1076 format!("Resets in {}", reset_text)
1077 };
1078 sections.push(Section::Spacer);
1079 sections.push_metric(
1080 Section::Metric {
1081 label: label.into(),
1082 pct,
1083 severity: severity_for(pct as i32),
1084 value_label: format!("{pct}%"),
1085 footnote,
1086 },
1087 w.resets_at,
1088 );
1089}
1090
1091pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
1099 if sections.is_empty() {
1100 return;
1101 }
1102 let bubble = bubble_theme(theme);
1103 let pin_last =
1106 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
1107
1108 let body_end = if pin_last {
1109 sections.len() - 1
1110 } else {
1111 sections.len()
1112 };
1113 let mut constraints: Vec<Constraint> =
1114 sections[..body_end].iter().map(section_height).collect();
1115
1116 if pin_last {
1117 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
1119 } else {
1120 constraints.push(Constraint::Min(0));
1121 }
1122
1123 let chunks = Layout::default()
1124 .direction(ratatui::layout::Direction::Vertical)
1125 .constraints(constraints)
1126 .split(area);
1127
1128 for (i, s) in sections[..body_end].iter().enumerate() {
1129 render_section(f, chunks[i], theme, &bubble, s);
1130 }
1131 if pin_last {
1132 render_section(
1133 f,
1134 chunks[chunks.len() - 1],
1135 theme,
1136 &bubble,
1137 sections.last().unwrap(),
1138 );
1139 }
1140}
1141
1142fn section_height(s: &Section) -> Constraint {
1143 match s {
1144 Section::Title { .. } => Constraint::Length(2),
1145 Section::Metric { .. } => Constraint::Length(3),
1146 Section::Text { .. } => Constraint::Length(1),
1147 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
1148 Section::Spacer => Constraint::Length(1),
1149 }
1150}
1151
1152fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
1153 match s {
1154 Section::Title { left, right } => {
1155 let left_line = Line::from(Span::styled(
1158 format!(" {} {left}", bubble.symbols.selected),
1159 bubble.title,
1160 ));
1161 f.render_widget(Paragraph::new(left_line), area);
1162 if let Some(rt) = right {
1163 let right_line =
1164 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
1165 f.render_widget(Paragraph::new(right_line), area);
1166 }
1167 }
1168 Section::Metric {
1169 label,
1170 pct,
1171 severity,
1172 value_label,
1173 footnote,
1174 } => render_metric(
1175 f,
1176 area,
1177 theme,
1178 bubble,
1179 label,
1180 *pct,
1181 *severity,
1182 value_label,
1183 footnote,
1184 ),
1185 Section::Text { label, value } => {
1186 if label.is_empty() && value.contains("Loading") {
1187 render_loading(f, area, bubble);
1188 return;
1189 }
1190 if label == "Error" {
1191 let line = Line::from(vec![
1192 bubble.error(format!(" {} ", bubble.symbols.cross)),
1193 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
1194 ]);
1195 f.render_widget(Paragraph::new(line), area);
1196 return;
1197 }
1198 let mut spans = Vec::new();
1199 if !label.is_empty() {
1200 spans.push(Span::styled(
1201 format!(" {label} "),
1202 bubble.text.add_modifier(Modifier::BOLD),
1203 ));
1204 }
1205 spans.push(Span::styled(value.clone(), bubble.muted));
1206 f.render_widget(Paragraph::new(Line::from(spans)), area);
1207 }
1208 Section::Block { label, body } => render_block(f, area, bubble, label, body),
1209 Section::Spacer => {}
1210 }
1211}
1212
1213fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
1214 let frames = SpinnerFrames::DOTS;
1215 let frame_count = frames.frames().len().max(1);
1216 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
1217 let mut spinner = Spinner::new()
1218 .frames(frames)
1219 .label("Fetching usage data")
1220 .theme(*bubble);
1221 for _ in 0..(frame % frame_count) {
1222 spinner.tick();
1223 }
1224 f.render_widget(&spinner, area);
1225}
1226
1227#[allow(clippy::too_many_arguments)]
1228fn render_metric(
1229 f: &mut Frame,
1230 area: Rect,
1231 theme: &Theme,
1232 bubble: &BubbleTheme,
1233 label: &str,
1234 pct: u16,
1235 severity: PaceSeverity,
1236 value_label: &str,
1237 footnote: &str,
1238) {
1239 let bar_color = severity_color(theme, bubble, severity);
1240 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
1241
1242 let inner = Layout::default()
1243 .direction(ratatui::layout::Direction::Vertical)
1244 .constraints([
1245 Constraint::Length(1),
1246 Constraint::Length(1),
1247 Constraint::Length(1),
1248 ])
1249 .split(area);
1250
1251 let label_line = Line::from(Span::styled(
1253 format!(" {label}"),
1254 bubble.text.add_modifier(Modifier::BOLD),
1255 ));
1256 f.render_widget(Paragraph::new(label_line), inner[0]);
1257
1258 let row = inner[1];
1260 let value_w = value_label.chars().count() as u16 + 2;
1261 let gauge_area = Rect {
1262 x: row.x + 2,
1263 y: row.y,
1264 width: row.width.saturating_sub(value_w + 4),
1265 height: 1,
1266 };
1267 let value_area = Rect {
1268 x: gauge_area.x + gauge_area.width + 1,
1269 y: row.y,
1270 width: value_w,
1271 height: 1,
1272 };
1273 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
1274 let progress = Progress::from_percent(pct)
1275 .theme(progress_theme)
1276 .show_percentage(false);
1277 f.render_widget(&progress, gauge_area);
1278 let value = Paragraph::new(Line::from(Span::styled(
1279 value_label.to_string(),
1280 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
1281 )));
1282 f.render_widget(value, value_area);
1283
1284 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
1286 f.render_widget(Paragraph::new(foot), inner[2]);
1287}
1288
1289fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
1290 let mut lines = vec![Line::from(Span::styled(
1291 format!(" {label}"),
1292 bubble.text.add_modifier(Modifier::BOLD),
1293 ))];
1294 for b in body {
1295 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
1296 }
1297 f.render_widget(Paragraph::new(lines), area);
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use crate::usage::{
1304 AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
1305 OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
1306 };
1307 use chrono::TimeZone;
1308
1309 fn now() -> DateTime<Utc> {
1310 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
1311 }
1312
1313 fn ready(snapshot: VendorSnapshot) -> TabState {
1314 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
1315 snapshot,
1316 stale: false,
1317 last_error: None,
1318 fetched_at: Some(now() - chrono::Duration::seconds(15)),
1319 }))
1320 }
1321
1322 #[test]
1323 fn anthropic_sections_include_all_three_windows_when_present() {
1324 let snap = AnthropicSnapshot {
1325 plan: "Max 20x".into(),
1326 session: UsageWindow {
1327 utilization_pct: 60,
1328 resets_at: Some(now() + chrono::Duration::hours(1)),
1329 window_duration: chrono::Duration::hours(5),
1330 },
1331 weekly: UsageWindow {
1332 utilization_pct: 30,
1333 resets_at: Some(now() + chrono::Duration::days(3)),
1334 window_duration: chrono::Duration::days(7),
1335 },
1336 sonnet: Some(UsageWindow {
1337 utilization_pct: 5,
1338 resets_at: Some(now() + chrono::Duration::hours(2)),
1339 window_duration: chrono::Duration::days(7),
1340 }),
1341 scoped: vec![],
1342 extra: Some(ExtraUsage {
1343 limit: Some(Cents(5000)),
1344 spent: Cents(250),
1345 currency: None,
1346 decimal_places: Some(2),
1347 }),
1348 };
1349 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1350 assert_eq!(sections.len(), 9);
1353 assert!(matches!(sections[0], Section::Title { .. }));
1354 if let Section::Title { right, .. } = §ions[0] {
1356 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
1357 } else {
1358 panic!("expected first section to be Title");
1359 }
1360 let metric_count = sections
1361 .iter()
1362 .filter(|s| matches!(s, Section::Metric { .. }))
1363 .count();
1364 assert_eq!(metric_count, 4);
1365 }
1366
1367 #[test]
1368 fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
1369 let snap = AnthropicSnapshot {
1372 plan: "Pro".into(),
1373 session: UsageWindow {
1374 utilization_pct: 10,
1375 resets_at: None,
1376 window_duration: chrono::Duration::hours(5),
1377 },
1378 weekly: UsageWindow {
1379 utilization_pct: 20,
1380 resets_at: None,
1381 window_duration: chrono::Duration::days(7),
1382 },
1383 sonnet: None,
1384 scoped: vec![],
1385 extra: Some(ExtraUsage {
1386 limit: None,
1387 spent: Cents(14157),
1388 currency: Some("BRL".into()),
1389 decimal_places: Some(2),
1390 }),
1391 };
1392 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1393 let extra = sections
1394 .iter()
1395 .find_map(|s| match s {
1396 Section::Metric {
1397 label,
1398 pct,
1399 value_label,
1400 footnote,
1401 ..
1402 } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
1403 _ => None,
1404 })
1405 .expect("uncapped extra usage must still render a section");
1406 assert_eq!(extra.0, 0);
1407 assert_eq!(extra.1, "R$141.57");
1409 assert!(
1410 !extra.1.contains(" of "),
1411 "no denominator to show: {}",
1412 extra.1
1413 );
1414 assert_eq!(extra.2, "no monthly limit reported");
1415 }
1416
1417 #[test]
1418 fn anthropic_omits_sonnet_and_extra_when_absent() {
1419 let snap = AnthropicSnapshot {
1420 plan: "Pro".into(),
1421 session: UsageWindow {
1422 utilization_pct: 10,
1423 resets_at: None,
1424 window_duration: chrono::Duration::hours(5),
1425 },
1426 weekly: UsageWindow {
1427 utilization_pct: 5,
1428 resets_at: None,
1429 window_duration: chrono::Duration::days(7),
1430 },
1431 sonnet: None,
1432 scoped: vec![],
1433 extra: None,
1434 };
1435 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1436 let metric_count = sections
1437 .iter()
1438 .filter(|s| matches!(s, Section::Metric { .. }))
1439 .count();
1440 assert_eq!(metric_count, 2);
1441 }
1442
1443 #[test]
1444 fn openrouter_always_has_balance_metric_and_period_block() {
1445 let snap = OpenRouterSnapshot {
1446 label: "OR".into(),
1447 total_credits: 100.0,
1448 total_usage: 25.0,
1449 usage_daily: 1.0,
1450 usage_weekly: 5.0,
1451 usage_monthly: 25.0,
1452 is_free_tier: false,
1453 limit: None,
1454 limit_remaining: None,
1455 };
1456 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
1457 assert!(matches!(sections[0], Section::Title { .. }));
1458 assert!(
1459 sections
1460 .iter()
1461 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
1462 );
1463 assert!(
1464 sections
1465 .iter()
1466 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
1467 );
1468 }
1469
1470 #[test]
1474 fn openrouter_debt_reaches_the_panel_row_red_and_signed() {
1475 let snap = OpenRouterSnapshot {
1476 label: "OR".into(),
1477 total_credits: 0.0,
1478 total_usage: 5.71,
1479 usage_daily: 1.0,
1480 usage_weekly: 5.0,
1481 usage_monthly: 5.71,
1482 is_free_tier: false,
1483 limit: None,
1484 limit_remaining: None,
1485 };
1486 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap.clone())), now(), 5);
1487 let metric = sections
1488 .iter()
1489 .find_map(|s| match s {
1490 Section::Metric {
1491 label,
1492 value_label,
1493 severity,
1494 footnote,
1495 ..
1496 } if label == "Credit balance" => Some((value_label, severity, footnote)),
1497 _ => None,
1498 })
1499 .expect("no credit balance metric");
1500 assert_eq!(metric.0, "-$5.71");
1501 assert_eq!(*metric.1, PaceSeverity::Critical);
1502 assert_eq!(metric.2, "$5.71 of $0.00 used (0%)");
1503
1504 let (_, cells) = compact_cells(&VendorSnapshot::Openrouter(snap));
1506 assert_eq!(cells[0].0, "-$5.71");
1507 }
1508
1509 #[test]
1510 fn zai_no_windows_renders_message() {
1511 let snap = ZaiSnapshot {
1512 plan: "GLM".into(),
1513 session: None,
1514 weekly: None,
1515 mcp: None,
1516 };
1517 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
1518 assert!(sections.iter().any(|s| matches!(
1519 s,
1520 Section::Text { value, .. } if value.contains("no usage windows reported")
1521 )));
1522 }
1523
1524 #[test]
1525 fn openai_no_windows_renders_message() {
1526 let snap = OpenAiSnapshot {
1527 plan: "ChatGPT Plus".into(),
1528 session: None,
1529 weekly: None,
1530 code_review: None,
1531 credits: None,
1532 source: OpenAiSource::CodexOauth,
1533 };
1534 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1535 assert!(sections.iter().any(|s| matches!(
1536 s,
1537 Section::Text { value, .. } if value.contains("no usage windows reported")
1538 )));
1539 }
1540
1541 #[test]
1542 fn loading_state_yields_loading_section() {
1543 let sections = sections_for(&TabState::Loading, now(), 5);
1544 assert!(sections.iter().any(|s| matches!(
1545 s,
1546 Section::Text { value, .. } if value.contains("Loading")
1547 )));
1548 }
1549
1550 #[test]
1551 fn error_state_includes_retry_hint() {
1552 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
1553 assert!(sections.iter().any(|s| matches!(
1554 s,
1555 Section::Text { value, .. } if value.contains("token expired")
1556 )));
1557 assert!(sections.iter().any(|s| matches!(
1558 s,
1559 Section::Text { value, .. } if value.contains("`r` to retry")
1560 )));
1561 }
1562
1563 #[test]
1564 fn openai_with_credits_renders_block() {
1565 let snap = OpenAiSnapshot {
1566 plan: "ChatGPT Plus".into(),
1567 session: Some(UsageWindow {
1568 utilization_pct: 1,
1569 resets_at: None,
1570 window_duration: chrono::Duration::hours(5),
1571 }),
1572 weekly: Some(UsageWindow {
1573 utilization_pct: 0,
1574 resets_at: None,
1575 window_duration: chrono::Duration::days(7),
1576 }),
1577 code_review: None,
1578 credits: Some(OpenAiCredits {
1579 balance: "$5.00".into(),
1580 has_credits: true,
1581 unlimited: false,
1582 approx_local_messages: Some((100, 200)),
1583 approx_cloud_messages: Some((30, 50)),
1584 }),
1585 source: OpenAiSource::CodexOauth,
1586 };
1587 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1588 assert!(
1589 sections
1590 .iter()
1591 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
1592 );
1593 }
1594
1595 #[test]
1596 fn openai_weekly_only_omits_session_section() {
1597 let snap = OpenAiSnapshot {
1598 plan: "ChatGPT Prolite".into(),
1599 session: None,
1600 weekly: Some(UsageWindow {
1601 utilization_pct: 66,
1602 resets_at: None,
1603 window_duration: chrono::Duration::days(7),
1604 }),
1605 code_review: None,
1606 credits: None,
1607 source: OpenAiSource::CodexOauth,
1608 };
1609 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1610 assert!(sections.iter().any(|section| matches!(
1611 section,
1612 Section::Metric { label, .. } if label == "Codex weekly"
1613 )));
1614 assert!(!sections.iter().any(|section| matches!(
1615 section,
1616 Section::Metric { label, .. } if label == "Codex 5h"
1617 )));
1618 }
1619
1620 #[test]
1621 fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
1622 let now = now();
1623 let snap = KimiSnapshot {
1624 plan: Some("LEVEL_INTERMEDIATE".into()),
1625 weekly_limit: 100,
1626 weekly_used: 26,
1627 weekly_remaining: 74,
1628 weekly_reset_at: Some(now + chrono::Duration::days(4)),
1629 window_limit: 100,
1630 window_used: 15,
1631 window_remaining: 85,
1632 window_reset_at: Some(now + chrono::Duration::hours(2)),
1633 };
1634 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
1635 let metrics: Vec<_> = sections
1636 .iter()
1637 .filter(|s| matches!(s, Section::Metric { .. }))
1638 .collect();
1639 assert_eq!(metrics.len(), 2);
1640 assert!(sections.iter().any(|s| matches!(
1641 s,
1642 Section::Metric { label, .. } if label == "Weekly quota"
1643 )));
1644 assert!(sections.iter().any(|s| matches!(
1645 s,
1646 Section::Metric { label, .. } if label == "Rolling window (5h)"
1647 )));
1648
1649 let find_footnote = |label: &str| -> (String, String) {
1650 sections
1651 .iter()
1652 .find_map(|s| match s {
1653 Section::Metric {
1654 label: l,
1655 value_label,
1656 footnote,
1657 ..
1658 } if l == label => Some((value_label.clone(), footnote.clone())),
1659 _ => None,
1660 })
1661 .unwrap_or_else(|| panic!("missing metric {label}"))
1662 };
1663
1664 let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
1665 assert_eq!(weekly_value, "26 / 100");
1666 assert!(weekly_footnote.contains("74 remaining"));
1667 assert!(
1668 weekly_footnote.contains("4d 0h"),
1669 "weekly reset countdown: {weekly_footnote}"
1670 );
1671 assert!(!weekly_footnote.contains("2026-05-27T")); let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
1674 assert_eq!(window_value, "15 / 100");
1675 assert!(window_footnote.contains("85 remaining"));
1676 assert!(
1677 window_footnote.contains("2h 00m"),
1678 "window reset countdown: {window_footnote}"
1679 );
1680 assert!(!window_footnote.contains("2026-05-23T14")); }
1682
1683 #[test]
1684 fn kimi_sections_omit_window_when_limit_zero() {
1685 let snap = KimiSnapshot {
1686 plan: None,
1687 weekly_limit: 100,
1688 weekly_used: 10,
1689 weekly_remaining: 90,
1690 weekly_reset_at: None,
1691 window_limit: 0,
1692 window_used: 0,
1693 window_remaining: 0,
1694 window_reset_at: None,
1695 };
1696 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
1697 let metric_count = sections
1698 .iter()
1699 .filter(|s| matches!(s, Section::Metric { .. }))
1700 .count();
1701 assert_eq!(metric_count, 1);
1702 }
1703
1704 fn cursor_snap() -> crate::usage::CursorSnapshot {
1705 crate::usage::CursorSnapshot {
1706 plan: "Ultra".into(),
1707 auto_pct: 98,
1708 api_pct: 100,
1709 total_pct: 99,
1710 unlimited: false,
1711 on_demand_enabled: false,
1712 reset_at: Some(now() + chrono::Duration::days(9)),
1713 }
1714 }
1715
1716 #[test]
1717 fn compact_cells_flatten_key_metrics_for_the_overview() {
1718 let (plan, cells) = compact_cells(&VendorSnapshot::Cursor(cursor_snap()));
1720 assert_eq!(plan, "Ultra");
1721 assert_eq!(cells[0].0, "auto 98%");
1722 assert_eq!(cells[1].0, "premium 100%");
1723 assert_eq!(cells[1].1, PaceSeverity::Critical); let (plan, cells) = compact_cells(&VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1727 label: "Kilo".into(),
1728 balance: 8.42,
1729 }));
1730 assert!(plan.is_empty());
1731 assert_eq!(cells, vec![("$8.42".to_string(), PaceSeverity::Low)]);
1732 }
1733
1734 #[test]
1735 fn terminal_controls_are_removed_from_detail_and_overview_fields() {
1736 let error = TabState::Error("bad\x1b]52;c;Y2FuYXJ5\x07 value".into());
1737 let sections = sections_for(&error, now(), 5);
1738 assert!(matches!(
1739 §ions[1],
1740 Section::Text { value, .. }
1741 if value == "bad]52;c;Y2FuYXJ5 value"
1742 && !value.chars().any(|ch| ch.is_control())
1743 ));
1744
1745 let mut snapshot = cursor_snap();
1746 snapshot.plan = "Ultra\x1b[2J\x07".into();
1747 let (plan, _) = compact_cells(&VendorSnapshot::Cursor(snapshot));
1748 assert_eq!(plan, "Ultra[2J");
1749 assert!(!plan.chars().any(char::is_control));
1750 }
1751
1752 #[test]
1753 fn headline_pct_is_the_worst_window_or_combined_total() {
1754 assert_eq!(
1756 headline_pct(&VendorSnapshot::Cursor(cursor_snap())),
1757 Some(99)
1758 );
1759
1760 let kilo = VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1762 label: "Kilo".into(),
1763 balance: 8.42,
1764 });
1765 assert_eq!(headline_pct(&kilo), None);
1766 }
1767
1768 #[test]
1769 fn cursor_sections_show_both_pools_and_reset() {
1770 let sections = sections_for(&ready(VendorSnapshot::Cursor(cursor_snap())), now(), 5);
1771 let metrics: Vec<_> = sections
1772 .iter()
1773 .filter_map(|s| match s {
1774 Section::Metric {
1775 label, value_label, ..
1776 } => Some((label.clone(), value_label.clone())),
1777 _ => None,
1778 })
1779 .collect();
1780 assert_eq!(metrics.len(), 2, "two pools");
1781 assert!(
1782 metrics
1783 .iter()
1784 .any(|(l, v)| l == "Cursor Models" && v == "98%")
1785 );
1786 assert!(
1787 metrics
1788 .iter()
1789 .any(|(l, v)| l == "Other Models" && v == "100%")
1790 );
1791 assert!(sections.iter().any(|s| matches!(
1792 s,
1793 Section::Text { label, value } if label == "Resets" && value.contains("9d")
1794 )));
1795 }
1796
1797 #[test]
1798 fn cursor_unlimited_plan_shows_no_pool_bars() {
1799 let mut snap = cursor_snap();
1800 snap.unlimited = true;
1801 let sections = sections_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
1802 let metric_count = sections
1803 .iter()
1804 .filter(|s| matches!(s, Section::Metric { .. }))
1805 .count();
1806 assert_eq!(metric_count, 0);
1807 assert!(sections.iter().any(|s| matches!(
1808 s,
1809 Section::Text { value, .. } if value.contains("Unlimited")
1810 )));
1811 }
1812
1813 fn kiro_snap() -> crate::usage::KiroSnapshot {
1814 crate::usage::KiroSnapshot {
1815 plan: "KIRO POWER".into(),
1816 used: 9943.38,
1817 limit: 10000.0,
1818 reset_at: Some(now() + chrono::Duration::days(1)),
1819 }
1820 }
1821
1822 #[test]
1823 fn kiro_compact_cell_shows_the_credit_percentage() {
1824 let (plan, cells) = compact_cells(&VendorSnapshot::Kiro(kiro_snap()));
1825 assert_eq!(plan, "KIRO POWER");
1826 assert_eq!(
1827 cells,
1828 vec![("credits 99%".to_string(), PaceSeverity::Critical)]
1829 );
1830 }
1831
1832 #[test]
1833 fn kiro_headline_pct_is_the_credit_percentage() {
1834 assert_eq!(headline_pct(&VendorSnapshot::Kiro(kiro_snap())), Some(99));
1835 }
1836
1837 #[test]
1838 fn kiro_sections_show_the_credit_metric_and_reset() {
1839 let sections = sections_for(&ready(VendorSnapshot::Kiro(kiro_snap())), now(), 5);
1840 let metrics: Vec<_> = sections
1841 .iter()
1842 .filter_map(|s| match s {
1843 Section::Metric {
1844 label, value_label, ..
1845 } => Some((label.clone(), value_label.clone())),
1846 _ => None,
1847 })
1848 .collect();
1849 assert_eq!(metrics, vec![("Credits".to_string(), "99%".to_string())]);
1850 assert!(sections.iter().any(|s| matches!(
1851 s,
1852 Section::Text { label, value } if label == "Resets" && value.contains("1d")
1853 )));
1854 }
1855
1856 #[test]
1857 fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
1858 let snap = KimiSnapshot {
1859 plan: None,
1860 weekly_limit: 100,
1861 weekly_used: 10,
1862 weekly_remaining: 90,
1863 weekly_reset_at: None,
1864 window_limit: 0,
1865 window_used: 0,
1866 window_remaining: 0,
1867 window_reset_at: None,
1868 };
1869 let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
1870 let TabState::Ready(tab) = &mut schema else {
1871 unreachable!()
1872 };
1873 tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
1874 let schema_sections = sections_for(&schema, now(), 5);
1875 assert!(schema_sections.iter().any(|section| matches!(
1876 section,
1877 Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
1878 )));
1879
1880 let mut generic = ready(VendorSnapshot::Kimi(snap));
1881 let TabState::Ready(tab) = &mut generic else {
1882 unreachable!()
1883 };
1884 tab.last_error = Some((0, "cache lock unavailable".into()));
1885 let generic_sections = sections_for(&generic, now(), 5);
1886 assert!(generic_sections.iter().any(|section| matches!(
1887 section,
1888 Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
1889 )));
1890 assert!(!generic_sections.iter().any(|section| matches!(
1891 section,
1892 Section::Text { label, .. } if label.starts_with("HTTP")
1893 )));
1894
1895 let http = warning_label(
1896 &VendorSnapshot::Kimi(KimiSnapshot {
1897 plan: None,
1898 weekly_limit: 0,
1899 weekly_used: 0,
1900 weekly_remaining: 0,
1901 weekly_reset_at: None,
1902 window_limit: 0,
1903 window_used: 0,
1904 window_remaining: 0,
1905 window_reset_at: None,
1906 }),
1907 &Some((503, "service unavailable".into())),
1908 );
1909 assert_eq!(
1910 http,
1911 Some(("HTTP 503".into(), "service unavailable".into()))
1912 );
1913 }
1914}