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