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