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