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 =
373 Line::from(Span::styled(format!("{rt} "), Style::default().fg(dim)))
374 .right_aligned();
375 f.render_widget(Paragraph::new(right_line), area);
376 }
377 }
378 Section::Metric {
379 label,
380 pct,
381 severity,
382 value_label,
383 footnote,
384 } => render_metric(
385 f,
386 area,
387 theme,
388 label,
389 *pct,
390 *severity,
391 value_label,
392 footnote,
393 ),
394 Section::Text { label, value } => {
395 let mut spans = Vec::new();
396 if !label.is_empty() {
397 spans.push(Span::styled(
398 format!(" {label} "),
399 Style::default().fg(fg).add_modifier(Modifier::BOLD),
400 ));
401 }
402 spans.push(Span::styled(value.clone(), Style::default().fg(dim)));
403 f.render_widget(Paragraph::new(Line::from(spans)), area);
404 }
405 Section::Block { label, body } => render_block(f, area, theme, label, body),
406 Section::Spacer => {}
407 }
408}
409
410#[allow(clippy::too_many_arguments)]
411fn render_metric(
412 f: &mut Frame,
413 area: Rect,
414 theme: &Theme,
415 label: &str,
416 pct: u16,
417 severity: PaceSeverity,
418 value_label: &str,
419 footnote: &str,
420) {
421 let fg = parse_hex(&theme.fg).unwrap_or(Color::White);
422 let dim = parse_hex(&theme.dim).unwrap_or(Color::DarkGray);
423 let bar_color = match severity {
424 PaceSeverity::Low => parse_hex(&theme.green),
425 PaceSeverity::Mid => parse_hex(&theme.yellow),
426 PaceSeverity::High => parse_hex(&theme.orange),
427 PaceSeverity::Critical => parse_hex(&theme.red),
428 }
429 .unwrap_or(Color::Green);
430 let bar_empty = parse_hex(&theme.bar_empty).unwrap_or(Color::Black);
431
432 let inner = Layout::default()
433 .direction(ratatui::layout::Direction::Vertical)
434 .constraints([
435 Constraint::Length(1),
436 Constraint::Length(1),
437 Constraint::Length(1),
438 ])
439 .split(area);
440
441 let label_line = Line::from(Span::styled(
443 format!(" {label}"),
444 Style::default().fg(fg).add_modifier(Modifier::BOLD),
445 ));
446 f.render_widget(Paragraph::new(label_line), inner[0]);
447
448 let row = inner[1];
450 let value_w = value_label.chars().count() as u16 + 2;
451 let gauge_area = Rect {
452 x: row.x + 2,
453 y: row.y,
454 width: row.width.saturating_sub(value_w + 4),
455 height: 1,
456 };
457 let value_area = Rect {
458 x: gauge_area.x + gauge_area.width + 1,
459 y: row.y,
460 width: value_w,
461 height: 1,
462 };
463 let gauge = Gauge::default()
464 .block(Block::default())
465 .gauge_style(Style::default().fg(bar_color).bg(bar_empty))
466 .percent(pct)
467 .label("");
468 f.render_widget(gauge, gauge_area);
469 let value = Paragraph::new(Line::from(Span::styled(
470 value_label.to_string(),
471 Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
472 )));
473 f.render_widget(value, value_area);
474
475 let foot = Line::from(Span::styled(
477 format!(" {footnote}"),
478 Style::default().fg(dim),
479 ));
480 f.render_widget(Paragraph::new(foot), inner[2]);
481}
482
483fn render_block(f: &mut Frame, area: Rect, theme: &Theme, label: &str, body: &[String]) {
484 let fg = parse_hex(&theme.fg).unwrap_or(Color::White);
485 let dim = parse_hex(&theme.dim).unwrap_or(Color::DarkGray);
486
487 let mut lines = vec![Line::from(Span::styled(
488 format!(" {label}"),
489 Style::default().fg(fg).add_modifier(Modifier::BOLD),
490 ))];
491 for b in body {
492 lines.push(Line::from(Span::styled(
493 format!(" {b}"),
494 Style::default().fg(dim),
495 )));
496 }
497 f.render_widget(Paragraph::new(lines), area);
498}
499
500fn parse_hex(s: &str) -> Option<Color> {
501 let (r, g, b) = crate::theme::parse_hex_rgb(s)?;
502 Some(Color::Rgb(r, g, b))
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::usage::{
509 AnthropicSnapshot, Cents, ExtraUsage, OpenAiCredits, OpenAiSnapshot, OpenAiSource,
510 OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
511 };
512 use chrono::TimeZone;
513
514 fn now() -> DateTime<Utc> {
515 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
516 }
517
518 fn ready(snapshot: VendorSnapshot) -> TabState {
519 TabState::Ready(Box::new(crate::tui::app::ReadyTab {
520 snapshot,
521 stale: false,
522 last_error: None,
523 fetched_at: Some(now() - chrono::Duration::seconds(15)),
524 }))
525 }
526
527 #[test]
528 fn anthropic_sections_include_all_three_windows_when_present() {
529 let snap = AnthropicSnapshot {
530 plan: "Max 20x".into(),
531 session: UsageWindow {
532 utilization_pct: 60,
533 resets_at: Some(now() + chrono::Duration::hours(1)),
534 window_duration: chrono::Duration::hours(5),
535 },
536 weekly: UsageWindow {
537 utilization_pct: 30,
538 resets_at: Some(now() + chrono::Duration::days(3)),
539 window_duration: chrono::Duration::days(7),
540 },
541 sonnet: Some(UsageWindow {
542 utilization_pct: 5,
543 resets_at: Some(now() + chrono::Duration::hours(2)),
544 window_duration: chrono::Duration::days(7),
545 }),
546 extra: Some(ExtraUsage {
547 limit: Cents(5000),
548 spent: Cents(250),
549 }),
550 };
551 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
552 assert_eq!(sections.len(), 9);
555 assert!(matches!(sections[0], Section::Title { .. }));
556 if let Section::Title { right, .. } = §ions[0] {
558 assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
559 } else {
560 panic!("expected first section to be Title");
561 }
562 let metric_count = sections
563 .iter()
564 .filter(|s| matches!(s, Section::Metric { .. }))
565 .count();
566 assert_eq!(metric_count, 4);
567 }
568
569 #[test]
570 fn anthropic_omits_sonnet_and_extra_when_absent() {
571 let snap = AnthropicSnapshot {
572 plan: "Pro".into(),
573 session: UsageWindow {
574 utilization_pct: 10,
575 resets_at: None,
576 window_duration: chrono::Duration::hours(5),
577 },
578 weekly: UsageWindow {
579 utilization_pct: 5,
580 resets_at: None,
581 window_duration: chrono::Duration::days(7),
582 },
583 sonnet: None,
584 extra: None,
585 };
586 let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
587 let metric_count = sections
588 .iter()
589 .filter(|s| matches!(s, Section::Metric { .. }))
590 .count();
591 assert_eq!(metric_count, 2);
592 }
593
594 #[test]
595 fn openrouter_always_has_balance_metric_and_period_block() {
596 let snap = OpenRouterSnapshot {
597 label: "OR".into(),
598 total_credits: 100.0,
599 total_usage: 25.0,
600 usage_daily: 1.0,
601 usage_weekly: 5.0,
602 usage_monthly: 25.0,
603 is_free_tier: false,
604 limit: None,
605 limit_remaining: None,
606 };
607 let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
608 assert!(matches!(sections[0], Section::Title { .. }));
609 assert!(
610 sections
611 .iter()
612 .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
613 );
614 assert!(
615 sections
616 .iter()
617 .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
618 );
619 }
620
621 #[test]
622 fn zai_no_windows_renders_message() {
623 let snap = ZaiSnapshot {
624 plan: "GLM".into(),
625 session: None,
626 weekly: None,
627 mcp: None,
628 };
629 let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
630 assert!(sections.iter().any(|s| matches!(
631 s,
632 Section::Text { value, .. } if value.contains("no usage windows reported")
633 )));
634 }
635
636 #[test]
637 fn loading_state_yields_loading_section() {
638 let sections = sections_for(&TabState::Loading, now(), 5);
639 assert!(sections.iter().any(|s| matches!(
640 s,
641 Section::Text { value, .. } if value.contains("Loading")
642 )));
643 }
644
645 #[test]
646 fn error_state_includes_retry_hint() {
647 let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
648 assert!(sections.iter().any(|s| matches!(
649 s,
650 Section::Text { value, .. } if value.contains("token expired")
651 )));
652 assert!(sections.iter().any(|s| matches!(
653 s,
654 Section::Text { value, .. } if value.contains("`r` to retry")
655 )));
656 }
657
658 #[test]
659 fn openai_with_credits_renders_block() {
660 let snap = OpenAiSnapshot {
661 plan: "ChatGPT Plus".into(),
662 session: UsageWindow {
663 utilization_pct: 1,
664 resets_at: None,
665 window_duration: chrono::Duration::hours(5),
666 },
667 weekly: UsageWindow {
668 utilization_pct: 0,
669 resets_at: None,
670 window_duration: chrono::Duration::days(7),
671 },
672 code_review: None,
673 credits: Some(OpenAiCredits {
674 balance: "$5.00".into(),
675 has_credits: true,
676 unlimited: false,
677 approx_local_messages: Some((100, 200)),
678 approx_cloud_messages: Some((30, 50)),
679 }),
680 source: OpenAiSource::CodexOauth,
681 };
682 let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
683 assert!(
684 sections
685 .iter()
686 .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
687 );
688 }
689}