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 sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
56 match tab {
57 TabState::Loading => vec![
58 Section::Spacer,
59 Section::Text {
60 label: "".into(),
61 value: " Loading…".into(),
62 },
63 ],
64 TabState::Error(e) => vec![
65 Section::Spacer,
66 Section::Text {
67 label: "Error".into(),
68 value: e.clone(),
69 },
70 Section::Spacer,
71 Section::Text {
72 label: "".into(),
73 value: "Press `r` to retry, `q` to quit.".into(),
74 },
75 ],
76 TabState::Ready(r) => {
77 let snapshot = &r.snapshot;
78 let last_error = &r.last_error;
79 let mut sections = match snapshot {
80 VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
81 VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
82 VendorSnapshot::Zai(s) => zai_sections(s, now),
83 VendorSnapshot::Openrouter(s) => openrouter_sections(s),
84 VendorSnapshot::Deepseek(s) => deepseek_sections(s),
85 VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
86 };
87 let updated = match r.fetched_at {
91 Some(at) => format!("Updated {}", local_time_hms(at)),
92 None => "Updated —".to_string(),
93 };
94 if let Some(Section::Title { right, .. }) = sections.first_mut() {
95 *right = Some(updated);
96 }
97 if let Some((label, msg)) = warning_label(snapshot, last_error) {
99 sections.push(Section::Spacer);
100 sections.push(Section::Text { label, value: msg });
101 }
102 sections
103 }
104 }
105}
106
107fn warning_label(
111 snapshot: &VendorSnapshot,
112 last_error: &Option<(u16, String)>,
113) -> Option<(String, String)> {
114 let (code, message) = last_error.as_ref()?;
115 if *code != 0 {
116 return Some((format!("HTTP {code}"), message.clone()));
117 }
118 if message.is_empty() {
119 return None;
120 }
121 let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
122 && matches!(
123 crate::kimi::vendor::warning_kind(*code, message),
124 crate::kimi::vendor::WarningKind::SchemaDrift
125 ) {
126 "Kimi API schema drift"
127 } else {
128 "Warning"
129 };
130 let value = if label == message {
133 String::new()
134 } else {
135 message.clone()
136 };
137 Some((label.into(), value))
138}
139
140fn anthropic_sections(
141 s: &crate::usage::AnthropicSnapshot,
142 now: DateTime<Utc>,
143 tol: u32,
144) -> Vec<Section> {
145 let mut v = vec![Section::Title {
146 left: format!("Claude {}", s.plan),
147 right: None,
148 }];
149
150 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
151 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
152 if let Some(w) = &s.sonnet {
153 push_window(&mut v, "Sonnet only", w, now, tol, false);
154 }
155 for sw in &s.scoped {
156 push_window(
157 &mut v,
158 &format!("{} (7d)", sw.label),
159 &sw.window,
160 now,
161 tol,
162 false,
163 );
164 }
165 if let Some(e) = &s.extra {
166 v.push(Section::Spacer);
167 let pct = e.percent().clamp(0, 100) as u16;
168 v.push(Section::Metric {
169 label: "Extra usage".into(),
170 pct,
171 severity: severity_for(pct as i32),
172 value_label: format!("{} of {}", e.spent.fmt_dollars(), e.limit.fmt_dollars()),
173 footnote: format!("{}% of monthly limit consumed", pct),
174 });
175 }
176 v
177}
178
179fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
180 let mut v = vec![Section::Title {
181 left: s.plan.clone(),
182 right: None,
183 }];
184 push_window(&mut v, "Codex 5h", &s.session, now, tol, true);
185 push_window(&mut v, "Codex weekly", &s.weekly, now, tol, true);
186 if let Some(cr) = &s.code_review {
187 push_window(&mut v, "Code review", cr, now, tol, false);
188 }
189 if let Some(c) = &s.credits {
190 v.push(Section::Spacer);
191 let balance = if c.unlimited {
192 "unlimited".into()
193 } else {
194 c.balance.clone()
195 };
196 let mut body = vec![format!("balance: {}", balance)];
197 if let Some((lo, hi)) = c.approx_local_messages {
198 body.push(format!("≈ {lo}-{hi} local messages"));
199 }
200 if let Some((lo, hi)) = c.approx_cloud_messages {
201 body.push(format!("≈ {lo}-{hi} cloud messages"));
202 }
203 v.push(Section::Block {
204 label: "Credits".into(),
205 body,
206 });
207 }
208 v
209}
210
211fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
212 let mut v = vec![Section::Title {
213 left: s.plan.clone(),
214 right: None,
215 }];
216 if let Some(w) = &s.session {
217 push_window(&mut v, "Session (5h)", w, now, 5, false);
218 }
219 if let Some(w) = &s.weekly {
220 push_window(&mut v, "Weekly", w, now, 5, false);
221 }
222 if let Some(w) = &s.mcp {
223 push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
224 }
225 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
226 v.push(Section::Spacer);
227 v.push(Section::Text {
228 label: "".into(),
229 value: " no usage windows reported".into(),
230 });
231 }
232 v
233}
234
235fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
236 let mut v = vec![Section::Title {
237 left: s.label.clone(),
238 right: None,
239 }];
240 let pct = s.consumed_pct().clamp(0, 100) as u16;
241 v.push(Section::Spacer);
242 v.push(Section::Metric {
243 label: "Credit balance".into(),
244 pct,
245 severity: severity_for(pct as i32),
246 value_label: format!("${:.2}", s.balance()),
247 footnote: format!(
248 "${:.2} of ${:.2} used ({pct}%)",
249 s.total_usage, s.total_credits
250 ),
251 });
252 v.push(Section::Spacer);
253 v.push(Section::Block {
254 label: "Usage by period".into(),
255 body: vec![format!(
256 "today ${:.2} · week ${:.2} · month ${:.2}",
257 s.usage_daily, s.usage_weekly, s.usage_monthly
258 )],
259 });
260 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
261 v.push(Section::Spacer);
262 v.push(Section::Block {
263 label: "Per-key limit".into(),
264 body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
265 });
266 }
267 v.push(Section::Spacer);
268 v.push(Section::Block {
269 label: "Tier".into(),
270 body: vec![if s.is_free_tier {
271 "free tier".into()
272 } else {
273 "paid tier".into()
274 }],
275 });
276 v
277}
278
279fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
280 let currency = &s.currency;
281 let fmt = |v: f64| match currency.as_str() {
282 "USD" => format!("${v:.2}"),
283 "CNY" => format!("¥{v:.2}"),
284 _ => format!("{v:.2} {currency}"),
285 };
286 let avail = if s.is_available {
287 "available"
288 } else {
289 "unavailable"
290 };
291 let mut v = vec![Section::Title {
292 left: "DeepSeek".into(),
293 right: None,
294 }];
295 v.push(Section::Spacer);
296 v.push(Section::Text {
297 label: "Balance".into(),
298 value: fmt(s.balance),
299 });
300 v.push(Section::Block {
301 label: "Breakdown".into(),
302 body: vec![format!(
303 "granted {} · topped-up {}",
304 fmt(s.granted),
305 fmt(s.topped_up)
306 )],
307 });
308 v.push(Section::Spacer);
309 v.push(Section::Block {
310 label: "API".into(),
311 body: vec![avail.into()],
312 });
313 v
314}
315
316fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> Vec<Section> {
317 let plan = s.plan.as_deref().unwrap_or("Kimi");
318 let mut v = vec![Section::Title {
319 left: plan.into(),
320 right: None,
321 }];
322
323 let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
324 v.push(Section::Spacer);
325 v.push(Section::Metric {
326 label: "Weekly quota".into(),
327 pct: weekly_pct,
328 severity: severity_for(s.weekly_pct()),
329 value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
330 footnote: format!(
331 "{} remaining · reset {}",
332 s.weekly_remaining,
333 countdown::format(s.weekly_reset_at, now)
334 ),
335 });
336
337 if s.window_limit > 0 {
338 let window_pct = s.window_pct().clamp(0, 100) as u16;
339 v.push(Section::Spacer);
340 v.push(Section::Metric {
341 label: "Rolling window (5h)".into(),
342 pct: window_pct,
343 severity: severity_for(s.window_pct()),
344 value_label: format!("{} / {}", s.window_used, s.window_limit),
345 footnote: format!(
346 "{} remaining · reset {}",
347 s.window_remaining,
348 countdown::format(s.window_reset_at, now)
349 ),
350 });
351 }
352
353 v
354}
355
356fn push_window(
357 sections: &mut Vec<Section>,
358 label: &str,
359 w: &crate::usage::UsageWindow,
360 now: DateTime<Utc>,
361 tol: u32,
362 show_pacing: bool,
363) {
364 let pct = w.utilization_pct.clamp(0, 100) as u16;
365 let reset_text = countdown::format(w.resets_at, now);
366 let footnote = if show_pacing {
367 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
368 format!(
369 "Resets in {} · {}% elapsed · {}",
370 reset_text, p.elapsed_pct, p.point_label
371 )
372 } else {
373 format!("Resets in {}", reset_text)
374 };
375 sections.push(Section::Spacer);
376 sections.push(Section::Metric {
377 label: label.into(),
378 pct,
379 severity: severity_for(pct as i32),
380 value_label: format!("{pct}%"),
381 footnote,
382 });
383}
384
385pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
393 if sections.is_empty() {
394 return;
395 }
396 let bubble = bubble_theme(theme);
397 let pin_last =
400 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
401
402 let body_end = if pin_last {
403 sections.len() - 1
404 } else {
405 sections.len()
406 };
407 let mut constraints: Vec<Constraint> =
408 sections[..body_end].iter().map(section_height).collect();
409
410 if pin_last {
411 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
413 } else {
414 constraints.push(Constraint::Min(0));
415 }
416
417 let chunks = Layout::default()
418 .direction(ratatui::layout::Direction::Vertical)
419 .constraints(constraints)
420 .split(area);
421
422 for (i, s) in sections[..body_end].iter().enumerate() {
423 render_section(f, chunks[i], theme, &bubble, s);
424 }
425 if pin_last {
426 render_section(
427 f,
428 chunks[chunks.len() - 1],
429 theme,
430 &bubble,
431 sections.last().unwrap(),
432 );
433 }
434}
435
436fn section_height(s: &Section) -> Constraint {
437 match s {
438 Section::Title { .. } => Constraint::Length(2),
439 Section::Metric { .. } => Constraint::Length(3),
440 Section::Text { .. } => Constraint::Length(1),
441 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
442 Section::Spacer => Constraint::Length(1),
443 }
444}
445
446fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
447 match s {
448 Section::Title { left, right } => {
449 let left_line = Line::from(Span::styled(
452 format!(" {} {left}", bubble.symbols.selected),
453 bubble.title,
454 ));
455 f.render_widget(Paragraph::new(left_line), area);
456 if let Some(rt) = right {
457 let right_line =
458 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
459 f.render_widget(Paragraph::new(right_line), area);
460 }
461 }
462 Section::Metric {
463 label,
464 pct,
465 severity,
466 value_label,
467 footnote,
468 } => render_metric(
469 f,
470 area,
471 theme,
472 bubble,
473 label,
474 *pct,
475 *severity,
476 value_label,
477 footnote,
478 ),
479 Section::Text { label, value } => {
480 if label.is_empty() && value.contains("Loading") {
481 render_loading(f, area, bubble);
482 return;
483 }
484 if label == "Error" {
485 let line = Line::from(vec![
486 bubble.error(format!(" {} ", bubble.symbols.cross)),
487 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
488 ]);
489 f.render_widget(Paragraph::new(line), area);
490 return;
491 }
492 let mut spans = Vec::new();
493 if !label.is_empty() {
494 spans.push(Span::styled(
495 format!(" {label} "),
496 bubble.text.add_modifier(Modifier::BOLD),
497 ));
498 }
499 spans.push(Span::styled(value.clone(), bubble.muted));
500 f.render_widget(Paragraph::new(Line::from(spans)), area);
501 }
502 Section::Block { label, body } => render_block(f, area, bubble, label, body),
503 Section::Spacer => {}
504 }
505}
506
507fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
508 let frames = SpinnerFrames::DOTS;
509 let frame_count = frames.frames().len().max(1);
510 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
511 let mut spinner = Spinner::new()
512 .frames(frames)
513 .label("Fetching usage data")
514 .theme(*bubble);
515 for _ in 0..(frame % frame_count) {
516 spinner.tick();
517 }
518 f.render_widget(&spinner, area);
519}
520
521#[allow(clippy::too_many_arguments)]
522fn render_metric(
523 f: &mut Frame,
524 area: Rect,
525 theme: &Theme,
526 bubble: &BubbleTheme,
527 label: &str,
528 pct: u16,
529 severity: PaceSeverity,
530 value_label: &str,
531 footnote: &str,
532) {
533 let bar_color = severity_color(theme, bubble, severity);
534 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
535
536 let inner = Layout::default()
537 .direction(ratatui::layout::Direction::Vertical)
538 .constraints([
539 Constraint::Length(1),
540 Constraint::Length(1),
541 Constraint::Length(1),
542 ])
543 .split(area);
544
545 let label_line = Line::from(Span::styled(
547 format!(" {label}"),
548 bubble.text.add_modifier(Modifier::BOLD),
549 ));
550 f.render_widget(Paragraph::new(label_line), inner[0]);
551
552 let row = inner[1];
554 let value_w = value_label.chars().count() as u16 + 2;
555 let gauge_area = Rect {
556 x: row.x + 2,
557 y: row.y,
558 width: row.width.saturating_sub(value_w + 4),
559 height: 1,
560 };
561 let value_area = Rect {
562 x: gauge_area.x + gauge_area.width + 1,
563 y: row.y,
564 width: value_w,
565 height: 1,
566 };
567 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
568 let progress = Progress::from_percent(pct)
569 .theme(progress_theme)
570 .show_percentage(false);
571 f.render_widget(&progress, gauge_area);
572 let value = Paragraph::new(Line::from(Span::styled(
573 value_label.to_string(),
574 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
575 )));
576 f.render_widget(value, value_area);
577
578 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
580 f.render_widget(Paragraph::new(foot), inner[2]);
581}
582
583fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
584 let mut lines = vec![Line::from(Span::styled(
585 format!(" {label}"),
586 bubble.text.add_modifier(Modifier::BOLD),
587 ))];
588 for b in body {
589 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
590 }
591 f.render_widget(Paragraph::new(lines), area);
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::usage::{
598 AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
599 OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
600 };
601 use chrono::TimeZone;
602
603 fn now() -> DateTime<Utc> {
604 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
605 }
606
607 fn ready(snapshot: VendorSnapshot) -> TabState {
608 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
609 snapshot,
610 stale: false,
611 last_error: None,
612 fetched_at: Some(now() - chrono::Duration::seconds(15)),
613 }))
614 }
615
616 #[test]
617 fn anthropic_sections_include_all_three_windows_when_present() {
618 let snap = AnthropicSnapshot {
619 plan: "Max 20x".into(),
620 session: UsageWindow {
621 utilization_pct: 60,
622 resets_at: Some(now() + chrono::Duration::hours(1)),
623 window_duration: chrono::Duration::hours(5),
624 },
625 weekly: UsageWindow {
626 utilization_pct: 30,
627 resets_at: Some(now() + chrono::Duration::days(3)),
628 window_duration: chrono::Duration::days(7),
629 },
630 sonnet: Some(UsageWindow {
631 utilization_pct: 5,
632 resets_at: Some(now() + chrono::Duration::hours(2)),
633 window_duration: chrono::Duration::days(7),
634 }),
635 scoped: vec![],
636 extra: Some(ExtraUsage {
637 limit: Cents(5000),
638 spent: Cents(250),
639 }),
640 };
641 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
642 assert_eq!(sections.len(), 9);
645 assert!(matches!(sections[0], Section::Title { .. }));
646 if let Section::Title { right, .. } = §ions[0] {
648 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
649 } else {
650 panic!("expected first section to be Title");
651 }
652 let metric_count = sections
653 .iter()
654 .filter(|s| matches!(s, Section::Metric { .. }))
655 .count();
656 assert_eq!(metric_count, 4);
657 }
658
659 #[test]
660 fn anthropic_omits_sonnet_and_extra_when_absent() {
661 let snap = AnthropicSnapshot {
662 plan: "Pro".into(),
663 session: UsageWindow {
664 utilization_pct: 10,
665 resets_at: None,
666 window_duration: chrono::Duration::hours(5),
667 },
668 weekly: UsageWindow {
669 utilization_pct: 5,
670 resets_at: None,
671 window_duration: chrono::Duration::days(7),
672 },
673 sonnet: None,
674 scoped: vec![],
675 extra: None,
676 };
677 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
678 let metric_count = sections
679 .iter()
680 .filter(|s| matches!(s, Section::Metric { .. }))
681 .count();
682 assert_eq!(metric_count, 2);
683 }
684
685 #[test]
686 fn openrouter_always_has_balance_metric_and_period_block() {
687 let snap = OpenRouterSnapshot {
688 label: "OR".into(),
689 total_credits: 100.0,
690 total_usage: 25.0,
691 usage_daily: 1.0,
692 usage_weekly: 5.0,
693 usage_monthly: 25.0,
694 is_free_tier: false,
695 limit: None,
696 limit_remaining: None,
697 };
698 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
699 assert!(matches!(sections[0], Section::Title { .. }));
700 assert!(
701 sections
702 .iter()
703 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
704 );
705 assert!(
706 sections
707 .iter()
708 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
709 );
710 }
711
712 #[test]
713 fn zai_no_windows_renders_message() {
714 let snap = ZaiSnapshot {
715 plan: "GLM".into(),
716 session: None,
717 weekly: None,
718 mcp: None,
719 };
720 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
721 assert!(sections.iter().any(|s| matches!(
722 s,
723 Section::Text { value, .. } if value.contains("no usage windows reported")
724 )));
725 }
726
727 #[test]
728 fn loading_state_yields_loading_section() {
729 let sections = sections_for(&TabState::Loading, now(), 5);
730 assert!(sections.iter().any(|s| matches!(
731 s,
732 Section::Text { value, .. } if value.contains("Loading")
733 )));
734 }
735
736 #[test]
737 fn error_state_includes_retry_hint() {
738 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
739 assert!(sections.iter().any(|s| matches!(
740 s,
741 Section::Text { value, .. } if value.contains("token expired")
742 )));
743 assert!(sections.iter().any(|s| matches!(
744 s,
745 Section::Text { value, .. } if value.contains("`r` to retry")
746 )));
747 }
748
749 #[test]
750 fn openai_with_credits_renders_block() {
751 let snap = OpenAiSnapshot {
752 plan: "ChatGPT Plus".into(),
753 session: UsageWindow {
754 utilization_pct: 1,
755 resets_at: None,
756 window_duration: chrono::Duration::hours(5),
757 },
758 weekly: UsageWindow {
759 utilization_pct: 0,
760 resets_at: None,
761 window_duration: chrono::Duration::days(7),
762 },
763 code_review: None,
764 credits: Some(OpenAiCredits {
765 balance: "$5.00".into(),
766 has_credits: true,
767 unlimited: false,
768 approx_local_messages: Some((100, 200)),
769 approx_cloud_messages: Some((30, 50)),
770 }),
771 source: OpenAiSource::CodexOauth,
772 };
773 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
774 assert!(
775 sections
776 .iter()
777 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
778 );
779 }
780
781 #[test]
782 fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
783 let now = now();
784 let snap = KimiSnapshot {
785 plan: Some("LEVEL_INTERMEDIATE".into()),
786 weekly_limit: 100,
787 weekly_used: 26,
788 weekly_remaining: 74,
789 weekly_reset_at: Some(now + chrono::Duration::days(4)),
790 window_limit: 100,
791 window_used: 15,
792 window_remaining: 85,
793 window_reset_at: Some(now + chrono::Duration::hours(2)),
794 };
795 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
796 let metrics: Vec<_> = sections
797 .iter()
798 .filter(|s| matches!(s, Section::Metric { .. }))
799 .collect();
800 assert_eq!(metrics.len(), 2);
801 assert!(sections.iter().any(|s| matches!(
802 s,
803 Section::Metric { label, .. } if label == "Weekly quota"
804 )));
805 assert!(sections.iter().any(|s| matches!(
806 s,
807 Section::Metric { label, .. } if label == "Rolling window (5h)"
808 )));
809
810 let find_footnote = |label: &str| -> (String, String) {
811 sections
812 .iter()
813 .find_map(|s| match s {
814 Section::Metric {
815 label: l,
816 value_label,
817 footnote,
818 ..
819 } if l == label => Some((value_label.clone(), footnote.clone())),
820 _ => None,
821 })
822 .unwrap_or_else(|| panic!("missing metric {label}"))
823 };
824
825 let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
826 assert_eq!(weekly_value, "26 / 100");
827 assert!(weekly_footnote.contains("74 remaining"));
828 assert!(
829 weekly_footnote.contains("4d 0h"),
830 "weekly reset countdown: {weekly_footnote}"
831 );
832 assert!(!weekly_footnote.contains("2026-05-27T")); let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
835 assert_eq!(window_value, "15 / 100");
836 assert!(window_footnote.contains("85 remaining"));
837 assert!(
838 window_footnote.contains("2h 00m"),
839 "window reset countdown: {window_footnote}"
840 );
841 assert!(!window_footnote.contains("2026-05-23T14")); }
843
844 #[test]
845 fn kimi_sections_omit_window_when_limit_zero() {
846 let snap = KimiSnapshot {
847 plan: None,
848 weekly_limit: 100,
849 weekly_used: 10,
850 weekly_remaining: 90,
851 weekly_reset_at: None,
852 window_limit: 0,
853 window_used: 0,
854 window_remaining: 0,
855 window_reset_at: None,
856 };
857 let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
858 let metric_count = sections
859 .iter()
860 .filter(|s| matches!(s, Section::Metric { .. }))
861 .count();
862 assert_eq!(metric_count, 1);
863 }
864
865 #[test]
866 fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
867 let snap = KimiSnapshot {
868 plan: None,
869 weekly_limit: 100,
870 weekly_used: 10,
871 weekly_remaining: 90,
872 weekly_reset_at: None,
873 window_limit: 0,
874 window_used: 0,
875 window_remaining: 0,
876 window_reset_at: None,
877 };
878 let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
879 let TabState::Ready(tab) = &mut schema else {
880 unreachable!()
881 };
882 tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
883 let schema_sections = sections_for(&schema, now(), 5);
884 assert!(schema_sections.iter().any(|section| matches!(
885 section,
886 Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
887 )));
888
889 let mut generic = ready(VendorSnapshot::Kimi(snap));
890 let TabState::Ready(tab) = &mut generic else {
891 unreachable!()
892 };
893 tab.last_error = Some((0, "cache lock unavailable".into()));
894 let generic_sections = sections_for(&generic, now(), 5);
895 assert!(generic_sections.iter().any(|section| matches!(
896 section,
897 Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
898 )));
899 assert!(!generic_sections.iter().any(|section| matches!(
900 section,
901 Section::Text { label, .. } if label.starts_with("HTTP")
902 )));
903
904 let http = warning_label(
905 &VendorSnapshot::Kimi(KimiSnapshot {
906 plan: None,
907 weekly_limit: 0,
908 weekly_used: 0,
909 weekly_remaining: 0,
910 weekly_reset_at: None,
911 window_limit: 0,
912 window_used: 0,
913 window_remaining: 0,
914 window_reset_at: None,
915 }),
916 &Some((503, "service unavailable".into())),
917 );
918 assert_eq!(
919 http,
920 Some(("HTTP 503".into(), "service unavailable".into()))
921 );
922 }
923}