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