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 };
86 let updated = match r.fetched_at {
90 Some(at) => format!("Updated {}", local_time_hms(at)),
91 None => "Updated —".to_string(),
92 };
93 if let Some(Section::Title { right, .. }) = sections.first_mut() {
94 *right = Some(updated);
95 }
96 if let Some((code, msg)) = last_error
98 && *code != 0
99 {
100 sections.push(Section::Spacer);
101 sections.push(Section::Text {
102 label: format!("HTTP {code}"),
103 value: msg.clone(),
104 });
105 }
106 sections
107 }
108 }
109}
110
111fn anthropic_sections(
112 s: &crate::usage::AnthropicSnapshot,
113 now: DateTime<Utc>,
114 tol: u32,
115) -> Vec<Section> {
116 let mut v = vec![Section::Title {
117 left: format!("Claude {}", s.plan),
118 right: None,
119 }];
120
121 push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
122 push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
123 if let Some(w) = &s.sonnet {
124 push_window(&mut v, "Sonnet only", w, now, tol, false);
125 }
126 for sw in &s.scoped {
127 push_window(
128 &mut v,
129 &format!("{} (7d)", sw.label),
130 &sw.window,
131 now,
132 tol,
133 false,
134 );
135 }
136 if let Some(e) = &s.extra {
137 v.push(Section::Spacer);
138 let pct = e.percent().clamp(0, 100) as u16;
139 v.push(Section::Metric {
140 label: "Extra usage".into(),
141 pct,
142 severity: severity_for(pct as i32),
143 value_label: format!("{} of {}", e.spent.fmt_dollars(), e.limit.fmt_dollars()),
144 footnote: format!("{}% of monthly limit consumed", pct),
145 });
146 }
147 v
148}
149
150fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
151 let mut v = vec![Section::Title {
152 left: s.plan.clone(),
153 right: None,
154 }];
155 push_window(&mut v, "Codex 5h", &s.session, now, tol, true);
156 push_window(&mut v, "Codex weekly", &s.weekly, now, tol, true);
157 if let Some(cr) = &s.code_review {
158 push_window(&mut v, "Code review", cr, now, tol, false);
159 }
160 if let Some(c) = &s.credits {
161 v.push(Section::Spacer);
162 let balance = if c.unlimited {
163 "unlimited".into()
164 } else {
165 c.balance.clone()
166 };
167 let mut body = vec![format!("balance: {}", balance)];
168 if let Some((lo, hi)) = c.approx_local_messages {
169 body.push(format!("≈ {lo}-{hi} local messages"));
170 }
171 if let Some((lo, hi)) = c.approx_cloud_messages {
172 body.push(format!("≈ {lo}-{hi} cloud messages"));
173 }
174 v.push(Section::Block {
175 label: "Credits".into(),
176 body,
177 });
178 }
179 v
180}
181
182fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
183 let mut v = vec![Section::Title {
184 left: s.plan.clone(),
185 right: None,
186 }];
187 if let Some(w) = &s.session {
188 push_window(&mut v, "Session (5h)", w, now, 5, false);
189 }
190 if let Some(w) = &s.weekly {
191 push_window(&mut v, "Weekly", w, now, 5, false);
192 }
193 if let Some(w) = &s.mcp {
194 push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
195 }
196 if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
197 v.push(Section::Spacer);
198 v.push(Section::Text {
199 label: "".into(),
200 value: " no usage windows reported".into(),
201 });
202 }
203 v
204}
205
206fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
207 let mut v = vec![Section::Title {
208 left: s.label.clone(),
209 right: None,
210 }];
211 let pct = s.consumed_pct().clamp(0, 100) as u16;
212 v.push(Section::Spacer);
213 v.push(Section::Metric {
214 label: "Credit balance".into(),
215 pct,
216 severity: severity_for(pct as i32),
217 value_label: format!("${:.2}", s.balance()),
218 footnote: format!(
219 "${:.2} of ${:.2} used ({pct}%)",
220 s.total_usage, s.total_credits
221 ),
222 });
223 v.push(Section::Spacer);
224 v.push(Section::Block {
225 label: "Usage by period".into(),
226 body: vec![format!(
227 "today ${:.2} · week ${:.2} · month ${:.2}",
228 s.usage_daily, s.usage_weekly, s.usage_monthly
229 )],
230 });
231 if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
232 v.push(Section::Spacer);
233 v.push(Section::Block {
234 label: "Per-key limit".into(),
235 body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
236 });
237 }
238 v.push(Section::Spacer);
239 v.push(Section::Block {
240 label: "Tier".into(),
241 body: vec![if s.is_free_tier {
242 "free tier".into()
243 } else {
244 "paid tier".into()
245 }],
246 });
247 v
248}
249
250fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
251 let currency = &s.currency;
252 let fmt = |v: f64| match currency.as_str() {
253 "USD" => format!("${v:.2}"),
254 "CNY" => format!("¥{v:.2}"),
255 _ => format!("{v:.2} {currency}"),
256 };
257 let avail = if s.is_available {
258 "available"
259 } else {
260 "unavailable"
261 };
262 let mut v = vec![Section::Title {
263 left: "DeepSeek".into(),
264 right: None,
265 }];
266 v.push(Section::Spacer);
267 v.push(Section::Text {
268 label: "Balance".into(),
269 value: fmt(s.balance),
270 });
271 v.push(Section::Block {
272 label: "Breakdown".into(),
273 body: vec![format!(
274 "granted {} · topped-up {}",
275 fmt(s.granted),
276 fmt(s.topped_up)
277 )],
278 });
279 v.push(Section::Spacer);
280 v.push(Section::Block {
281 label: "API".into(),
282 body: vec![avail.into()],
283 });
284 v
285}
286
287fn push_window(
288 sections: &mut Vec<Section>,
289 label: &str,
290 w: &crate::usage::UsageWindow,
291 now: DateTime<Utc>,
292 tol: u32,
293 show_pacing: bool,
294) {
295 let pct = w.utilization_pct.clamp(0, 100) as u16;
296 let reset_text = countdown::format(w.resets_at, now);
297 let footnote = if show_pacing {
298 let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
299 format!(
300 "Resets in {} · {}% elapsed · {}",
301 reset_text, p.elapsed_pct, p.point_label
302 )
303 } else {
304 format!("Resets in {}", reset_text)
305 };
306 sections.push(Section::Spacer);
307 sections.push(Section::Metric {
308 label: label.into(),
309 pct,
310 severity: severity_for(pct as i32),
311 value_label: format!("{pct}%"),
312 footnote,
313 });
314}
315
316pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
324 if sections.is_empty() {
325 return;
326 }
327 let bubble = bubble_theme(theme);
328 let pin_last =
331 matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
332
333 let body_end = if pin_last {
334 sections.len() - 1
335 } else {
336 sections.len()
337 };
338 let mut constraints: Vec<Constraint> =
339 sections[..body_end].iter().map(section_height).collect();
340
341 if pin_last {
342 constraints.push(Constraint::Min(0)); constraints.push(section_height(sections.last().unwrap()));
344 } else {
345 constraints.push(Constraint::Min(0));
346 }
347
348 let chunks = Layout::default()
349 .direction(ratatui::layout::Direction::Vertical)
350 .constraints(constraints)
351 .split(area);
352
353 for (i, s) in sections[..body_end].iter().enumerate() {
354 render_section(f, chunks[i], theme, &bubble, s);
355 }
356 if pin_last {
357 render_section(
358 f,
359 chunks[chunks.len() - 1],
360 theme,
361 &bubble,
362 sections.last().unwrap(),
363 );
364 }
365}
366
367fn section_height(s: &Section) -> Constraint {
368 match s {
369 Section::Title { .. } => Constraint::Length(2),
370 Section::Metric { .. } => Constraint::Length(3),
371 Section::Text { .. } => Constraint::Length(1),
372 Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
373 Section::Spacer => Constraint::Length(1),
374 }
375}
376
377fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
378 match s {
379 Section::Title { left, right } => {
380 let left_line = Line::from(Span::styled(
383 format!(" {} {left}", bubble.symbols.selected),
384 bubble.title,
385 ));
386 f.render_widget(Paragraph::new(left_line), area);
387 if let Some(rt) = right {
388 let right_line =
389 Line::from(Span::styled(format!("{rt} "), bubble.muted)).right_aligned();
390 f.render_widget(Paragraph::new(right_line), area);
391 }
392 }
393 Section::Metric {
394 label,
395 pct,
396 severity,
397 value_label,
398 footnote,
399 } => render_metric(
400 f,
401 area,
402 theme,
403 bubble,
404 label,
405 *pct,
406 *severity,
407 value_label,
408 footnote,
409 ),
410 Section::Text { label, value } => {
411 if label.is_empty() && value.contains("Loading") {
412 render_loading(f, area, bubble);
413 return;
414 }
415 if label == "Error" {
416 let line = Line::from(vec![
417 bubble.error(format!(" {} ", bubble.symbols.cross)),
418 Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
419 ]);
420 f.render_widget(Paragraph::new(line), area);
421 return;
422 }
423 let mut spans = Vec::new();
424 if !label.is_empty() {
425 spans.push(Span::styled(
426 format!(" {label} "),
427 bubble.text.add_modifier(Modifier::BOLD),
428 ));
429 }
430 spans.push(Span::styled(value.clone(), bubble.muted));
431 f.render_widget(Paragraph::new(Line::from(spans)), area);
432 }
433 Section::Block { label, body } => render_block(f, area, bubble, label, body),
434 Section::Spacer => {}
435 }
436}
437
438fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
439 let frames = SpinnerFrames::DOTS;
440 let frame_count = frames.frames().len().max(1);
441 let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
442 let mut spinner = Spinner::new()
443 .frames(frames)
444 .label("Fetching usage data")
445 .theme(*bubble);
446 for _ in 0..(frame % frame_count) {
447 spinner.tick();
448 }
449 f.render_widget(&spinner, area);
450}
451
452#[allow(clippy::too_many_arguments)]
453fn render_metric(
454 f: &mut Frame,
455 area: Rect,
456 theme: &Theme,
457 bubble: &BubbleTheme,
458 label: &str,
459 pct: u16,
460 severity: PaceSeverity,
461 value_label: &str,
462 footnote: &str,
463) {
464 let bar_color = severity_color(theme, bubble, severity);
465 let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
466
467 let inner = Layout::default()
468 .direction(ratatui::layout::Direction::Vertical)
469 .constraints([
470 Constraint::Length(1),
471 Constraint::Length(1),
472 Constraint::Length(1),
473 ])
474 .split(area);
475
476 let label_line = Line::from(Span::styled(
478 format!(" {label}"),
479 bubble.text.add_modifier(Modifier::BOLD),
480 ));
481 f.render_widget(Paragraph::new(label_line), inner[0]);
482
483 let row = inner[1];
485 let value_w = value_label.chars().count() as u16 + 2;
486 let gauge_area = Rect {
487 x: row.x + 2,
488 y: row.y,
489 width: row.width.saturating_sub(value_w + 4),
490 height: 1,
491 };
492 let value_area = Rect {
493 x: gauge_area.x + gauge_area.width + 1,
494 y: row.y,
495 width: value_w,
496 height: 1,
497 };
498 let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
499 let progress = Progress::from_percent(pct)
500 .theme(progress_theme)
501 .show_percentage(false);
502 f.render_widget(&progress, gauge_area);
503 let value = Paragraph::new(Line::from(Span::styled(
504 value_label.to_string(),
505 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
506 )));
507 f.render_widget(value, value_area);
508
509 let foot = Line::from(Span::styled(format!(" {footnote}"), bubble.muted));
511 f.render_widget(Paragraph::new(foot), inner[2]);
512}
513
514fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
515 let mut lines = vec![Line::from(Span::styled(
516 format!(" {label}"),
517 bubble.text.add_modifier(Modifier::BOLD),
518 ))];
519 for b in body {
520 lines.push(Line::from(Span::styled(format!(" {b}"), bubble.muted)));
521 }
522 f.render_widget(Paragraph::new(lines), area);
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::usage::{
529 AnthropicSnapshot, Cents, ExtraUsage, OpenAiCredits, OpenAiSnapshot, OpenAiSource,
530 OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
531 };
532 use chrono::TimeZone;
533
534 fn now() -> DateTime<Utc> {
535 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
536 }
537
538 fn ready(snapshot: VendorSnapshot) -> TabState {
539 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
540 snapshot,
541 stale: false,
542 last_error: None,
543 fetched_at: Some(now() - chrono::Duration::seconds(15)),
544 }))
545 }
546
547 #[test]
548 fn anthropic_sections_include_all_three_windows_when_present() {
549 let snap = AnthropicSnapshot {
550 plan: "Max 20x".into(),
551 session: UsageWindow {
552 utilization_pct: 60,
553 resets_at: Some(now() + chrono::Duration::hours(1)),
554 window_duration: chrono::Duration::hours(5),
555 },
556 weekly: UsageWindow {
557 utilization_pct: 30,
558 resets_at: Some(now() + chrono::Duration::days(3)),
559 window_duration: chrono::Duration::days(7),
560 },
561 sonnet: Some(UsageWindow {
562 utilization_pct: 5,
563 resets_at: Some(now() + chrono::Duration::hours(2)),
564 window_duration: chrono::Duration::days(7),
565 }),
566 scoped: vec![],
567 extra: Some(ExtraUsage {
568 limit: Cents(5000),
569 spent: Cents(250),
570 }),
571 };
572 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
573 assert_eq!(sections.len(), 9);
576 assert!(matches!(sections[0], Section::Title { .. }));
577 if let Section::Title { right, .. } = §ions[0] {
579 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
580 } else {
581 panic!("expected first section to be Title");
582 }
583 let metric_count = sections
584 .iter()
585 .filter(|s| matches!(s, Section::Metric { .. }))
586 .count();
587 assert_eq!(metric_count, 4);
588 }
589
590 #[test]
591 fn anthropic_omits_sonnet_and_extra_when_absent() {
592 let snap = AnthropicSnapshot {
593 plan: "Pro".into(),
594 session: UsageWindow {
595 utilization_pct: 10,
596 resets_at: None,
597 window_duration: chrono::Duration::hours(5),
598 },
599 weekly: UsageWindow {
600 utilization_pct: 5,
601 resets_at: None,
602 window_duration: chrono::Duration::days(7),
603 },
604 sonnet: None,
605 scoped: vec![],
606 extra: None,
607 };
608 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
609 let metric_count = sections
610 .iter()
611 .filter(|s| matches!(s, Section::Metric { .. }))
612 .count();
613 assert_eq!(metric_count, 2);
614 }
615
616 #[test]
617 fn openrouter_always_has_balance_metric_and_period_block() {
618 let snap = OpenRouterSnapshot {
619 label: "OR".into(),
620 total_credits: 100.0,
621 total_usage: 25.0,
622 usage_daily: 1.0,
623 usage_weekly: 5.0,
624 usage_monthly: 25.0,
625 is_free_tier: false,
626 limit: None,
627 limit_remaining: None,
628 };
629 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
630 assert!(matches!(sections[0], Section::Title { .. }));
631 assert!(
632 sections
633 .iter()
634 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
635 );
636 assert!(
637 sections
638 .iter()
639 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
640 );
641 }
642
643 #[test]
644 fn zai_no_windows_renders_message() {
645 let snap = ZaiSnapshot {
646 plan: "GLM".into(),
647 session: None,
648 weekly: None,
649 mcp: None,
650 };
651 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
652 assert!(sections.iter().any(|s| matches!(
653 s,
654 Section::Text { value, .. } if value.contains("no usage windows reported")
655 )));
656 }
657
658 #[test]
659 fn loading_state_yields_loading_section() {
660 let sections = sections_for(&TabState::Loading, now(), 5);
661 assert!(sections.iter().any(|s| matches!(
662 s,
663 Section::Text { value, .. } if value.contains("Loading")
664 )));
665 }
666
667 #[test]
668 fn error_state_includes_retry_hint() {
669 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
670 assert!(sections.iter().any(|s| matches!(
671 s,
672 Section::Text { value, .. } if value.contains("token expired")
673 )));
674 assert!(sections.iter().any(|s| matches!(
675 s,
676 Section::Text { value, .. } if value.contains("`r` to retry")
677 )));
678 }
679
680 #[test]
681 fn openai_with_credits_renders_block() {
682 let snap = OpenAiSnapshot {
683 plan: "ChatGPT Plus".into(),
684 session: UsageWindow {
685 utilization_pct: 1,
686 resets_at: None,
687 window_duration: chrono::Duration::hours(5),
688 },
689 weekly: UsageWindow {
690 utilization_pct: 0,
691 resets_at: None,
692 window_duration: chrono::Duration::days(7),
693 },
694 code_review: None,
695 credits: Some(OpenAiCredits {
696 balance: "$5.00".into(),
697 has_credits: true,
698 unlimited: false,
699 approx_local_messages: Some((100, 200)),
700 approx_cloud_messages: Some((30, 50)),
701 }),
702 source: OpenAiSource::CodexOauth,
703 };
704 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
705 assert!(
706 sections
707 .iter()
708 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
709 );
710 }
711}