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