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