Skip to main content

ai_usagebar/tui/
panels.rs

1//! Native ratatui panels.
2//!
3//! Each vendor projects its snapshot into a sequence of [`Section`]s — either
4//! a metric (gauge + footnote) or a free-form text block. The renderer lays
5//! them out vertically with consistent spacing so every panel has the same
6//! visual rhythm regardless of vendor.
7//!
8//! Progress bars use Bubble Tea-style block glyphs that scale to the available
9//! width, so on a wide monitor you get long, readable bars instead of the
10//! 20-char Pango ones the Waybar tooltip is stuck with.
11
12use 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
30/// One row of the panel body. Vendors emit a `Vec<Section>`; the renderer
31/// turns them into ratatui widgets.
32pub enum Section {
33    /// Title row at the top. `left` is the plan/vendor label (accent-colored,
34    /// bold); `right` is an optional right-aligned annotation, used for the
35    /// "Updated HH:MM:SS" timestamp so it shares the title row instead of
36    /// taking a separate body row + duplicating the global footer's clock.
37    Title { left: String, right: Option<String> },
38    /// A metric: label + gauge + value annotation + dim footnote.
39    Metric {
40        label: String,
41        pct: u16,
42        severity: PaceSeverity,
43        value_label: String,
44        footnote: String,
45    },
46    /// Free-form key/value text line.
47    Text { label: String, value: String },
48    /// A label followed by a multi-line dim block (no gauge).
49    Block { label: String, body: Vec<String> },
50    /// Visual spacer (one blank row).
51    Spacer,
52}
53
54/// Build the section list for the currently-active vendor's snapshot.
55pub 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::AnthropicApi(s) => anthropic_api_sections(s),
82                VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
83                VendorSnapshot::Zai(s) => zai_sections(s, now),
84                VendorSnapshot::Openrouter(s) => openrouter_sections(s),
85                VendorSnapshot::Deepseek(s) => deepseek_sections(s),
86                VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
87                VendorSnapshot::Kilo(s) => kilo_sections(s),
88                VendorSnapshot::Novita(s) => novita_sections(s),
89                VendorSnapshot::Moonshot(s) => moonshot_sections(s),
90                VendorSnapshot::Grok(s) => grok_sections(s),
91                VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
92            };
93            // Inject the (already-absolute) fetched-at instant into the title
94            // row, right-aligned. Pre-snapshotted in app::refresh_one so it
95            // doesn't drift between redraws.
96            let updated = match r.fetched_at {
97                Some(at) => format!("Updated {}", local_time_hms(at)),
98                None => "Updated —".to_string(),
99            };
100            if let Some(Section::Title { right, .. }) = sections.first_mut() {
101                *right = Some(updated);
102            }
103            // Error footer (when present) still lives in the body.
104            if let Some((label, msg)) = warning_label(snapshot, last_error) {
105                sections.push(Section::Spacer);
106                sections.push(Section::Text { label, value: msg });
107            }
108            sections
109        }
110    }
111}
112
113/// Translate cache diagnostics at the presentation boundary. Cache files keep
114/// their established `(u16, String)` form: only non-zero codes are HTTP, while
115/// Kimi's stable schema marker identifies its code-zero schema warning.
116fn warning_label(
117    snapshot: &VendorSnapshot,
118    last_error: &Option<(u16, String)>,
119) -> Option<(String, String)> {
120    let (code, message) = last_error.as_ref()?;
121    if *code != 0 {
122        return Some((format!("HTTP {code}"), message.clone()));
123    }
124    if message.is_empty() {
125        return None;
126    }
127    let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
128        && matches!(
129            crate::kimi::vendor::warning_kind(*code, message),
130            crate::kimi::vendor::WarningKind::SchemaDrift
131        ) {
132        "Kimi API schema drift"
133    } else {
134        "Warning"
135    };
136    // The stable marker is already the schema-warning label. Keep the label
137    // visible but do not repeat that sentinel as a redundant body value.
138    let value = if label == message {
139        String::new()
140    } else {
141        message.clone()
142    };
143    Some((label.into(), value))
144}
145
146fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> Vec<Section> {
147    let mut v = vec![Section::Title {
148        left: "Anthropic API".into(),
149        right: None,
150    }];
151    match (s.limit.filter(|l| *l > 0.0), s.pct()) {
152        (Some(limit), Some(pct)) => {
153            let p = pct.clamp(0, 100) as u16;
154            v.push(Section::Metric {
155                label: "Spend (mo)".into(),
156                pct: p,
157                severity: severity_for(pct),
158                value_label: format!("${:.2} of ${:.0}", s.spent, limit),
159                footnote: format!("{pct}% of monthly limit"),
160            });
161        }
162        _ => {
163            v.push(Section::Text {
164                label: "Spend (mo)".into(),
165                value: format!("${:.2}", s.spent),
166            });
167        }
168    }
169    v.push(Section::Spacer);
170    v.push(Section::Text {
171        label: "".into(),
172        value: "Month-to-date cost via the Admin usage API.".into(),
173    });
174    v.push(Section::Text {
175        label: "".into(),
176        value: "Prepaid credit balance is Console-only (no API).".into(),
177    });
178    v.push(Section::Text {
179        label: "".into(),
180        value: "Excludes Priority Tier cost (not reported by this API).".into(),
181    });
182    v
183}
184
185fn anthropic_sections(
186    s: &crate::usage::AnthropicSnapshot,
187    now: DateTime<Utc>,
188    tol: u32,
189) -> Vec<Section> {
190    let mut v = vec![Section::Title {
191        left: format!("Claude {}", s.plan),
192        right: None,
193    }];
194
195    push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
196    push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
197    if let Some(w) = &s.sonnet {
198        push_window(&mut v, "Sonnet only", w, now, tol, false);
199    }
200    for sw in &s.scoped {
201        push_window(
202            &mut v,
203            &format!("{} (7d)", sw.label),
204            &sw.window,
205            now,
206            tol,
207            false,
208        );
209    }
210    if let Some(e) = &s.extra {
211        v.push(Section::Spacer);
212        let pct = e.percent().clamp(0, 100) as u16;
213        // An uncapped plan (`monthly_limit: null`) has spend but no
214        // denominator: show the amount alone rather than "of $0.00" or a
215        // percentage nobody can vouch for (#30).
216        let (value_label, footnote) = match e.fmt_limit() {
217            Some(l) => (
218                format!("{} of {}", e.fmt_spent(), l),
219                format!("{pct}% of monthly limit consumed"),
220            ),
221            None => (e.fmt_spent(), "no monthly limit reported".to_string()),
222        };
223        v.push(Section::Metric {
224            label: "Extra usage".into(),
225            pct,
226            severity: severity_for(pct as i32),
227            value_label,
228            footnote,
229        });
230    }
231    v
232}
233
234fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
235    let mut v = vec![Section::Title {
236        left: s.plan.clone(),
237        right: None,
238    }];
239    if let Some(session) = &s.session {
240        push_window(&mut v, "Codex 5h", session, now, tol, true);
241    }
242    if let Some(weekly) = &s.weekly {
243        push_window(&mut v, "Codex weekly", weekly, now, tol, true);
244    }
245    if s.session.is_none() && s.weekly.is_none() {
246        v.push(Section::Spacer);
247        v.push(Section::Text {
248            label: "".into(),
249            value: "  no usage windows reported".into(),
250        });
251    }
252    if let Some(cr) = &s.code_review {
253        push_window(&mut v, "Code review", cr, now, tol, false);
254    }
255    if let Some(c) = &s.credits {
256        v.push(Section::Spacer);
257        let balance = if c.unlimited {
258            "unlimited".into()
259        } else {
260            c.balance.clone()
261        };
262        let mut body = vec![format!("balance: {}", balance)];
263        if let Some((lo, hi)) = c.approx_local_messages {
264            body.push(format!("≈ {lo}-{hi} local messages"));
265        }
266        if let Some((lo, hi)) = c.approx_cloud_messages {
267            body.push(format!("≈ {lo}-{hi} cloud messages"));
268        }
269        v.push(Section::Block {
270            label: "Credits".into(),
271            body,
272        });
273    }
274    v
275}
276
277fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
278    let mut v = vec![Section::Title {
279        left: s.plan.clone(),
280        right: None,
281    }];
282    if let Some(w) = &s.session {
283        push_window(&mut v, "Session (5h)", w, now, 5, false);
284    }
285    if let Some(w) = &s.weekly {
286        push_window(&mut v, "Weekly", w, now, 5, false);
287    }
288    if let Some(w) = &s.mcp {
289        push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
290    }
291    if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
292        v.push(Section::Spacer);
293        v.push(Section::Text {
294            label: "".into(),
295            value: "  no usage windows reported".into(),
296        });
297    }
298    v
299}
300
301fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
302    let mut v = vec![Section::Title {
303        left: s.label.clone(),
304        right: None,
305    }];
306    let pct = s.consumed_pct().clamp(0, 100) as u16;
307    v.push(Section::Spacer);
308    v.push(Section::Metric {
309        label: "Credit balance".into(),
310        pct,
311        severity: severity_for(pct as i32),
312        value_label: format!("${:.2}", s.balance()),
313        footnote: format!(
314            "${:.2} of ${:.2} used ({pct}%)",
315            s.total_usage, s.total_credits
316        ),
317    });
318    v.push(Section::Spacer);
319    v.push(Section::Block {
320        label: "Usage by period".into(),
321        body: vec![format!(
322            "today ${:.2} · week ${:.2} · month ${:.2}",
323            s.usage_daily, s.usage_weekly, s.usage_monthly
324        )],
325    });
326    if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
327        v.push(Section::Spacer);
328        v.push(Section::Block {
329            label: "Per-key limit".into(),
330            body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
331        });
332    }
333    v.push(Section::Spacer);
334    v.push(Section::Block {
335        label: "Tier".into(),
336        body: vec![if s.is_free_tier {
337            "free tier".into()
338        } else {
339            "paid tier".into()
340        }],
341    });
342    v
343}
344
345/// Antigravity holds two independent pools (Gemini, Claude & GPT OSS), each
346/// with a 5-hour and a weekly window. Grouped by window type so the two pools
347/// sit side by side, matching the GNOME dropdown.
348fn antigravity_sections(s: &crate::usage::AntigravitySnapshot, now: DateTime<Utc>) -> Vec<Section> {
349    use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
350
351    let mut v = vec![Section::Title {
352        left: s.plan.clone(),
353        right: None,
354    }];
355    for (heading, primary, third_party) in [
356        ("Session", &s.session, s.third_party_session.as_ref()),
357        ("Weekly", &s.weekly, s.third_party_weekly.as_ref()),
358    ] {
359        v.push(Section::Spacer);
360        v.push(Section::Text {
361            label: heading.into(),
362            value: String::new(),
363        });
364        push_window(&mut v, GROUP_PRIMARY, primary, now, 5, false);
365        if let Some(w) = third_party {
366            push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
367        }
368    }
369    v
370}
371
372fn kilo_sections(s: &crate::usage::KiloSnapshot) -> Vec<Section> {
373    vec![
374        Section::Title {
375            left: s.label.clone(),
376            right: None,
377        },
378        Section::Spacer,
379        Section::Text {
380            label: "Balance".into(),
381            value: format!("${:.2}", s.balance),
382        },
383    ]
384}
385
386fn novita_sections(s: &crate::usage::NovitaSnapshot) -> Vec<Section> {
387    let mut v = vec![
388        Section::Title {
389            left: "Novita".into(),
390            right: None,
391        },
392        Section::Spacer,
393        Section::Text {
394            label: "Balance".into(),
395            value: format!("${:.2}", s.available),
396        },
397        Section::Block {
398            label: "Breakdown".into(),
399            body: vec![format!(
400                "top-up ${:.2} · credit limit ${:.2}",
401                s.cash, s.credit_limit
402            )],
403        },
404    ];
405    if s.outstanding > 0.0 {
406        v.push(Section::Spacer);
407        v.push(Section::Block {
408            label: "Owed".into(),
409            body: vec![format!("${:.2}", s.outstanding)],
410        });
411    }
412    v
413}
414
415fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> Vec<Section> {
416    let cur = &s.currency;
417    let fmt = |v: f64| match cur.as_str() {
418        "USD" => format!("${v:.2}"),
419        "CNY" => format!("¥{v:.2}"),
420        _ => format!("{v:.2} {cur}"),
421    };
422    vec![
423        Section::Title {
424            left: "Kimi (Moonshot)".into(),
425            right: None,
426        },
427        Section::Spacer,
428        Section::Text {
429            label: "Balance".into(),
430            value: fmt(s.available),
431        },
432        Section::Block {
433            label: "Breakdown".into(),
434            body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
435        },
436    ]
437}
438
439fn grok_sections(s: &crate::usage::GrokSnapshot) -> Vec<Section> {
440    vec![
441        Section::Title {
442            left: "Grok (xAI)".into(),
443            right: None,
444        },
445        Section::Spacer,
446        Section::Text {
447            label: "Prepaid balance".into(),
448            value: format!("${:.2}", s.balance),
449        },
450    ]
451}
452
453fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
454    let currency = &s.currency;
455    let fmt = |v: f64| match currency.as_str() {
456        "USD" => format!("${v:.2}"),
457        "CNY" => format!("¥{v:.2}"),
458        _ => format!("{v:.2} {currency}"),
459    };
460    let avail = if s.is_available {
461        "available"
462    } else {
463        "unavailable"
464    };
465    let mut v = vec![Section::Title {
466        left: "DeepSeek".into(),
467        right: None,
468    }];
469    v.push(Section::Spacer);
470    v.push(Section::Text {
471        label: "Balance".into(),
472        value: fmt(s.balance),
473    });
474    v.push(Section::Block {
475        label: "Breakdown".into(),
476        body: vec![format!(
477            "granted {} · topped-up {}",
478            fmt(s.granted),
479            fmt(s.topped_up)
480        )],
481    });
482    v.push(Section::Spacer);
483    v.push(Section::Block {
484        label: "API".into(),
485        body: vec![avail.into()],
486    });
487    v
488}
489
490fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> Vec<Section> {
491    let plan = s.plan.as_deref().unwrap_or("Kimi");
492    let mut v = vec![Section::Title {
493        left: plan.into(),
494        right: None,
495    }];
496
497    let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
498    v.push(Section::Spacer);
499    v.push(Section::Metric {
500        label: "Weekly quota".into(),
501        pct: weekly_pct,
502        severity: severity_for(s.weekly_pct()),
503        value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
504        footnote: format!(
505            "{} remaining · reset {}",
506            s.weekly_remaining,
507            countdown::format(s.weekly_reset_at, now)
508        ),
509    });
510
511    if s.window_limit > 0 {
512        let window_pct = s.window_pct().clamp(0, 100) as u16;
513        v.push(Section::Spacer);
514        v.push(Section::Metric {
515            label: "Rolling window (5h)".into(),
516            pct: window_pct,
517            severity: severity_for(s.window_pct()),
518            value_label: format!("{} / {}", s.window_used, s.window_limit),
519            footnote: format!(
520                "{} remaining · reset {}",
521                s.window_remaining,
522                countdown::format(s.window_reset_at, now)
523            ),
524        });
525    }
526
527    v
528}
529
530fn push_window(
531    sections: &mut Vec<Section>,
532    label: &str,
533    w: &crate::usage::UsageWindow,
534    now: DateTime<Utc>,
535    tol: u32,
536    show_pacing: bool,
537) {
538    let pct = w.utilization_pct.clamp(0, 100) as u16;
539    let reset_text = countdown::format(w.resets_at, now);
540    let footnote = if show_pacing {
541        let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
542        format!(
543            "Resets in {} · {}% elapsed · {}",
544            reset_text, p.elapsed_pct, p.point_label
545        )
546    } else {
547        format!("Resets in {}", reset_text)
548    };
549    sections.push(Section::Spacer);
550    sections.push(Section::Metric {
551        label: label.into(),
552        pct,
553        severity: severity_for(pct as i32),
554        value_label: format!("{pct}%"),
555        footnote,
556    });
557}
558
559/// Render the given sections into `area`. Lays them out vertically; metric
560/// rows take 2 lines (label+gauge / footnote), text and spacer rows take 1.
561///
562/// The trailing "Updated …" footer is detected (the last `Text` section)
563/// and pinned to the bottom of the area, with the slack absorbed *between*
564/// content and footer. This way shorter vendor panels (OpenRouter, Z.AI)
565/// don't leave a giant gap below the footer.
566pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
567    if sections.is_empty() {
568        return;
569    }
570    let bubble = bubble_theme(theme);
571    // Heuristic: if the last section is a Text starting with "  Updated",
572    // pin it to the bottom. Otherwise just lay everything out top-down.
573    let pin_last =
574        matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
575
576    let body_end = if pin_last {
577        sections.len() - 1
578    } else {
579        sections.len()
580    };
581    let mut constraints: Vec<Constraint> =
582        sections[..body_end].iter().map(section_height).collect();
583
584    if pin_last {
585        constraints.push(Constraint::Min(0)); // slack between body and footer
586        constraints.push(section_height(sections.last().unwrap()));
587    } else {
588        constraints.push(Constraint::Min(0));
589    }
590
591    let chunks = Layout::default()
592        .direction(ratatui::layout::Direction::Vertical)
593        .constraints(constraints)
594        .split(area);
595
596    for (i, s) in sections[..body_end].iter().enumerate() {
597        render_section(f, chunks[i], theme, &bubble, s);
598    }
599    if pin_last {
600        render_section(
601            f,
602            chunks[chunks.len() - 1],
603            theme,
604            &bubble,
605            sections.last().unwrap(),
606        );
607    }
608}
609
610fn section_height(s: &Section) -> Constraint {
611    match s {
612        Section::Title { .. } => Constraint::Length(2),
613        Section::Metric { .. } => Constraint::Length(3),
614        Section::Text { .. } => Constraint::Length(1),
615        Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
616        Section::Spacer => Constraint::Length(1),
617    }
618}
619
620fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
621    match s {
622        Section::Title { left, right } => {
623            // Left: bold accent-colored plan/vendor label. Right: dim-styled
624            // "Updated HH:MM:SS" pinned to the right edge of the title row.
625            let left_line = Line::from(Span::styled(
626                format!("  {} {left}", bubble.symbols.selected),
627                bubble.title,
628            ));
629            f.render_widget(Paragraph::new(left_line), area);
630            if let Some(rt) = right {
631                let right_line =
632                    Line::from(Span::styled(format!("{rt}  "), bubble.muted)).right_aligned();
633                f.render_widget(Paragraph::new(right_line), area);
634            }
635        }
636        Section::Metric {
637            label,
638            pct,
639            severity,
640            value_label,
641            footnote,
642        } => render_metric(
643            f,
644            area,
645            theme,
646            bubble,
647            label,
648            *pct,
649            *severity,
650            value_label,
651            footnote,
652        ),
653        Section::Text { label, value } => {
654            if label.is_empty() && value.contains("Loading") {
655                render_loading(f, area, bubble);
656                return;
657            }
658            if label == "Error" {
659                let line = Line::from(vec![
660                    bubble.error(format!("  {} ", bubble.symbols.cross)),
661                    Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
662                ]);
663                f.render_widget(Paragraph::new(line), area);
664                return;
665            }
666            let mut spans = Vec::new();
667            if !label.is_empty() {
668                spans.push(Span::styled(
669                    format!("  {label}  "),
670                    bubble.text.add_modifier(Modifier::BOLD),
671                ));
672            }
673            spans.push(Span::styled(value.clone(), bubble.muted));
674            f.render_widget(Paragraph::new(Line::from(spans)), area);
675        }
676        Section::Block { label, body } => render_block(f, area, bubble, label, body),
677        Section::Spacer => {}
678    }
679}
680
681fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
682    let frames = SpinnerFrames::DOTS;
683    let frame_count = frames.frames().len().max(1);
684    let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
685    let mut spinner = Spinner::new()
686        .frames(frames)
687        .label("Fetching usage data")
688        .theme(*bubble);
689    for _ in 0..(frame % frame_count) {
690        spinner.tick();
691    }
692    f.render_widget(&spinner, area);
693}
694
695#[allow(clippy::too_many_arguments)]
696fn render_metric(
697    f: &mut Frame,
698    area: Rect,
699    theme: &Theme,
700    bubble: &BubbleTheme,
701    label: &str,
702    pct: u16,
703    severity: PaceSeverity,
704    value_label: &str,
705    footnote: &str,
706) {
707    let bar_color = severity_color(theme, bubble, severity);
708    let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
709
710    let inner = Layout::default()
711        .direction(ratatui::layout::Direction::Vertical)
712        .constraints([
713            Constraint::Length(1),
714            Constraint::Length(1),
715            Constraint::Length(1),
716        ])
717        .split(area);
718
719    // Row 1: label
720    let label_line = Line::from(Span::styled(
721        format!("  {label}"),
722        bubble.text.add_modifier(Modifier::BOLD),
723    ));
724    f.render_widget(Paragraph::new(label_line), inner[0]);
725
726    // Row 2: gauge spanning most of the width + value annotation on the right
727    let row = inner[1];
728    let value_w = value_label.chars().count() as u16 + 2;
729    let gauge_area = Rect {
730        x: row.x + 2,
731        y: row.y,
732        width: row.width.saturating_sub(value_w + 4),
733        height: 1,
734    };
735    let value_area = Rect {
736        x: gauge_area.x + gauge_area.width + 1,
737        y: row.y,
738        width: value_w,
739        height: 1,
740    };
741    let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
742    let progress = Progress::from_percent(pct)
743        .theme(progress_theme)
744        .show_percentage(false);
745    f.render_widget(&progress, gauge_area);
746    let value = Paragraph::new(Line::from(Span::styled(
747        value_label.to_string(),
748        Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
749    )));
750    f.render_widget(value, value_area);
751
752    // Row 3: footnote (dim)
753    let foot = Line::from(Span::styled(format!("    {footnote}"), bubble.muted));
754    f.render_widget(Paragraph::new(foot), inner[2]);
755}
756
757fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
758    let mut lines = vec![Line::from(Span::styled(
759        format!("  {label}"),
760        bubble.text.add_modifier(Modifier::BOLD),
761    ))];
762    for b in body {
763        lines.push(Line::from(Span::styled(format!("    {b}"), bubble.muted)));
764    }
765    f.render_widget(Paragraph::new(lines), area);
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::usage::{
772        AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
773        OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
774    };
775    use chrono::TimeZone;
776
777    fn now() -> DateTime<Utc> {
778        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
779    }
780
781    fn ready(snapshot: VendorSnapshot) -> TabState {
782        TabState::Ready(Box::new(crate::tui::app::ReadyTab {
783            snapshot,
784            stale: false,
785            last_error: None,
786            fetched_at: Some(now() - chrono::Duration::seconds(15)),
787        }))
788    }
789
790    #[test]
791    fn anthropic_sections_include_all_three_windows_when_present() {
792        let snap = AnthropicSnapshot {
793            plan: "Max 20x".into(),
794            session: UsageWindow {
795                utilization_pct: 60,
796                resets_at: Some(now() + chrono::Duration::hours(1)),
797                window_duration: chrono::Duration::hours(5),
798            },
799            weekly: UsageWindow {
800                utilization_pct: 30,
801                resets_at: Some(now() + chrono::Duration::days(3)),
802                window_duration: chrono::Duration::days(7),
803            },
804            sonnet: Some(UsageWindow {
805                utilization_pct: 5,
806                resets_at: Some(now() + chrono::Duration::hours(2)),
807                window_duration: chrono::Duration::days(7),
808            }),
809            scoped: vec![],
810            extra: Some(ExtraUsage {
811                limit: Some(Cents(5000)),
812                spent: Cents(250),
813                currency: None,
814                decimal_places: Some(2),
815            }),
816        };
817        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
818        // Title (carries "Updated …" inline now) + 4 metrics (3 windows +
819        // extra) each preceded by a Spacer. 1 + 4*2 = 9 sections.
820        assert_eq!(sections.len(), 9);
821        assert!(matches!(sections[0], Section::Title { .. }));
822        // Title's right-aligned slot should carry the timestamp.
823        if let Section::Title { right, .. } = &sections[0] {
824            assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
825        } else {
826            panic!("expected first section to be Title");
827        }
828        let metric_count = sections
829            .iter()
830            .filter(|s| matches!(s, Section::Metric { .. }))
831            .count();
832        assert_eq!(metric_count, 4);
833    }
834
835    #[test]
836    fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
837        // The #30 shape: `monthly_limit: null` (Pro). The panel must show the
838        // spend alone — not "of $0.00", not an invented percentage.
839        let snap = AnthropicSnapshot {
840            plan: "Pro".into(),
841            session: UsageWindow {
842                utilization_pct: 10,
843                resets_at: None,
844                window_duration: chrono::Duration::hours(5),
845            },
846            weekly: UsageWindow {
847                utilization_pct: 20,
848                resets_at: None,
849                window_duration: chrono::Duration::days(7),
850            },
851            sonnet: None,
852            scoped: vec![],
853            extra: Some(ExtraUsage {
854                limit: None,
855                spent: Cents(14157),
856                currency: Some("BRL".into()),
857                decimal_places: Some(2),
858            }),
859        };
860        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
861        let extra = sections
862            .iter()
863            .find_map(|s| match s {
864                Section::Metric {
865                    label,
866                    pct,
867                    value_label,
868                    footnote,
869                    ..
870                } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
871                _ => None,
872            })
873            .expect("uncapped extra usage must still render a section");
874        assert_eq!(extra.0, 0);
875        // Non-vacuous currency pin: fmt_dollars would say "$141.57" here.
876        assert_eq!(extra.1, "R$141.57");
877        assert!(
878            !extra.1.contains(" of "),
879            "no denominator to show: {}",
880            extra.1
881        );
882        assert_eq!(extra.2, "no monthly limit reported");
883    }
884
885    #[test]
886    fn anthropic_omits_sonnet_and_extra_when_absent() {
887        let snap = AnthropicSnapshot {
888            plan: "Pro".into(),
889            session: UsageWindow {
890                utilization_pct: 10,
891                resets_at: None,
892                window_duration: chrono::Duration::hours(5),
893            },
894            weekly: UsageWindow {
895                utilization_pct: 5,
896                resets_at: None,
897                window_duration: chrono::Duration::days(7),
898            },
899            sonnet: None,
900            scoped: vec![],
901            extra: None,
902        };
903        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
904        let metric_count = sections
905            .iter()
906            .filter(|s| matches!(s, Section::Metric { .. }))
907            .count();
908        assert_eq!(metric_count, 2);
909    }
910
911    #[test]
912    fn openrouter_always_has_balance_metric_and_period_block() {
913        let snap = OpenRouterSnapshot {
914            label: "OR".into(),
915            total_credits: 100.0,
916            total_usage: 25.0,
917            usage_daily: 1.0,
918            usage_weekly: 5.0,
919            usage_monthly: 25.0,
920            is_free_tier: false,
921            limit: None,
922            limit_remaining: None,
923        };
924        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
925        assert!(matches!(sections[0], Section::Title { .. }));
926        assert!(
927            sections
928                .iter()
929                .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
930        );
931        assert!(
932            sections
933                .iter()
934                .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
935        );
936    }
937
938    #[test]
939    fn zai_no_windows_renders_message() {
940        let snap = ZaiSnapshot {
941            plan: "GLM".into(),
942            session: None,
943            weekly: None,
944            mcp: None,
945        };
946        let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
947        assert!(sections.iter().any(|s| matches!(
948            s,
949            Section::Text { value, .. } if value.contains("no usage windows reported")
950        )));
951    }
952
953    #[test]
954    fn openai_no_windows_renders_message() {
955        let snap = OpenAiSnapshot {
956            plan: "ChatGPT Plus".into(),
957            session: None,
958            weekly: None,
959            code_review: None,
960            credits: None,
961            source: OpenAiSource::CodexOauth,
962        };
963        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
964        assert!(sections.iter().any(|s| matches!(
965            s,
966            Section::Text { value, .. } if value.contains("no usage windows reported")
967        )));
968    }
969
970    #[test]
971    fn loading_state_yields_loading_section() {
972        let sections = sections_for(&TabState::Loading, now(), 5);
973        assert!(sections.iter().any(|s| matches!(
974            s,
975            Section::Text { value, .. } if value.contains("Loading")
976        )));
977    }
978
979    #[test]
980    fn error_state_includes_retry_hint() {
981        let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
982        assert!(sections.iter().any(|s| matches!(
983            s,
984            Section::Text { value, .. } if value.contains("token expired")
985        )));
986        assert!(sections.iter().any(|s| matches!(
987            s,
988            Section::Text { value, .. } if value.contains("`r` to retry")
989        )));
990    }
991
992    #[test]
993    fn openai_with_credits_renders_block() {
994        let snap = OpenAiSnapshot {
995            plan: "ChatGPT Plus".into(),
996            session: Some(UsageWindow {
997                utilization_pct: 1,
998                resets_at: None,
999                window_duration: chrono::Duration::hours(5),
1000            }),
1001            weekly: Some(UsageWindow {
1002                utilization_pct: 0,
1003                resets_at: None,
1004                window_duration: chrono::Duration::days(7),
1005            }),
1006            code_review: None,
1007            credits: Some(OpenAiCredits {
1008                balance: "$5.00".into(),
1009                has_credits: true,
1010                unlimited: false,
1011                approx_local_messages: Some((100, 200)),
1012                approx_cloud_messages: Some((30, 50)),
1013            }),
1014            source: OpenAiSource::CodexOauth,
1015        };
1016        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1017        assert!(
1018            sections
1019                .iter()
1020                .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
1021        );
1022    }
1023
1024    #[test]
1025    fn openai_weekly_only_omits_session_section() {
1026        let snap = OpenAiSnapshot {
1027            plan: "ChatGPT Prolite".into(),
1028            session: None,
1029            weekly: Some(UsageWindow {
1030                utilization_pct: 66,
1031                resets_at: None,
1032                window_duration: chrono::Duration::days(7),
1033            }),
1034            code_review: None,
1035            credits: None,
1036            source: OpenAiSource::CodexOauth,
1037        };
1038        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1039        assert!(sections.iter().any(|section| matches!(
1040            section,
1041            Section::Metric { label, .. } if label == "Codex weekly"
1042        )));
1043        assert!(!sections.iter().any(|section| matches!(
1044            section,
1045            Section::Metric { label, .. } if label == "Codex 5h"
1046        )));
1047    }
1048
1049    #[test]
1050    fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
1051        let now = now();
1052        let snap = KimiSnapshot {
1053            plan: Some("LEVEL_INTERMEDIATE".into()),
1054            weekly_limit: 100,
1055            weekly_used: 26,
1056            weekly_remaining: 74,
1057            weekly_reset_at: Some(now + chrono::Duration::days(4)),
1058            window_limit: 100,
1059            window_used: 15,
1060            window_remaining: 85,
1061            window_reset_at: Some(now + chrono::Duration::hours(2)),
1062        };
1063        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
1064        let metrics: Vec<_> = sections
1065            .iter()
1066            .filter(|s| matches!(s, Section::Metric { .. }))
1067            .collect();
1068        assert_eq!(metrics.len(), 2);
1069        assert!(sections.iter().any(|s| matches!(
1070            s,
1071            Section::Metric { label, .. } if label == "Weekly quota"
1072        )));
1073        assert!(sections.iter().any(|s| matches!(
1074            s,
1075            Section::Metric { label, .. } if label == "Rolling window (5h)"
1076        )));
1077
1078        let find_footnote = |label: &str| -> (String, String) {
1079            sections
1080                .iter()
1081                .find_map(|s| match s {
1082                    Section::Metric {
1083                        label: l,
1084                        value_label,
1085                        footnote,
1086                        ..
1087                    } if l == label => Some((value_label.clone(), footnote.clone())),
1088                    _ => None,
1089                })
1090                .unwrap_or_else(|| panic!("missing metric {label}"))
1091        };
1092
1093        let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
1094        assert_eq!(weekly_value, "26 / 100");
1095        assert!(weekly_footnote.contains("74 remaining"));
1096        assert!(
1097            weekly_footnote.contains("4d 0h"),
1098            "weekly reset countdown: {weekly_footnote}"
1099        );
1100        assert!(!weekly_footnote.contains("2026-05-27T")); // not a raw RFC3339
1101
1102        let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
1103        assert_eq!(window_value, "15 / 100");
1104        assert!(window_footnote.contains("85 remaining"));
1105        assert!(
1106            window_footnote.contains("2h 00m"),
1107            "window reset countdown: {window_footnote}"
1108        );
1109        assert!(!window_footnote.contains("2026-05-23T14")); // not a raw RFC3339
1110    }
1111
1112    #[test]
1113    fn kimi_sections_omit_window_when_limit_zero() {
1114        let snap = KimiSnapshot {
1115            plan: None,
1116            weekly_limit: 100,
1117            weekly_used: 10,
1118            weekly_remaining: 90,
1119            weekly_reset_at: None,
1120            window_limit: 0,
1121            window_used: 0,
1122            window_remaining: 0,
1123            window_reset_at: None,
1124        };
1125        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
1126        let metric_count = sections
1127            .iter()
1128            .filter(|s| matches!(s, Section::Metric { .. }))
1129            .count();
1130        assert_eq!(metric_count, 1);
1131    }
1132
1133    #[test]
1134    fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
1135        let snap = KimiSnapshot {
1136            plan: None,
1137            weekly_limit: 100,
1138            weekly_used: 10,
1139            weekly_remaining: 90,
1140            weekly_reset_at: None,
1141            window_limit: 0,
1142            window_used: 0,
1143            window_remaining: 0,
1144            window_reset_at: None,
1145        };
1146        let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
1147        let TabState::Ready(tab) = &mut schema else {
1148            unreachable!()
1149        };
1150        tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
1151        let schema_sections = sections_for(&schema, now(), 5);
1152        assert!(schema_sections.iter().any(|section| matches!(
1153            section,
1154            Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
1155        )));
1156
1157        let mut generic = ready(VendorSnapshot::Kimi(snap));
1158        let TabState::Ready(tab) = &mut generic else {
1159            unreachable!()
1160        };
1161        tab.last_error = Some((0, "cache lock unavailable".into()));
1162        let generic_sections = sections_for(&generic, now(), 5);
1163        assert!(generic_sections.iter().any(|section| matches!(
1164            section,
1165            Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
1166        )));
1167        assert!(!generic_sections.iter().any(|section| matches!(
1168            section,
1169            Section::Text { label, .. } if label.starts_with("HTTP")
1170        )));
1171
1172        let http = warning_label(
1173            &VendorSnapshot::Kimi(KimiSnapshot {
1174                plan: None,
1175                weekly_limit: 0,
1176                weekly_used: 0,
1177                weekly_remaining: 0,
1178                weekly_reset_at: None,
1179                window_limit: 0,
1180                window_used: 0,
1181                window_remaining: 0,
1182                window_reset_at: None,
1183            }),
1184            &Some((503, "service unavailable".into())),
1185        );
1186        assert_eq!(
1187            http,
1188            Some(("HTTP 503".into(), "service unavailable".into()))
1189        );
1190    }
1191}