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, money, usd};
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/// Internal metadata carried alongside a public [`Section`]. Keeping this
55/// wrapper private to the crate lets machine-readable frontends receive
56/// absolute reset timestamps without adding a source-breaking field to the
57/// public `Section::Metric` variant.
58pub(crate) struct SectionProjection {
59    pub section: Section,
60    pub reset_at: Option<DateTime<Utc>>,
61}
62
63struct SectionBuilder(Vec<SectionProjection>);
64
65impl SectionBuilder {
66    fn new(sections: Vec<Section>) -> Self {
67        Self(
68            sections
69                .into_iter()
70                .map(|section| {
71                    assert!(
72                        !matches!(section, Section::Metric { .. }),
73                        "metric sections must declare reset metadata with push_metric"
74                    );
75                    SectionProjection {
76                        section,
77                        reset_at: None,
78                    }
79                })
80                .collect(),
81        )
82    }
83
84    fn push(&mut self, section: Section) {
85        assert!(
86            !matches!(section, Section::Metric { .. }),
87            "metric sections must declare reset metadata with push_metric"
88        );
89        self.0.push(SectionProjection {
90            section,
91            reset_at: None,
92        });
93    }
94
95    fn push_metric(&mut self, section: Section, reset_at: Option<DateTime<Utc>>) {
96        assert!(matches!(section, Section::Metric { .. }));
97        self.0.push(SectionProjection { section, reset_at });
98    }
99}
100
101/// Compact one-line projection of a vendor snapshot for the Overview: a short
102/// plan/tier sub-label (may be empty) plus a few key metric cells — a percent
103/// or a balance — each carrying a severity for coloring. Same numbers as
104/// [`sections_for`], flattened for a dense multi-vendor list. The vendor's name
105/// is supplied by the caller, so it is not repeated here.
106pub fn compact_cells(snapshot: &VendorSnapshot) -> (String, Vec<(String, PaceSeverity)>) {
107    let pct = |label: &str, p: i32| (format!("{label} {p}%"), severity_for(p));
108    // Named `*_cell` so neither shadows `format::{usd, money}`, which they
109    // wrap — the cell is the string plus the severity the Overview colours it with.
110    let usd_cell = |v: f64| (usd(v), PaceSeverity::Low);
111    let money_cell = |v: f64, c: &str| (money(v, c), PaceSeverity::Low);
112    let (plan, mut cells) = match snapshot {
113        VendorSnapshot::Anthropic(s) => {
114            let mut cells = vec![
115                pct("S", s.session.utilization_pct),
116                pct("W", s.weekly.utilization_pct),
117            ];
118            if let Some(sonnet) = &s.sonnet {
119                cells.push(pct("Son", sonnet.utilization_pct));
120            }
121            (s.plan.clone(), cells)
122        }
123        VendorSnapshot::AnthropicApi(s) => {
124            let cell = match s.pct() {
125                Some(p) => pct("spend", p),
126                None => (format!("{}/mo", usd(s.spent)), PaceSeverity::Low),
127            };
128            (String::new(), vec![cell])
129        }
130        VendorSnapshot::Openai(s) => {
131            let mut cells = Vec::new();
132            if let Some(w) = &s.session {
133                cells.push(pct("5h", w.utilization_pct));
134            }
135            if let Some(w) = &s.weekly {
136                cells.push(pct("7d", w.utilization_pct));
137            }
138            if cells.is_empty() {
139                cells.push(("—".into(), PaceSeverity::Low));
140            }
141            (s.plan.clone(), cells)
142        }
143        VendorSnapshot::Zai(s) => {
144            let mut cells = Vec::new();
145            if let Some(w) = &s.session {
146                cells.push(pct("S", w.utilization_pct));
147            }
148            if let Some(w) = &s.weekly {
149                cells.push(pct("W", w.utilization_pct));
150            }
151            if cells.is_empty() {
152                cells.push(("—".into(), PaceSeverity::Low));
153            }
154            (s.plan.clone(), cells)
155        }
156        VendorSnapshot::Openrouter(s) => (String::new(), vec![usd_cell(s.balance())]),
157        VendorSnapshot::Deepseek(s) => (String::new(), vec![money_cell(s.balance, &s.currency)]),
158        VendorSnapshot::Kimi(s) => (
159            s.plan.clone().unwrap_or_default(),
160            vec![pct("wk", s.weekly_pct()), pct("5h", s.window_pct())],
161        ),
162        VendorSnapshot::Kilo(s) => (String::new(), vec![usd_cell(s.balance)]),
163        VendorSnapshot::Novita(s) => (String::new(), vec![usd_cell(s.available)]),
164        VendorSnapshot::Moonshot(s) => (String::new(), vec![money_cell(s.available, &s.currency)]),
165        VendorSnapshot::Grok(s) => (String::new(), vec![usd_cell(s.balance)]),
166        VendorSnapshot::SuperGrok(s) => (s.plan.clone(), vec![pct(s.period.short(), s.weekly_pct)]),
167        VendorSnapshot::Antigravity(s) => (
168            s.plan.clone(),
169            vec![
170                pct("S", s.session.utilization_pct),
171                pct("W", s.weekly.utilization_pct),
172            ],
173        ),
174        VendorSnapshot::Cursor(s) => (
175            s.plan.clone(),
176            vec![pct("auto", s.auto_pct), pct("premium", s.api_pct)],
177        ),
178        VendorSnapshot::Minimax(s) => (
179            s.plan.clone(),
180            vec![
181                pct("S", s.session.utilization_pct),
182                pct("W", s.weekly.utilization_pct),
183            ],
184        ),
185        VendorSnapshot::Kiro(s) => (s.plan.clone(), vec![pct("credits", s.pct())]),
186        VendorSnapshot::NousResearch(s) => {
187            let cell = s
188                .usage_percent()
189                .map(|value| pct("usage", value.round().clamp(0.0, 100.0) as i32))
190                .unwrap_or_else(|| ("—".into(), PaceSeverity::Low));
191            (s.plan.clone().unwrap_or_default(), vec![cell])
192        }
193        VendorSnapshot::CommandCode(s) => {
194            let cells = [
195                ("session", s.five_hour.as_ref()),
196                ("weekly", s.weekly.as_ref()),
197            ]
198            .into_iter()
199            .filter_map(|(label, window)| window.map(|window| pct(label, window.pct())))
200            .collect();
201            (s.plan.clone().unwrap_or_default(), cells)
202        }
203        VendorSnapshot::OpenCodeGo(s) => {
204            let cells = [
205                ("rolling", s.rolling.as_ref()),
206                ("weekly", s.weekly.as_ref()),
207                ("monthly", s.monthly.as_ref()),
208            ]
209            .into_iter()
210            .filter_map(|(label, window)| {
211                window.map(|window| pct(label, window.percent.round().clamp(0.0, 100.0) as i32))
212            })
213            .collect();
214            ("OpenCode Go".into(), cells)
215        }
216    };
217
218    for (text, _) in &mut cells {
219        *text = crate::display::sanitize_untrusted_field(text);
220    }
221    (crate::display::sanitize_untrusted_field(&plan), cells)
222}
223
224/// The single most-relevant percentage for a vendor in the Overview — what its
225/// per-row mini bar shows. Mirrors the macOS menu bar's headline: Cursor is the
226/// combined included-total, quota vendors the most-exhausted window; balance
227/// vendors have no meaningful percentage (`None` → no bar).
228pub fn headline_pct(snapshot: &VendorSnapshot) -> Option<i32> {
229    match snapshot {
230        VendorSnapshot::Anthropic(s) => [
231            Some(s.session.utilization_pct),
232            Some(s.weekly.utilization_pct),
233            s.sonnet.as_ref().map(|w| w.utilization_pct),
234        ]
235        .into_iter()
236        .flatten()
237        .max(),
238        VendorSnapshot::AnthropicApi(s) => s.pct(),
239        VendorSnapshot::Openai(s) => [
240            s.session.as_ref().map(|w| w.utilization_pct),
241            s.weekly.as_ref().map(|w| w.utilization_pct),
242        ]
243        .into_iter()
244        .flatten()
245        .max(),
246        VendorSnapshot::Zai(s) => [
247            s.session.as_ref().map(|w| w.utilization_pct),
248            s.weekly.as_ref().map(|w| w.utilization_pct),
249        ]
250        .into_iter()
251        .flatten()
252        .max(),
253        VendorSnapshot::Kimi(s) => Some(s.weekly_pct().max(s.window_pct())),
254        VendorSnapshot::Antigravity(s) => {
255            Some(s.session.utilization_pct.max(s.weekly.utilization_pct))
256        }
257        VendorSnapshot::Cursor(s) => (!s.unlimited).then_some(s.total_pct),
258        VendorSnapshot::Minimax(s) => Some(s.session.utilization_pct.max(s.weekly.utilization_pct)),
259        VendorSnapshot::Kiro(s) => Some(s.pct()),
260        VendorSnapshot::NousResearch(s) => s
261            .usage_percent()
262            .map(|value| value.round().clamp(0.0, 100.0) as i32),
263        VendorSnapshot::CommandCode(s) => {
264            let worst = s.worst_pct();
265            (s.five_hour.is_some() || s.weekly.is_some()).then_some(worst)
266        }
267        VendorSnapshot::OpenCodeGo(s) => [
268            s.rolling
269                .as_ref()
270                .map(|window| window.percent.round() as i32),
271            s.weekly
272                .as_ref()
273                .map(|window| window.percent.round() as i32),
274            s.monthly
275                .as_ref()
276                .map(|window| window.percent.round() as i32),
277        ]
278        .into_iter()
279        .flatten()
280        .max(),
281        VendorSnapshot::SuperGrok(s) => Some(s.weekly_pct),
282        VendorSnapshot::Openrouter(_)
283        | VendorSnapshot::Deepseek(_)
284        | VendorSnapshot::Kilo(_)
285        | VendorSnapshot::Novita(_)
286        | VendorSnapshot::Moonshot(_)
287        | VendorSnapshot::Grok(_) => None,
288    }
289}
290
291/// Build the section list for the currently-active vendor's snapshot.
292pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
293    sections_with_metadata_for(tab, now, pace_tolerance)
294        .into_iter()
295        .map(|projected| projected.section)
296        .collect()
297}
298
299/// Rich projection used by machine-readable frontends. The TUI continues to
300/// expose the source-compatible [`sections_for`] result above.
301pub(crate) fn sections_with_metadata_for(
302    tab: &TabState,
303    now: DateTime<Utc>,
304    pace_tolerance: u32,
305) -> Vec<SectionProjection> {
306    let mut sections = match tab {
307        TabState::Loading => SectionBuilder::new(vec![
308            Section::Spacer,
309            Section::Text {
310                label: "".into(),
311                value: "  Loading…".into(),
312            },
313        ]),
314        TabState::Error(e) => SectionBuilder::new(vec![
315            Section::Spacer,
316            Section::Text {
317                label: "Error".into(),
318                value: e.clone(),
319            },
320            Section::Spacer,
321            Section::Text {
322                label: "".into(),
323                value: "Press `r` to retry, `q` to quit.".into(),
324            },
325        ]),
326        TabState::Ready(r) => {
327            let snapshot = &r.snapshot;
328            let last_error = &r.last_error;
329            let mut sections = match snapshot {
330                VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
331                VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
332                VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
333                VendorSnapshot::Zai(s) => zai_sections(s, now, pace_tolerance),
334                VendorSnapshot::Openrouter(s) => openrouter_sections(s),
335                VendorSnapshot::Deepseek(s) => deepseek_sections(s),
336                VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
337                VendorSnapshot::Kilo(s) => kilo_sections(s),
338                VendorSnapshot::Novita(s) => novita_sections(s),
339                VendorSnapshot::Moonshot(s) => moonshot_sections(s),
340                VendorSnapshot::Grok(s) => grok_sections(s),
341                VendorSnapshot::SuperGrok(s) => supergrok_sections(s, now),
342                VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
343                VendorSnapshot::Cursor(s) => cursor_sections(s, now),
344                VendorSnapshot::Minimax(s) => minimax_sections(s, now, pace_tolerance),
345                VendorSnapshot::Kiro(s) => kiro_sections(s, now),
346                VendorSnapshot::NousResearch(s) => nous_sections(s, now),
347                VendorSnapshot::OpenCodeGo(s) => opencode_go_sections(s, now),
348                VendorSnapshot::CommandCode(s) => commandcode_sections(s, now),
349            };
350            // Inject the (already-absolute) fetched-at instant into the title
351            // row, right-aligned. Pre-snapshotted in app::refresh_one so it
352            // doesn't drift between redraws.
353            let updated = match r.fetched_at {
354                Some(at) => format!("Updated {}", local_time_hms(at)),
355                None => "Updated —".to_string(),
356            };
357            if let Some(SectionProjection {
358                section: Section::Title { right, .. },
359                ..
360            }) = sections.0.first_mut()
361            {
362                *right = Some(updated);
363            }
364            // Error footer (when present) still lives in the body.
365            if let Some((label, msg)) = warning_label(snapshot, last_error) {
366                sections.push(Section::Spacer);
367                sections.push(Section::Text { label, value: msg });
368            }
369            sections
370        }
371    };
372    for projected in &mut sections.0 {
373        sanitize_section(&mut projected.section);
374    }
375    sections.0
376}
377
378/// Sanitize at the final projection boundary so every vendor field, cached
379/// diagnostic, and fetch error is inert before ratatui writes it to a terminal.
380fn sanitize_section(section: &mut Section) {
381    let clean = |value: &mut String| {
382        *value = crate::display::sanitize_untrusted_field(value);
383    };
384    match section {
385        Section::Title { left, right } => {
386            clean(left);
387            if let Some(right) = right {
388                clean(right);
389            }
390        }
391        Section::Metric {
392            label,
393            value_label,
394            footnote,
395            ..
396        } => {
397            clean(label);
398            clean(value_label);
399            clean(footnote);
400        }
401        Section::Text { label, value } => {
402            clean(label);
403            clean(value);
404        }
405        Section::Block { label, body } => {
406            clean(label);
407            for line in body {
408                clean(line);
409            }
410        }
411        Section::Spacer => {}
412    }
413}
414
415/// Translate cache diagnostics at the presentation boundary. Cache files keep
416/// their established `(u16, String)` form: only non-zero codes are HTTP, while
417/// Kimi's stable schema marker identifies its code-zero schema warning.
418fn warning_label(
419    snapshot: &VendorSnapshot,
420    last_error: &Option<(u16, String)>,
421) -> Option<(String, String)> {
422    let (code, message) = last_error.as_ref()?;
423    if *code != 0 {
424        return Some((format!("HTTP {code}"), message.clone()));
425    }
426    if message.is_empty() {
427        return None;
428    }
429    let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
430        && matches!(
431            crate::kimi::vendor::warning_kind(*code, message),
432            crate::kimi::vendor::WarningKind::SchemaDrift
433        ) {
434        "Kimi API schema drift"
435    } else {
436        "Warning"
437    };
438    // The stable marker is already the schema-warning label. Keep the label
439    // visible but do not repeat that sentinel as a redundant body value.
440    let value = if label == message {
441        String::new()
442    } else {
443        message.clone()
444    };
445    Some((label.into(), value))
446}
447
448fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> SectionBuilder {
449    let mut v = SectionBuilder::new(vec![Section::Title {
450        left: "Anthropic API".into(),
451        right: None,
452    }]);
453    match (s.limit.filter(|l| *l > 0.0), s.pct()) {
454        (Some(limit), Some(pct)) => {
455            let p = pct.clamp(0, 100) as u16;
456            v.push_metric(
457                Section::Metric {
458                    label: "Spend (mo)".into(),
459                    pct: p,
460                    severity: severity_for(pct),
461                    value_label: format!("{} of ${:.0}", usd(s.spent), limit),
462                    footnote: format!("{pct}% of monthly limit"),
463                },
464                None,
465            );
466        }
467        _ => {
468            v.push(Section::Text {
469                label: "Spend (mo)".into(),
470                value: usd(s.spent),
471            });
472        }
473    }
474    v.push(Section::Spacer);
475    v.push(Section::Text {
476        label: "".into(),
477        value: "Month-to-date cost via the Admin usage API.".into(),
478    });
479    v.push(Section::Text {
480        label: "".into(),
481        value: "Prepaid credit balance is Console-only (no API).".into(),
482    });
483    v.push(Section::Text {
484        label: "".into(),
485        value: "Excludes Priority Tier cost (not reported by this API).".into(),
486    });
487    v
488}
489
490fn anthropic_sections(
491    s: &crate::usage::AnthropicSnapshot,
492    now: DateTime<Utc>,
493    tol: u32,
494) -> SectionBuilder {
495    let mut v = SectionBuilder::new(vec![Section::Title {
496        left: format!("Claude {}", s.plan),
497        right: None,
498    }]);
499
500    push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
501    push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
502    if let Some(w) = &s.sonnet {
503        push_window(&mut v, "Sonnet only", w, now, tol, false);
504    }
505    for sw in &s.scoped {
506        push_window(
507            &mut v,
508            &format!("{} (7d)", sw.label),
509            &sw.window,
510            now,
511            tol,
512            false,
513        );
514    }
515    if let Some(e) = &s.extra {
516        v.push(Section::Spacer);
517        let pct = e.percent().clamp(0, 100) as u16;
518        // An uncapped plan (`monthly_limit: null`) has spend but no
519        // denominator: show the amount alone rather than "of $0.00" or a
520        // percentage nobody can vouch for (#30).
521        let (value_label, footnote) = match e.fmt_limit() {
522            Some(l) => (
523                format!("{} of {}", e.fmt_spent(), l),
524                format!("{pct}% of monthly limit consumed"),
525            ),
526            None => (e.fmt_spent(), "no monthly limit reported".to_string()),
527        };
528        v.push_metric(
529            Section::Metric {
530                label: "Extra usage".into(),
531                pct,
532                severity: severity_for(pct as i32),
533                value_label,
534                footnote,
535            },
536            None,
537        );
538    }
539    v
540}
541
542fn openai_sections(
543    s: &crate::usage::OpenAiSnapshot,
544    now: DateTime<Utc>,
545    tol: u32,
546) -> SectionBuilder {
547    let mut v = SectionBuilder::new(vec![Section::Title {
548        left: s.plan.clone(),
549        right: None,
550    }]);
551    if let Some(session) = &s.session {
552        push_window(&mut v, "Codex 5h", session, now, tol, true);
553    }
554    if let Some(weekly) = &s.weekly {
555        push_window(&mut v, "Codex weekly", weekly, now, tol, true);
556    }
557    if s.session.is_none() && s.weekly.is_none() {
558        v.push(Section::Spacer);
559        v.push(Section::Text {
560            label: "".into(),
561            value: "  no usage windows reported".into(),
562        });
563    }
564    if let Some(cr) = &s.code_review {
565        push_window(&mut v, "Code review", cr, now, tol, false);
566    }
567    if let Some(c) = &s.credits {
568        v.push(Section::Spacer);
569        let balance = if c.unlimited {
570            "unlimited".into()
571        } else {
572            c.balance.clone()
573        };
574        let mut body = vec![format!("balance: {}", balance)];
575        if let Some((lo, hi)) = c.approx_local_messages {
576            body.push(format!("≈ {lo}-{hi} local messages"));
577        }
578        if let Some((lo, hi)) = c.approx_cloud_messages {
579            body.push(format!("≈ {lo}-{hi} cloud messages"));
580        }
581        v.push(Section::Block {
582            label: "Credits".into(),
583            body,
584        });
585    }
586    v
587}
588
589fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>, tol: u32) -> SectionBuilder {
590    let mut v = SectionBuilder::new(vec![Section::Title {
591        left: s.plan.clone(),
592        right: None,
593    }]);
594    if let Some(w) = &s.session {
595        push_window(&mut v, "Session (5h)", w, now, tol, true);
596    }
597    if let Some(w) = &s.weekly {
598        push_window(&mut v, "Weekly", w, now, tol, true);
599    }
600    if let Some(w) = &s.mcp {
601        push_window(&mut v, "MCP tools (monthly)", w, now, tol, true);
602    }
603    if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
604        v.push(Section::Spacer);
605        v.push(Section::Text {
606            label: "".into(),
607            value: "  no usage windows reported".into(),
608        });
609    }
610    v
611}
612
613fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> SectionBuilder {
614    let mut v = SectionBuilder::new(vec![Section::Title {
615        left: s.label.clone(),
616        right: None,
617    }]);
618    let pct = s.consumed_pct().clamp(0, 100) as u16;
619    v.push(Section::Spacer);
620    v.push_metric(
621        Section::Metric {
622            label: "Credit balance".into(),
623            pct,
624            // One severity policy for every frontend: this value is what the
625            // Omarchy, GNOME and KDE panels colour their row with, so it has to
626            // agree with the Waybar tooltip about what "in debt" looks like.
627            severity: crate::openrouter::vendor::severity(s),
628            value_label: usd(s.balance()),
629            footnote: format!(
630                "{} of {} used ({pct}%)",
631                usd(s.total_usage),
632                usd(s.total_credits)
633            ),
634        },
635        None,
636    );
637    v.push(Section::Spacer);
638    v.push(Section::Block {
639        label: "Usage by period".into(),
640        body: vec![format!(
641            "today ${:.2} · week ${:.2} · month ${:.2}",
642            s.usage_daily, s.usage_weekly, s.usage_monthly
643        )],
644    });
645    if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
646        v.push(Section::Spacer);
647        v.push(Section::Block {
648            label: "Per-key limit".into(),
649            body: vec![format!("{} of {} remaining", usd(rem), usd(limit))],
650        });
651    }
652    v.push(Section::Spacer);
653    v.push(Section::Block {
654        label: "Tier".into(),
655        body: vec![if s.is_free_tier {
656            "free tier".into()
657        } else {
658            "paid tier".into()
659        }],
660    });
661    v
662}
663
664/// Antigravity holds two independent pools (Gemini, Claude & GPT OSS), each
665/// with a 5-hour and a weekly window. Grouped by window type so the two pools
666/// sit side by side, matching the GNOME dropdown.
667fn antigravity_sections(
668    s: &crate::usage::AntigravitySnapshot,
669    now: DateTime<Utc>,
670) -> SectionBuilder {
671    use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
672
673    let mut v = SectionBuilder::new(vec![Section::Title {
674        left: s.plan.clone(),
675        right: None,
676    }]);
677    for (heading, primary, third_party) in [
678        ("Session", &s.session, s.third_party_session.as_ref()),
679        ("Weekly", &s.weekly, s.third_party_weekly.as_ref()),
680    ] {
681        v.push(Section::Spacer);
682        v.push(Section::Text {
683            label: heading.into(),
684            value: String::new(),
685        });
686        push_window(&mut v, GROUP_PRIMARY, primary, now, 5, false);
687        if let Some(w) = third_party {
688            push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
689        }
690    }
691    v
692}
693
694fn cursor_sections(s: &crate::usage::CursorSnapshot, now: DateTime<Utc>) -> SectionBuilder {
695    let mut v = SectionBuilder::new(vec![Section::Title {
696        left: format!("Cursor {}", s.plan),
697        right: None,
698    }]);
699    if s.unlimited {
700        v.push(Section::Spacer);
701        v.push(Section::Text {
702            label: "Plan".into(),
703            value: "Unlimited — pools don't cap".into(),
704        });
705    } else {
706        // Two included-usage pools, mirroring the dashboard's two bars.
707        v.push(Section::Spacer);
708        v.push_metric(
709            Section::Metric {
710                label: "Cursor Models".into(),
711                pct: s.auto_pct.clamp(0, 100) as u16,
712                severity: severity_for(s.auto_pct),
713                value_label: format!("{}%", s.auto_pct),
714                footnote: "Auto + Composer".into(),
715            },
716            s.reset_at,
717        );
718        v.push(Section::Spacer);
719        v.push_metric(
720            Section::Metric {
721                label: "Other Models".into(),
722                pct: s.api_pct.clamp(0, 100) as u16,
723                severity: severity_for(s.api_pct),
724                value_label: format!("{}%", s.api_pct),
725                footnote: format!(
726                    "Named / API models · on-demand {}",
727                    if s.on_demand_enabled { "on" } else { "off" }
728                ),
729            },
730            s.reset_at,
731        );
732    }
733    v.push(Section::Spacer);
734    v.push(Section::Text {
735        label: "Resets".into(),
736        value: countdown::format(s.reset_at, now),
737    });
738    v
739}
740
741fn nous_sections(s: &crate::nous::types::AccountSnapshot, now: DateTime<Utc>) -> SectionBuilder {
742    let mut sections = SectionBuilder::new(vec![Section::Title {
743        left: "Nous Research".into(),
744        right: None,
745    }]);
746    if let Some(value) = s.usage_percent() {
747        let pct = value.round().clamp(0.0, 100.0) as i32;
748        sections.push_metric(
749            Section::Metric {
750                label: "Usage".into(),
751                pct: pct as u16,
752                severity: severity_for(pct),
753                value_label: format!("{pct}%"),
754                footnote: "current period".into(),
755            },
756            s.current_period_end,
757        );
758    }
759    sections.push(Section::Spacer);
760    if let Some(remaining) = s.credits_remaining {
761        sections.push(Section::Text {
762            label: "Subscription credits".into(),
763            value: format!("{remaining:.2} remaining"),
764        });
765    }
766    if let Some(purchased) = s.purchased_credits_remaining {
767        sections.push(Section::Text {
768            label: "Top-up credits".into(),
769            value: format!("{purchased:.2} remaining"),
770        });
771    }
772    if let Some(total_usable) = s.total_usable_credits {
773        sections.push(Section::Text {
774            label: "Total usable credits".into(),
775            value: format!("{total_usable:.2}"),
776        });
777    }
778    if let Some(period_end) = s.current_period_end {
779        sections.push(Section::Text {
780            label: "Renews".into(),
781            value: countdown::format(Some(period_end), now),
782        });
783    }
784    sections
785}
786
787fn commandcode_sections(
788    s: &crate::commandcode::types::Snapshot,
789    now: DateTime<Utc>,
790) -> SectionBuilder {
791    let title = match s.plan.as_deref() {
792        Some(plan) if !plan.is_empty() => format!("Command Code {plan}"),
793        _ => "Command Code".to_string(),
794    };
795    let mut sections = SectionBuilder::new(vec![Section::Title {
796        left: title,
797        right: None,
798    }]);
799    for (label, window) in [
800        ("Session (5h)", s.five_hour.as_ref()),
801        ("Weekly", s.weekly.as_ref()),
802    ] {
803        if let Some(window) = window {
804            let pct = window.pct();
805            sections.push_metric(
806                Section::Metric {
807                    label: label.into(),
808                    pct: pct.clamp(0, 100) as u16,
809                    severity: severity_for(pct),
810                    value_label: format!("{pct}%"),
811                    footnote: format!("{} of {}", usd(window.used), usd(window.cap)),
812                },
813                window.resets_at,
814            );
815            sections.push(Section::Text {
816                label: "Resets".into(),
817                value: countdown::format(window.resets_at, now),
818            });
819        }
820    }
821    if let Some(credits) = s.credits.as_ref() {
822        sections.push(Section::Spacer);
823        let footnote = match (s.credits_spent(), s.credit_pool) {
824            (Some(spent), Some(pool)) => format!("{} of {} spent", usd(spent), usd(pool)),
825            _ => String::new(),
826        };
827        sections.push(Section::Text {
828            label: "Credits".into(),
829            value: if footnote.is_empty() {
830                usd(credits.remaining())
831            } else {
832                format!("{} · {footnote}", usd(credits.remaining()))
833            },
834        });
835    }
836    sections
837}
838
839fn opencode_go_sections(
840    s: &crate::opencode_go::types::Usage,
841    now: DateTime<Utc>,
842) -> SectionBuilder {
843    let mut sections = SectionBuilder::new(vec![Section::Title {
844        left: "OpenCode Go".into(),
845        right: None,
846    }]);
847    for (label, window) in [
848        ("Rolling", s.rolling.as_ref()),
849        ("Weekly", s.weekly.as_ref()),
850        ("Monthly", s.monthly.as_ref()),
851    ] {
852        if let Some(window) = window {
853            let pct = window.percent.round().clamp(0.0, 100.0) as i32;
854            sections.push_metric(
855                Section::Metric {
856                    label: label.into(),
857                    pct: pct as u16,
858                    severity: severity_for(pct),
859                    value_label: format!("{pct}%"),
860                    footnote: String::new(),
861                },
862                Some(window.resets_at),
863            );
864            sections.push(Section::Text {
865                label: "Resets".into(),
866                value: countdown::format(Some(window.resets_at), now),
867            });
868        }
869    }
870    sections
871}
872
873/// Kiro has a single credit pool, so the panel is a single metric bar plus
874/// the reset row — the same shape as `anthropic_api_sections` but with a
875/// real percentage (Kiro always reports both used and limit) instead of an
876/// optional configured one.
877fn kiro_sections(s: &crate::usage::KiroSnapshot, now: DateTime<Utc>) -> SectionBuilder {
878    let pct = s.pct();
879    let mut v = SectionBuilder::new(vec![
880        Section::Title {
881            left: format!("Kiro {}", s.plan),
882            right: None,
883        },
884        Section::Spacer,
885    ]);
886    v.push_metric(
887        Section::Metric {
888            label: "Credits".into(),
889            pct: pct.clamp(0, 100) as u16,
890            severity: severity_for(pct),
891            value_label: format!("{pct}%"),
892            footnote: format!("{:.2} of {:.0}", s.used, s.limit),
893        },
894        s.reset_at,
895    );
896    v.push(Section::Spacer);
897    v.push(Section::Text {
898        label: "Resets".into(),
899        value: countdown::format(s.reset_at, now),
900    });
901    v
902}
903
904/// MiniMax groups quota by model bucket, so the panel is laid out by window
905/// (Session, Weekly) with one row per pool — the same shape as Antigravity's
906/// two-group panel. Pacing is shown: both windows report a real duration, so
907/// the marker is meaningful.
908fn minimax_sections(
909    s: &crate::usage::MinimaxSnapshot,
910    now: DateTime<Utc>,
911    tol: u32,
912) -> SectionBuilder {
913    use crate::minimax::vendor::{POOL_GENERAL, POOL_VIDEO};
914
915    let mut v = SectionBuilder::new(vec![Section::Title {
916        left: s.plan.clone(),
917        right: None,
918    }]);
919    for (heading, general, video) in [
920        ("Session", &s.session, s.video_session.as_ref()),
921        ("Weekly", &s.weekly, s.video_weekly.as_ref()),
922    ] {
923        v.push(Section::Spacer);
924        v.push(Section::Text {
925            label: heading.into(),
926            value: String::new(),
927        });
928        push_window(&mut v, POOL_GENERAL, general, now, tol, true);
929        if let Some(w) = video {
930            push_window(&mut v, POOL_VIDEO, w, now, tol, true);
931        }
932    }
933    v
934}
935
936fn kilo_sections(s: &crate::usage::KiloSnapshot) -> SectionBuilder {
937    SectionBuilder::new(vec![
938        Section::Title {
939            left: s.label.clone(),
940            right: None,
941        },
942        Section::Spacer,
943        Section::Text {
944            label: "Balance".into(),
945            value: usd(s.balance),
946        },
947    ])
948}
949
950fn novita_sections(s: &crate::usage::NovitaSnapshot) -> SectionBuilder {
951    let mut v = SectionBuilder::new(vec![
952        Section::Title {
953            left: "Novita".into(),
954            right: None,
955        },
956        Section::Spacer,
957        Section::Text {
958            label: "Balance".into(),
959            value: usd(s.available),
960        },
961        Section::Block {
962            label: "Breakdown".into(),
963            body: vec![format!(
964                "top-up ${:.2} · credit limit ${:.2}",
965                s.cash, s.credit_limit
966            )],
967        },
968    ]);
969    if s.outstanding > 0.0 {
970        v.push(Section::Spacer);
971        v.push(Section::Block {
972            label: "Owed".into(),
973            body: vec![usd(s.outstanding)],
974        });
975    }
976    v
977}
978
979fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> SectionBuilder {
980    let cur = &s.currency;
981    let fmt = |v: f64| money(v, cur);
982    SectionBuilder::new(vec![
983        Section::Title {
984            left: "Kimi (Moonshot)".into(),
985            right: None,
986        },
987        Section::Spacer,
988        Section::Text {
989            label: "Balance".into(),
990            value: fmt(s.available),
991        },
992        Section::Block {
993            label: "Breakdown".into(),
994            body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
995        },
996    ])
997}
998
999fn grok_sections(s: &crate::usage::GrokSnapshot) -> SectionBuilder {
1000    SectionBuilder::new(vec![
1001        Section::Title {
1002            left: "Grok (xAI)".into(),
1003            right: None,
1004        },
1005        Section::Spacer,
1006        Section::Text {
1007            label: "Prepaid balance".into(),
1008            value: usd(s.balance),
1009        },
1010    ])
1011}
1012
1013fn supergrok_sections(s: &crate::usage::SuperGrokSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1014    let pct = s.weekly_pct;
1015    let mut v = SectionBuilder::new(vec![
1016        Section::Title {
1017            left: s.plan.clone(),
1018            right: None,
1019        },
1020        Section::Spacer,
1021    ]);
1022    v.push_metric(
1023        Section::Metric {
1024            label: format!("{} Build credits", s.period.label()),
1025            pct: pct.clamp(0, 100) as u16,
1026            severity: severity_for(pct),
1027            value_label: format!("{pct}%"),
1028            footnote: String::new(),
1029        },
1030        s.reset_at,
1031    );
1032    v.push(Section::Spacer);
1033    v.push(Section::Text {
1034        label: "Resets".into(),
1035        value: countdown::format(s.reset_at, now),
1036    });
1037    if let Some(bal) = s.prepaid_balance {
1038        v.push(Section::Spacer);
1039        v.push(Section::Text {
1040            label: "Prepaid API".into(),
1041            value: usd(bal),
1042        });
1043    }
1044    v
1045}
1046
1047fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> SectionBuilder {
1048    let currency = &s.currency;
1049    let fmt = |v: f64| money(v, currency);
1050    let avail = if s.is_available {
1051        "available"
1052    } else {
1053        "unavailable"
1054    };
1055    let mut v = SectionBuilder::new(vec![Section::Title {
1056        left: "DeepSeek".into(),
1057        right: None,
1058    }]);
1059    v.push(Section::Spacer);
1060    v.push(Section::Text {
1061        label: "Balance".into(),
1062        value: fmt(s.balance),
1063    });
1064    v.push(Section::Block {
1065        label: "Breakdown".into(),
1066        body: vec![format!(
1067            "granted {} · topped-up {}",
1068            fmt(s.granted),
1069            fmt(s.topped_up)
1070        )],
1071    });
1072    v.push(Section::Spacer);
1073    v.push(Section::Block {
1074        label: "API".into(),
1075        body: vec![avail.into()],
1076    });
1077    v
1078}
1079
1080fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> SectionBuilder {
1081    let plan = s.plan.as_deref().unwrap_or("Kimi");
1082    let mut v = SectionBuilder::new(vec![Section::Title {
1083        left: plan.into(),
1084        right: None,
1085    }]);
1086
1087    let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
1088    v.push(Section::Spacer);
1089    v.push_metric(
1090        Section::Metric {
1091            label: "Weekly quota".into(),
1092            pct: weekly_pct,
1093            severity: severity_for(s.weekly_pct()),
1094            // Same `{pct}%` every other vendor shows; the request counts stay
1095            // on the footnote.
1096            value_label: format!("{weekly_pct}%"),
1097            footnote: format!(
1098                "{} / {} · {} remaining · reset {}",
1099                s.weekly_used,
1100                s.weekly_limit,
1101                s.weekly_remaining,
1102                countdown::format(s.weekly_reset_at, now)
1103            ),
1104        },
1105        s.weekly_reset_at,
1106    );
1107
1108    if s.window_limit > 0 {
1109        let window_pct = s.window_pct().clamp(0, 100) as u16;
1110        v.push(Section::Spacer);
1111        v.push_metric(
1112            Section::Metric {
1113                label: "Rolling window (5h)".into(),
1114                pct: window_pct,
1115                severity: severity_for(s.window_pct()),
1116                value_label: format!("{window_pct}%"),
1117                footnote: format!(
1118                    "{} / {} · {} remaining · reset {}",
1119                    s.window_used,
1120                    s.window_limit,
1121                    s.window_remaining,
1122                    countdown::format(s.window_reset_at, now)
1123                ),
1124            },
1125            s.window_reset_at,
1126        );
1127    }
1128
1129    v
1130}
1131
1132fn push_window(
1133    sections: &mut SectionBuilder,
1134    label: &str,
1135    w: &crate::usage::UsageWindow,
1136    now: DateTime<Utc>,
1137    tol: u32,
1138    show_pacing: bool,
1139) {
1140    let pct = w.utilization_pct.clamp(0, 100) as u16;
1141    let reset_text = countdown::format(w.resets_at, now);
1142    let footnote = if show_pacing {
1143        let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
1144        format!(
1145            "Resets in {} · {}% elapsed · {}",
1146            reset_text, p.elapsed_pct, p.point_label
1147        )
1148    } else {
1149        format!("Resets in {}", reset_text)
1150    };
1151    sections.push(Section::Spacer);
1152    sections.push_metric(
1153        Section::Metric {
1154            label: label.into(),
1155            pct,
1156            severity: severity_for(pct as i32),
1157            value_label: format!("{pct}%"),
1158            footnote,
1159        },
1160        w.resets_at,
1161    );
1162}
1163
1164/// Render the given sections into `area`. Lays them out vertically; metric
1165/// rows take 2 lines (label+gauge / footnote), text and spacer rows take 1.
1166///
1167/// The trailing "Updated …" footer is detected (the last `Text` section)
1168/// and pinned to the bottom of the area, with the slack absorbed *between*
1169/// content and footer. This way shorter vendor panels (OpenRouter, Z.AI)
1170/// don't leave a giant gap below the footer.
1171pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
1172    if sections.is_empty() {
1173        return;
1174    }
1175    let bubble = bubble_theme(theme);
1176    // Heuristic: if the last section is a Text starting with "  Updated",
1177    // pin it to the bottom. Otherwise just lay everything out top-down.
1178    let pin_last =
1179        matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
1180
1181    let body_end = if pin_last {
1182        sections.len() - 1
1183    } else {
1184        sections.len()
1185    };
1186    let mut constraints: Vec<Constraint> =
1187        sections[..body_end].iter().map(section_height).collect();
1188
1189    if pin_last {
1190        constraints.push(Constraint::Min(0)); // slack between body and footer
1191        constraints.push(section_height(sections.last().unwrap()));
1192    } else {
1193        constraints.push(Constraint::Min(0));
1194    }
1195
1196    let chunks = Layout::default()
1197        .direction(ratatui::layout::Direction::Vertical)
1198        .constraints(constraints)
1199        .split(area);
1200
1201    for (i, s) in sections[..body_end].iter().enumerate() {
1202        render_section(f, chunks[i], theme, &bubble, s);
1203    }
1204    if pin_last {
1205        render_section(
1206            f,
1207            chunks[chunks.len() - 1],
1208            theme,
1209            &bubble,
1210            sections.last().unwrap(),
1211        );
1212    }
1213}
1214
1215fn section_height(s: &Section) -> Constraint {
1216    match s {
1217        Section::Title { .. } => Constraint::Length(2),
1218        Section::Metric { .. } => Constraint::Length(3),
1219        Section::Text { .. } => Constraint::Length(1),
1220        Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
1221        Section::Spacer => Constraint::Length(1),
1222    }
1223}
1224
1225fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
1226    match s {
1227        Section::Title { left, right } => {
1228            // Left: bold accent-colored plan/vendor label. Right: dim-styled
1229            // "Updated HH:MM:SS" pinned to the right edge of the title row.
1230            let left_line = Line::from(Span::styled(
1231                format!("  {} {left}", bubble.symbols.selected),
1232                bubble.title,
1233            ));
1234            f.render_widget(Paragraph::new(left_line), area);
1235            if let Some(rt) = right {
1236                let right_line =
1237                    Line::from(Span::styled(format!("{rt}  "), bubble.muted)).right_aligned();
1238                f.render_widget(Paragraph::new(right_line), area);
1239            }
1240        }
1241        Section::Metric {
1242            label,
1243            pct,
1244            severity,
1245            value_label,
1246            footnote,
1247        } => render_metric(
1248            f,
1249            area,
1250            theme,
1251            bubble,
1252            label,
1253            *pct,
1254            *severity,
1255            value_label,
1256            footnote,
1257        ),
1258        Section::Text { label, value } => {
1259            if label.is_empty() && value.contains("Loading") {
1260                render_loading(f, area, bubble);
1261                return;
1262            }
1263            if label == "Error" {
1264                let line = Line::from(vec![
1265                    bubble.error(format!("  {} ", bubble.symbols.cross)),
1266                    Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
1267                ]);
1268                f.render_widget(Paragraph::new(line), area);
1269                return;
1270            }
1271            let mut spans = Vec::new();
1272            if !label.is_empty() {
1273                spans.push(Span::styled(
1274                    format!("  {label}  "),
1275                    bubble.text.add_modifier(Modifier::BOLD),
1276                ));
1277            }
1278            spans.push(Span::styled(value.clone(), bubble.muted));
1279            f.render_widget(Paragraph::new(Line::from(spans)), area);
1280        }
1281        Section::Block { label, body } => render_block(f, area, bubble, label, body),
1282        Section::Spacer => {}
1283    }
1284}
1285
1286fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
1287    let frames = SpinnerFrames::DOTS;
1288    let frame_count = frames.frames().len().max(1);
1289    let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
1290    let mut spinner = Spinner::new()
1291        .frames(frames)
1292        .label("Fetching usage data")
1293        .theme(*bubble);
1294    for _ in 0..(frame % frame_count) {
1295        spinner.tick();
1296    }
1297    f.render_widget(&spinner, area);
1298}
1299
1300#[allow(clippy::too_many_arguments)]
1301fn render_metric(
1302    f: &mut Frame,
1303    area: Rect,
1304    theme: &Theme,
1305    bubble: &BubbleTheme,
1306    label: &str,
1307    pct: u16,
1308    severity: PaceSeverity,
1309    value_label: &str,
1310    footnote: &str,
1311) {
1312    let bar_color = severity_color(theme, bubble, severity);
1313    let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
1314
1315    let inner = Layout::default()
1316        .direction(ratatui::layout::Direction::Vertical)
1317        .constraints([
1318            Constraint::Length(1),
1319            Constraint::Length(1),
1320            Constraint::Length(1),
1321        ])
1322        .split(area);
1323
1324    // Row 1: label
1325    let label_line = Line::from(Span::styled(
1326        format!("  {label}"),
1327        bubble.text.add_modifier(Modifier::BOLD),
1328    ));
1329    f.render_widget(Paragraph::new(label_line), inner[0]);
1330
1331    // Row 2: gauge spanning most of the width + value annotation on the right
1332    let row = inner[1];
1333    let value_w = value_label.chars().count() as u16 + 2;
1334    let gauge_area = Rect {
1335        x: row.x + 2,
1336        y: row.y,
1337        width: row.width.saturating_sub(value_w + 4),
1338        height: 1,
1339    };
1340    let value_area = Rect {
1341        x: gauge_area.x + gauge_area.width + 1,
1342        y: row.y,
1343        width: value_w,
1344        height: 1,
1345    };
1346    let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
1347    let progress = Progress::from_percent(pct)
1348        .theme(progress_theme)
1349        .show_percentage(false);
1350    f.render_widget(&progress, gauge_area);
1351    let value = Paragraph::new(Line::from(Span::styled(
1352        value_label.to_string(),
1353        Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
1354    )));
1355    f.render_widget(value, value_area);
1356
1357    // Row 3: footnote (dim)
1358    let foot = Line::from(Span::styled(format!("    {footnote}"), bubble.muted));
1359    f.render_widget(Paragraph::new(foot), inner[2]);
1360}
1361
1362fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
1363    let mut lines = vec![Line::from(Span::styled(
1364        format!("  {label}"),
1365        bubble.text.add_modifier(Modifier::BOLD),
1366    ))];
1367    for b in body {
1368        lines.push(Line::from(Span::styled(format!("    {b}"), bubble.muted)));
1369    }
1370    f.render_widget(Paragraph::new(lines), area);
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    use super::*;
1376    use crate::usage::{
1377        AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
1378        OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
1379    };
1380    use chrono::TimeZone;
1381
1382    fn now() -> DateTime<Utc> {
1383        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
1384    }
1385
1386    fn ready(snapshot: VendorSnapshot) -> TabState {
1387        TabState::Ready(Box::new(crate::tui::app::ReadyTab {
1388            snapshot,
1389            stale: false,
1390            last_error: None,
1391            fetched_at: Some(now() - chrono::Duration::seconds(15)),
1392        }))
1393    }
1394
1395    #[test]
1396    fn anthropic_sections_include_all_three_windows_when_present() {
1397        let snap = AnthropicSnapshot {
1398            plan: "Max 20x".into(),
1399            session: UsageWindow {
1400                utilization_pct: 60,
1401                resets_at: Some(now() + chrono::Duration::hours(1)),
1402                window_duration: chrono::Duration::hours(5),
1403            },
1404            weekly: UsageWindow {
1405                utilization_pct: 30,
1406                resets_at: Some(now() + chrono::Duration::days(3)),
1407                window_duration: chrono::Duration::days(7),
1408            },
1409            sonnet: Some(UsageWindow {
1410                utilization_pct: 5,
1411                resets_at: Some(now() + chrono::Duration::hours(2)),
1412                window_duration: chrono::Duration::days(7),
1413            }),
1414            scoped: vec![],
1415            extra: Some(ExtraUsage {
1416                limit: Some(Cents(5000)),
1417                spent: Cents(250),
1418                currency: None,
1419                decimal_places: Some(2),
1420            }),
1421        };
1422        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1423        // Title (carries "Updated …" inline now) + 4 metrics (3 windows +
1424        // extra) each preceded by a Spacer. 1 + 4*2 = 9 sections.
1425        assert_eq!(sections.len(), 9);
1426        assert!(matches!(sections[0], Section::Title { .. }));
1427        // Title's right-aligned slot should carry the timestamp.
1428        if let Section::Title { right, .. } = &sections[0] {
1429            assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
1430        } else {
1431            panic!("expected first section to be Title");
1432        }
1433        let metric_count = sections
1434            .iter()
1435            .filter(|s| matches!(s, Section::Metric { .. }))
1436            .count();
1437        assert_eq!(metric_count, 4);
1438    }
1439
1440    #[test]
1441    fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
1442        // The #30 shape: `monthly_limit: null` (Pro). The panel must show the
1443        // spend alone — not "of $0.00", not an invented percentage.
1444        let snap = AnthropicSnapshot {
1445            plan: "Pro".into(),
1446            session: UsageWindow {
1447                utilization_pct: 10,
1448                resets_at: None,
1449                window_duration: chrono::Duration::hours(5),
1450            },
1451            weekly: UsageWindow {
1452                utilization_pct: 20,
1453                resets_at: None,
1454                window_duration: chrono::Duration::days(7),
1455            },
1456            sonnet: None,
1457            scoped: vec![],
1458            extra: Some(ExtraUsage {
1459                limit: None,
1460                spent: Cents(14157),
1461                currency: Some("BRL".into()),
1462                decimal_places: Some(2),
1463            }),
1464        };
1465        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1466        let extra = sections
1467            .iter()
1468            .find_map(|s| match s {
1469                Section::Metric {
1470                    label,
1471                    pct,
1472                    value_label,
1473                    footnote,
1474                    ..
1475                } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
1476                _ => None,
1477            })
1478            .expect("uncapped extra usage must still render a section");
1479        assert_eq!(extra.0, 0);
1480        // Non-vacuous currency pin: fmt_dollars would say "$141.57" here.
1481        assert_eq!(extra.1, "R$141.57");
1482        assert!(
1483            !extra.1.contains(" of "),
1484            "no denominator to show: {}",
1485            extra.1
1486        );
1487        assert_eq!(extra.2, "no monthly limit reported");
1488    }
1489
1490    #[test]
1491    fn anthropic_omits_sonnet_and_extra_when_absent() {
1492        let snap = AnthropicSnapshot {
1493            plan: "Pro".into(),
1494            session: UsageWindow {
1495                utilization_pct: 10,
1496                resets_at: None,
1497                window_duration: chrono::Duration::hours(5),
1498            },
1499            weekly: UsageWindow {
1500                utilization_pct: 5,
1501                resets_at: None,
1502                window_duration: chrono::Duration::days(7),
1503            },
1504            sonnet: None,
1505            scoped: vec![],
1506            extra: None,
1507        };
1508        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1509        let metric_count = sections
1510            .iter()
1511            .filter(|s| matches!(s, Section::Metric { .. }))
1512            .count();
1513        assert_eq!(metric_count, 2);
1514    }
1515
1516    #[test]
1517    fn openrouter_always_has_balance_metric_and_period_block() {
1518        let snap = OpenRouterSnapshot {
1519            label: "OR".into(),
1520            total_credits: 100.0,
1521            total_usage: 25.0,
1522            usage_daily: 1.0,
1523            usage_weekly: 5.0,
1524            usage_monthly: 25.0,
1525            is_free_tier: false,
1526            limit: None,
1527            limit_remaining: None,
1528        };
1529        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
1530        assert!(matches!(sections[0], Section::Title { .. }));
1531        assert!(
1532            sections
1533                .iter()
1534                .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
1535        );
1536        assert!(
1537            sections
1538                .iter()
1539                .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
1540        );
1541    }
1542
1543    /// #118 reached every frontend, not just Waybar: the panel row is what the
1544    /// Omarchy, GNOME and KDE plugins colour and label from, so the debt has to
1545    /// survive the projection with its sign and its severity intact.
1546    #[test]
1547    fn openrouter_debt_reaches_the_panel_row_red_and_signed() {
1548        let snap = OpenRouterSnapshot {
1549            label: "OR".into(),
1550            total_credits: 0.0,
1551            total_usage: 5.71,
1552            usage_daily: 1.0,
1553            usage_weekly: 5.0,
1554            usage_monthly: 5.71,
1555            is_free_tier: false,
1556            limit: None,
1557            limit_remaining: None,
1558        };
1559        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap.clone())), now(), 5);
1560        let metric = sections
1561            .iter()
1562            .find_map(|s| match s {
1563                Section::Metric {
1564                    label,
1565                    value_label,
1566                    severity,
1567                    footnote,
1568                    ..
1569                } if label == "Credit balance" => Some((value_label, severity, footnote)),
1570                _ => None,
1571            })
1572            .expect("no credit balance metric");
1573        assert_eq!(metric.0, "-$5.71");
1574        assert_eq!(*metric.1, PaceSeverity::Critical);
1575        assert_eq!(metric.2, "$5.71 of $0.00 used (0%)");
1576
1577        // ...and the same number in the dense Overview list.
1578        let (_, cells) = compact_cells(&VendorSnapshot::Openrouter(snap));
1579        assert_eq!(cells[0].0, "-$5.71");
1580    }
1581
1582    /// The panels express pace as a footnote on the row; the arrow is the
1583    /// widget's idiom and the bar tick the menu bar's. All three Z.AI windows
1584    /// report a duration and a reset, so all three carry one.
1585    #[test]
1586    fn zai_windows_are_paced_like_every_other_percentage_vendor() {
1587        let window = |pct: i32, hours: i64, span: chrono::Duration| crate::usage::UsageWindow {
1588            utilization_pct: pct,
1589            resets_at: Some(now() + chrono::Duration::hours(hours)),
1590            window_duration: span,
1591        };
1592        let snap = ZaiSnapshot {
1593            plan: "GLM Coding Pro".into(),
1594            session: Some(window(40, 2, chrono::Duration::hours(5))),
1595            weekly: Some(window(60, 48, chrono::Duration::days(7))),
1596            mcp: Some(window(10, 200, chrono::Duration::days(30))),
1597        };
1598
1599        let footnotes: Vec<String> = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5)
1600            .into_iter()
1601            .filter_map(|section| match section {
1602                Section::Metric { footnote, .. } => Some(footnote),
1603                _ => None,
1604            })
1605            .collect();
1606
1607        assert_eq!(footnotes.len(), 3, "{footnotes:?}");
1608        for footnote in &footnotes {
1609            assert!(footnote.contains("% elapsed"), "{footnote}");
1610        }
1611        // 40% used with 60% of a 5h window gone: behind pace, not ahead.
1612        assert_eq!(
1613            footnotes[0], "Resets in 2h 00m · 60% elapsed · 20pts under",
1614            "{footnotes:?}"
1615        );
1616    }
1617
1618    #[test]
1619    fn zai_no_windows_renders_message() {
1620        let snap = ZaiSnapshot {
1621            plan: "GLM".into(),
1622            session: None,
1623            weekly: None,
1624            mcp: None,
1625        };
1626        let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
1627        assert!(sections.iter().any(|s| matches!(
1628            s,
1629            Section::Text { value, .. } if value.contains("no usage windows reported")
1630        )));
1631    }
1632
1633    #[test]
1634    fn openai_no_windows_renders_message() {
1635        let snap = OpenAiSnapshot {
1636            plan: "ChatGPT Plus".into(),
1637            session: None,
1638            weekly: None,
1639            code_review: None,
1640            credits: None,
1641            source: OpenAiSource::CodexOauth,
1642        };
1643        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1644        assert!(sections.iter().any(|s| matches!(
1645            s,
1646            Section::Text { value, .. } if value.contains("no usage windows reported")
1647        )));
1648    }
1649
1650    #[test]
1651    fn loading_state_yields_loading_section() {
1652        let sections = sections_for(&TabState::Loading, now(), 5);
1653        assert!(sections.iter().any(|s| matches!(
1654            s,
1655            Section::Text { value, .. } if value.contains("Loading")
1656        )));
1657    }
1658
1659    #[test]
1660    fn error_state_includes_retry_hint() {
1661        let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
1662        assert!(sections.iter().any(|s| matches!(
1663            s,
1664            Section::Text { value, .. } if value.contains("token expired")
1665        )));
1666        assert!(sections.iter().any(|s| matches!(
1667            s,
1668            Section::Text { value, .. } if value.contains("`r` to retry")
1669        )));
1670    }
1671
1672    #[test]
1673    fn openai_with_credits_renders_block() {
1674        let snap = OpenAiSnapshot {
1675            plan: "ChatGPT Plus".into(),
1676            session: Some(UsageWindow {
1677                utilization_pct: 1,
1678                resets_at: None,
1679                window_duration: chrono::Duration::hours(5),
1680            }),
1681            weekly: Some(UsageWindow {
1682                utilization_pct: 0,
1683                resets_at: None,
1684                window_duration: chrono::Duration::days(7),
1685            }),
1686            code_review: None,
1687            credits: Some(OpenAiCredits {
1688                balance: "$5.00".into(),
1689                has_credits: true,
1690                unlimited: false,
1691                approx_local_messages: Some((100, 200)),
1692                approx_cloud_messages: Some((30, 50)),
1693            }),
1694            source: OpenAiSource::CodexOauth,
1695        };
1696        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1697        assert!(
1698            sections
1699                .iter()
1700                .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
1701        );
1702    }
1703
1704    #[test]
1705    fn openai_weekly_only_omits_session_section() {
1706        let snap = OpenAiSnapshot {
1707            plan: "ChatGPT Prolite".into(),
1708            session: None,
1709            weekly: Some(UsageWindow {
1710                utilization_pct: 66,
1711                resets_at: None,
1712                window_duration: chrono::Duration::days(7),
1713            }),
1714            code_review: None,
1715            credits: None,
1716            source: OpenAiSource::CodexOauth,
1717        };
1718        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
1719        assert!(sections.iter().any(|section| matches!(
1720            section,
1721            Section::Metric { label, .. } if label == "Codex weekly"
1722        )));
1723        assert!(!sections.iter().any(|section| matches!(
1724            section,
1725            Section::Metric { label, .. } if label == "Codex 5h"
1726        )));
1727    }
1728
1729    #[test]
1730    fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
1731        let now = now();
1732        let snap = KimiSnapshot {
1733            plan: Some("LEVEL_INTERMEDIATE".into()),
1734            weekly_limit: 100,
1735            weekly_used: 26,
1736            weekly_remaining: 74,
1737            weekly_reset_at: Some(now + chrono::Duration::days(4)),
1738            window_limit: 100,
1739            window_used: 15,
1740            window_remaining: 85,
1741            window_reset_at: Some(now + chrono::Duration::hours(2)),
1742        };
1743        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
1744        let metrics: Vec<_> = sections
1745            .iter()
1746            .filter(|s| matches!(s, Section::Metric { .. }))
1747            .collect();
1748        assert_eq!(metrics.len(), 2);
1749        assert!(sections.iter().any(|s| matches!(
1750            s,
1751            Section::Metric { label, .. } if label == "Weekly quota"
1752        )));
1753        assert!(sections.iter().any(|s| matches!(
1754            s,
1755            Section::Metric { label, .. } if label == "Rolling window (5h)"
1756        )));
1757
1758        let find_footnote = |label: &str| -> (String, String) {
1759            sections
1760                .iter()
1761                .find_map(|s| match s {
1762                    Section::Metric {
1763                        label: l,
1764                        value_label,
1765                        footnote,
1766                        ..
1767                    } if l == label => Some((value_label.clone(), footnote.clone())),
1768                    _ => None,
1769                })
1770                .unwrap_or_else(|| panic!("missing metric {label}"))
1771        };
1772
1773        let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
1774        assert_eq!(weekly_value, "26%");
1775        assert!(weekly_footnote.contains("26 / 100"));
1776        assert!(weekly_footnote.contains("74 remaining"));
1777        assert!(
1778            weekly_footnote.contains("4d 0h"),
1779            "weekly reset countdown: {weekly_footnote}"
1780        );
1781        assert!(!weekly_footnote.contains("2026-05-27T")); // not a raw RFC3339
1782
1783        let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
1784        assert_eq!(window_value, "15%");
1785        assert!(window_footnote.contains("15 / 100"));
1786        assert!(window_footnote.contains("85 remaining"));
1787        assert!(
1788            window_footnote.contains("2h 00m"),
1789            "window reset countdown: {window_footnote}"
1790        );
1791        assert!(!window_footnote.contains("2026-05-23T14")); // not a raw RFC3339
1792    }
1793
1794    #[test]
1795    fn kimi_sections_omit_window_when_limit_zero() {
1796        let snap = KimiSnapshot {
1797            plan: None,
1798            weekly_limit: 100,
1799            weekly_used: 10,
1800            weekly_remaining: 90,
1801            weekly_reset_at: None,
1802            window_limit: 0,
1803            window_used: 0,
1804            window_remaining: 0,
1805            window_reset_at: None,
1806        };
1807        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
1808        let metric_count = sections
1809            .iter()
1810            .filter(|s| matches!(s, Section::Metric { .. }))
1811            .count();
1812        assert_eq!(metric_count, 1);
1813    }
1814
1815    fn cursor_snap() -> crate::usage::CursorSnapshot {
1816        crate::usage::CursorSnapshot {
1817            plan: "Ultra".into(),
1818            auto_pct: 98,
1819            api_pct: 100,
1820            total_pct: 99,
1821            unlimited: false,
1822            on_demand_enabled: false,
1823            reset_at: Some(now() + chrono::Duration::days(9)),
1824        }
1825    }
1826
1827    #[test]
1828    fn compact_cells_flatten_key_metrics_for_the_overview() {
1829        // Percent vendor (Cursor): plan + two colored pool cells.
1830        let (plan, cells) = compact_cells(&VendorSnapshot::Cursor(cursor_snap()));
1831        assert_eq!(plan, "Ultra");
1832        assert_eq!(cells[0].0, "auto 98%");
1833        assert_eq!(cells[1].0, "premium 100%");
1834        assert_eq!(cells[1].1, PaceSeverity::Critical); // 100% is critical
1835
1836        // Balance vendor (Kilo): no plan, a single money cell, calm severity.
1837        let (plan, cells) = compact_cells(&VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1838            label: "Kilo".into(),
1839            balance: 8.42,
1840        }));
1841        assert!(plan.is_empty());
1842        assert_eq!(cells, vec![("$8.42".to_string(), PaceSeverity::Low)]);
1843    }
1844
1845    #[test]
1846    fn terminal_controls_are_removed_from_detail_and_overview_fields() {
1847        let error = TabState::Error("bad\x1b]52;c;Y2FuYXJ5\x07 value".into());
1848        let sections = sections_for(&error, now(), 5);
1849        assert!(matches!(
1850            &sections[1],
1851            Section::Text { value, .. }
1852                if value == "bad]52;c;Y2FuYXJ5 value"
1853                    && !value.chars().any(|ch| ch.is_control())
1854        ));
1855
1856        let mut snapshot = cursor_snap();
1857        snapshot.plan = "Ultra\x1b[2J\x07".into();
1858        let (plan, _) = compact_cells(&VendorSnapshot::Cursor(snapshot));
1859        assert_eq!(plan, "Ultra[2J");
1860        assert!(!plan.chars().any(char::is_control));
1861    }
1862
1863    #[test]
1864    fn headline_pct_is_the_worst_window_or_combined_total() {
1865        // Cursor: the combined total, not the worse pool (mirrors the menu bar).
1866        assert_eq!(
1867            headline_pct(&VendorSnapshot::Cursor(cursor_snap())),
1868            Some(99)
1869        );
1870
1871        // Balance-only vendors have no meaningful percentage → no bar.
1872        let kilo = VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
1873            label: "Kilo".into(),
1874            balance: 8.42,
1875        });
1876        assert_eq!(headline_pct(&kilo), None);
1877    }
1878
1879    #[test]
1880    fn cursor_sections_show_both_pools_and_reset() {
1881        let sections = sections_for(&ready(VendorSnapshot::Cursor(cursor_snap())), now(), 5);
1882        let metrics: Vec<_> = sections
1883            .iter()
1884            .filter_map(|s| match s {
1885                Section::Metric {
1886                    label, value_label, ..
1887                } => Some((label.clone(), value_label.clone())),
1888                _ => None,
1889            })
1890            .collect();
1891        assert_eq!(metrics.len(), 2, "two pools");
1892        assert!(
1893            metrics
1894                .iter()
1895                .any(|(l, v)| l == "Cursor Models" && v == "98%")
1896        );
1897        assert!(
1898            metrics
1899                .iter()
1900                .any(|(l, v)| l == "Other Models" && v == "100%")
1901        );
1902        assert!(sections.iter().any(|s| matches!(
1903            s,
1904            Section::Text { label, value } if label == "Resets" && value.contains("9d")
1905        )));
1906    }
1907
1908    #[test]
1909    fn cursor_unlimited_plan_shows_no_pool_bars() {
1910        let mut snap = cursor_snap();
1911        snap.unlimited = true;
1912        let sections = sections_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
1913        let metric_count = sections
1914            .iter()
1915            .filter(|s| matches!(s, Section::Metric { .. }))
1916            .count();
1917        assert_eq!(metric_count, 0);
1918        assert!(sections.iter().any(|s| matches!(
1919            s,
1920            Section::Text { value, .. } if value.contains("Unlimited")
1921        )));
1922    }
1923
1924    fn kiro_snap() -> crate::usage::KiroSnapshot {
1925        crate::usage::KiroSnapshot {
1926            plan: "KIRO POWER".into(),
1927            used: 9943.38,
1928            limit: 10000.0,
1929            reset_at: Some(now() + chrono::Duration::days(1)),
1930        }
1931    }
1932
1933    #[test]
1934    fn kiro_compact_cell_shows_the_credit_percentage() {
1935        let (plan, cells) = compact_cells(&VendorSnapshot::Kiro(kiro_snap()));
1936        assert_eq!(plan, "KIRO POWER");
1937        assert_eq!(
1938            cells,
1939            vec![("credits 99%".to_string(), PaceSeverity::Critical)]
1940        );
1941    }
1942
1943    #[test]
1944    fn kiro_headline_pct_is_the_credit_percentage() {
1945        assert_eq!(headline_pct(&VendorSnapshot::Kiro(kiro_snap())), Some(99));
1946    }
1947
1948    #[test]
1949    fn kiro_sections_show_the_credit_metric_and_reset() {
1950        let sections = sections_for(&ready(VendorSnapshot::Kiro(kiro_snap())), now(), 5);
1951        let metrics: Vec<_> = sections
1952            .iter()
1953            .filter_map(|s| match s {
1954                Section::Metric {
1955                    label, value_label, ..
1956                } => Some((label.clone(), value_label.clone())),
1957                _ => None,
1958            })
1959            .collect();
1960        assert_eq!(metrics, vec![("Credits".to_string(), "99%".to_string())]);
1961        assert!(sections.iter().any(|s| matches!(
1962            s,
1963            Section::Text { label, value } if label == "Resets" && value.contains("1d")
1964        )));
1965    }
1966
1967    #[test]
1968    fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
1969        let snap = KimiSnapshot {
1970            plan: None,
1971            weekly_limit: 100,
1972            weekly_used: 10,
1973            weekly_remaining: 90,
1974            weekly_reset_at: None,
1975            window_limit: 0,
1976            window_used: 0,
1977            window_remaining: 0,
1978            window_reset_at: None,
1979        };
1980        let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
1981        let TabState::Ready(tab) = &mut schema else {
1982            unreachable!()
1983        };
1984        tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
1985        let schema_sections = sections_for(&schema, now(), 5);
1986        assert!(schema_sections.iter().any(|section| matches!(
1987            section,
1988            Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
1989        )));
1990
1991        let mut generic = ready(VendorSnapshot::Kimi(snap));
1992        let TabState::Ready(tab) = &mut generic else {
1993            unreachable!()
1994        };
1995        tab.last_error = Some((0, "cache lock unavailable".into()));
1996        let generic_sections = sections_for(&generic, now(), 5);
1997        assert!(generic_sections.iter().any(|section| matches!(
1998            section,
1999            Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
2000        )));
2001        assert!(!generic_sections.iter().any(|section| matches!(
2002            section,
2003            Section::Text { label, .. } if label.starts_with("HTTP")
2004        )));
2005
2006        let http = warning_label(
2007            &VendorSnapshot::Kimi(KimiSnapshot {
2008                plan: None,
2009                weekly_limit: 0,
2010                weekly_used: 0,
2011                weekly_remaining: 0,
2012                weekly_reset_at: None,
2013                window_limit: 0,
2014                window_used: 0,
2015                window_remaining: 0,
2016                window_reset_at: None,
2017            }),
2018            &Some((503, "service unavailable".into())),
2019        );
2020        assert_eq!(
2021            http,
2022            Some(("HTTP 503".into(), "service unavailable".into()))
2023        );
2024    }
2025}