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