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