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, reset_credit_lines, 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    /// Full length of the metric's reset window, when it is known exactly
62    /// (a rolling 5h/7d window). `None` for calendar periods and for quotas
63    /// whose window length the vendor never states — a frontend can pace a
64    /// metric only when this is `Some`.
65    pub window: Option<chrono::Duration>,
66    /// Named sub-group the metric belongs under (e.g. SuperGrok's product
67    /// slices under `"Breakdown"`), so a frontend can draw it as a compact
68    /// row beneath a heading instead of a peer of the overall meter. The TUI
69    /// renders grouped metrics like any other; only the report carries this.
70    pub group: Option<&'static str>,
71}
72
73struct SectionBuilder(Vec<SectionProjection>);
74
75impl SectionBuilder {
76    fn new(sections: Vec<Section>) -> Self {
77        Self(
78            sections
79                .into_iter()
80                .map(|section| {
81                    assert!(
82                        !matches!(section, Section::Metric { .. }),
83                        "metric sections must declare reset metadata with push_metric"
84                    );
85                    SectionProjection {
86                        section,
87                        reset_at: None,
88                        window: None,
89                        group: None,
90                    }
91                })
92                .collect(),
93        )
94    }
95
96    fn push(&mut self, section: Section) {
97        assert!(
98            !matches!(section, Section::Metric { .. }),
99            "metric sections must declare reset metadata with push_metric"
100        );
101        self.0.push(SectionProjection {
102            section,
103            reset_at: None,
104            window: None,
105            group: None,
106        });
107    }
108
109    /// A metric whose window length is not known exactly (a calendar month,
110    /// a vendor-defined billing period, or no stated window at all).
111    fn push_metric(&mut self, section: Section, reset_at: Option<DateTime<Utc>>) {
112        assert!(matches!(section, Section::Metric { .. }));
113        self.0.push(SectionProjection {
114            section,
115            reset_at,
116            window: None,
117            group: None,
118        });
119    }
120
121    /// A metric on a window of exactly `window` length, so a frontend can
122    /// compute how far through the window `reset_at` sits.
123    fn push_metric_in_window(
124        &mut self,
125        section: Section,
126        reset_at: Option<DateTime<Utc>>,
127        window: chrono::Duration,
128    ) {
129        assert!(matches!(section, Section::Metric { .. }));
130        self.0.push(SectionProjection {
131            section,
132            reset_at,
133            window: Some(window),
134            group: None,
135        });
136    }
137
138    /// A metric that belongs to a named sub-group of the panel (SuperGrok's
139    /// product slices under `"Breakdown"`). Grouped slices share the overall
140    /// pool's window, so they carry no reset of their own; the group label is
141    /// the only extra thing they assert.
142    fn push_metric_in_group(&mut self, section: Section, group: &'static str) {
143        assert!(matches!(section, Section::Metric { .. }));
144        self.0.push(SectionProjection {
145            section,
146            reset_at: None,
147            window: None,
148            group: Some(group),
149        });
150    }
151}
152
153/// Compact one-line projection of a vendor snapshot for the Overview: a short
154/// plan/tier sub-label (may be empty) plus a few key metric cells — a percent
155/// or a balance — each carrying a severity for coloring. Same numbers as
156/// [`sections_for`], flattened for a dense multi-vendor list. The vendor's name
157/// is supplied by the caller, so it is not repeated here.
158pub fn compact_cells(snapshot: &VendorSnapshot) -> (String, Vec<(String, PaceSeverity)>) {
159    let pct = |label: &str, p: i32| (format!("{label} {p}%"), severity_for(p));
160    // Named `*_cell` so neither shadows `format::{usd, money}`, which they
161    // wrap — the cell is the string plus the severity the Overview colours it with.
162    let usd_cell = |v: f64| (usd(v), PaceSeverity::Low);
163    let money_cell = |v: f64, c: &str| (money(v, c), PaceSeverity::Low);
164    let (plan, mut cells) = match snapshot {
165        VendorSnapshot::Anthropic(s) => {
166            let mut cells = vec![
167                pct("S", s.session.utilization_pct),
168                pct("W", s.weekly.utilization_pct),
169            ];
170            if let Some(sonnet) = &s.sonnet {
171                cells.push(pct("Son", sonnet.utilization_pct));
172            }
173            (s.plan.clone(), cells)
174        }
175        VendorSnapshot::AnthropicApi(s) => {
176            let cell = match s.pct() {
177                Some(p) => pct("spend", p),
178                None => (format!("{}/mo", usd(s.spent)), PaceSeverity::Low),
179            };
180            (String::new(), vec![cell])
181        }
182        VendorSnapshot::Openai(s) => {
183            let mut cells = Vec::new();
184            if let Some(w) = &s.session {
185                cells.push(pct("5h", w.utilization_pct));
186            }
187            if let Some(w) = &s.weekly {
188                cells.push(pct("7d", w.utilization_pct));
189            }
190            if cells.is_empty() {
191                cells.push(("—".into(), PaceSeverity::Low));
192            }
193            (s.plan.clone(), cells)
194        }
195        VendorSnapshot::Copilot(s) => (
196            s.plan.clone(),
197            s.quotas()
198                .map(|(label, quota)| pct(label, quota.used_pct()))
199                .collect(),
200        ),
201        VendorSnapshot::Zai(s) => {
202            let mut cells = Vec::new();
203            if let Some(w) = &s.session {
204                cells.push(pct("S", w.utilization_pct));
205            }
206            if let Some(w) = &s.weekly {
207                cells.push(pct("W", w.utilization_pct));
208            }
209            if cells.is_empty() {
210                cells.push(("—".into(), PaceSeverity::Low));
211            }
212            (s.plan.clone(), cells)
213        }
214        VendorSnapshot::Openrouter(s) => (String::new(), vec![usd_cell(s.balance())]),
215        VendorSnapshot::Deepseek(s) => (String::new(), vec![money_cell(s.balance, &s.currency)]),
216        VendorSnapshot::Kimi(s) => {
217            let mut cells = vec![pct("5h", s.window_pct())];
218            if s.has_weekly {
219                cells.push(pct("wk", s.weekly_pct()));
220            }
221            if let Some(monthly) = s.monthly_pct {
222                cells.push(pct("mo", monthly));
223            }
224            (s.plan.clone().unwrap_or_default(), cells)
225        }
226        VendorSnapshot::Kilo(s) => (String::new(), vec![usd_cell(s.balance)]),
227        VendorSnapshot::Novita(s) => (String::new(), vec![usd_cell(s.available)]),
228        VendorSnapshot::Moonshot(s) => (String::new(), vec![money_cell(s.available, &s.currency)]),
229        VendorSnapshot::Grok(s) => (String::new(), vec![usd_cell(s.balance)]),
230        VendorSnapshot::SuperGrok(s) => (s.plan.clone(), vec![pct(s.period.short(), s.weekly_pct)]),
231        VendorSnapshot::Grokbot(s) => {
232            // No included allowance is a state, not a 0% — no meter cell.
233            let cells = if s.has_included_allowance {
234                vec![pct("wk", s.weekly_pct)]
235            } else {
236                vec![("—".into(), PaceSeverity::Low)]
237            };
238            (s.plan.clone(), cells)
239        }
240        VendorSnapshot::Antigravity(s) => (
241            s.plan.clone(),
242            [
243                s.session.as_ref().map(|w| pct("S", w.utilization_pct)),
244                s.weekly.as_ref().map(|w| pct("W", w.utilization_pct)),
245            ]
246            .into_iter()
247            .flatten()
248            .collect(),
249        ),
250        VendorSnapshot::Cursor(s) => (
251            s.plan.clone(),
252            vec![pct("auto", s.auto_pct), pct("premium", s.api_pct)],
253        ),
254        VendorSnapshot::Minimax(s) => (
255            s.plan.clone(),
256            vec![
257                pct("S", s.session.utilization_pct),
258                pct("W", s.weekly.utilization_pct),
259            ],
260        ),
261        VendorSnapshot::Kiro(s) => (s.plan.clone(), vec![pct("credits", s.pct())]),
262        VendorSnapshot::NousResearch(s) => {
263            let cell = s
264                .usage_percent()
265                .map(|value| pct("usage", value.round().clamp(0.0, 100.0) as i32))
266                .unwrap_or_else(|| ("—".into(), PaceSeverity::Low));
267            (s.plan.clone().unwrap_or_default(), vec![cell])
268        }
269        VendorSnapshot::CommandCode(s) => {
270            let cells = [
271                ("session", s.five_hour.as_ref()),
272                ("weekly", s.weekly.as_ref()),
273                ("monthly", s.monthly_window().as_ref()),
274            ]
275            .into_iter()
276            .filter_map(|(label, window)| window.map(|window| pct(label, window.pct())))
277            .collect();
278            (s.plan.clone().unwrap_or_default(), cells)
279        }
280        VendorSnapshot::OpenCodeGo(s) => {
281            let cells = [
282                ("rolling", s.rolling.as_ref()),
283                ("weekly", s.weekly.as_ref()),
284                ("monthly", s.monthly.as_ref()),
285            ]
286            .into_iter()
287            .filter_map(|(label, window)| {
288                window.map(|window| pct(label, window.percent.round().clamp(0.0, 100.0) as i32))
289            })
290            .collect();
291            ("OpenCode Go".into(), cells)
292        }
293        VendorSnapshot::Ollama(s) => {
294            let cells = [
295                ("5h", s.session.as_ref()),
296                ("wk", s.weekly.as_ref()),
297                ("mo", s.monthly.as_ref()),
298            ]
299            .into_iter()
300            .filter_map(|(label, window)| {
301                window.map(|window| pct(label, window.utilization_pct.clamp(0, 100)))
302            })
303            .collect();
304            (s.plan.clone(), cells)
305        }
306        VendorSnapshot::Custom(s) => (
307            s.plan.clone().unwrap_or_default(),
308            s.metrics
309                .iter()
310                .take(3)
311                .map(|metric| pct(&metric.label, i32::from(metric.pct)))
312                .collect(),
313        ),
314    };
315
316    for (text, _) in &mut cells {
317        *text = crate::display::sanitize_untrusted_field(text);
318    }
319    (crate::display::sanitize_untrusted_field(&plan), cells)
320}
321
322/// The single most-relevant percentage for a vendor in the Overview — what its
323/// per-row mini bar shows. Mirrors the macOS menu bar's headline: Cursor is the
324/// combined included-total, quota vendors the most-exhausted window; balance
325/// vendors have no meaningful percentage (`None` → no bar).
326pub fn headline_pct(snapshot: &VendorSnapshot) -> Option<i32> {
327    match snapshot {
328        VendorSnapshot::Anthropic(s) => [
329            Some(s.session.utilization_pct),
330            Some(s.weekly.utilization_pct),
331            s.sonnet.as_ref().map(|w| w.utilization_pct),
332        ]
333        .into_iter()
334        .flatten()
335        .max(),
336        VendorSnapshot::AnthropicApi(s) => s.pct(),
337        VendorSnapshot::Openai(s) => [
338            s.session.as_ref().map(|w| w.utilization_pct),
339            s.weekly.as_ref().map(|w| w.utilization_pct),
340        ]
341        .into_iter()
342        .flatten()
343        .max(),
344        VendorSnapshot::Copilot(s) => s.quotas().map(|(_, quota)| quota.used_pct()).max(),
345        VendorSnapshot::Zai(s) => [
346            s.session.as_ref().map(|w| w.utilization_pct),
347            s.weekly.as_ref().map(|w| w.utilization_pct),
348        ]
349        .into_iter()
350        .flatten()
351        .max(),
352        VendorSnapshot::Kimi(s) => Some(s.worst_pct()),
353        VendorSnapshot::Antigravity(s) => [
354            s.session.as_ref().map(|w| w.utilization_pct),
355            s.weekly.as_ref().map(|w| w.utilization_pct),
356            s.third_party_session.as_ref().map(|w| w.utilization_pct),
357            s.third_party_weekly.as_ref().map(|w| w.utilization_pct),
358        ]
359        .into_iter()
360        .flatten()
361        .max(),
362        VendorSnapshot::Cursor(s) => (!s.unlimited).then_some(s.total_pct),
363        VendorSnapshot::Minimax(s) => Some(s.session.utilization_pct.max(s.weekly.utilization_pct)),
364        VendorSnapshot::Kiro(s) => Some(s.pct()),
365        VendorSnapshot::NousResearch(s) => s
366            .usage_percent()
367            .map(|value| value.round().clamp(0.0, 100.0) as i32),
368        VendorSnapshot::CommandCode(s) => {
369            let worst = s.worst_pct();
370            (s.five_hour.is_some() || s.weekly.is_some()).then_some(worst)
371        }
372        VendorSnapshot::OpenCodeGo(s) => [
373            s.rolling
374                .as_ref()
375                .map(|window| window.percent.round() as i32),
376            s.weekly
377                .as_ref()
378                .map(|window| window.percent.round() as i32),
379            s.monthly
380                .as_ref()
381                .map(|window| window.percent.round() as i32),
382        ]
383        .into_iter()
384        .flatten()
385        .max(),
386        VendorSnapshot::SuperGrok(s) => Some(s.weekly_pct),
387        VendorSnapshot::Grokbot(s) => s.has_included_allowance.then_some(s.weekly_pct),
388        VendorSnapshot::Ollama(s) => [
389            s.session.as_ref().map(|w| w.utilization_pct),
390            s.weekly.as_ref().map(|w| w.utilization_pct),
391            s.monthly.as_ref().map(|w| w.utilization_pct),
392        ]
393        .into_iter()
394        .flatten()
395        .max(),
396        VendorSnapshot::Custom(s) => s.metrics.first().map(|metric| i32::from(metric.pct)),
397        VendorSnapshot::Openrouter(_)
398        | VendorSnapshot::Deepseek(_)
399        | VendorSnapshot::Kilo(_)
400        | VendorSnapshot::Novita(_)
401        | VendorSnapshot::Moonshot(_)
402        | VendorSnapshot::Grok(_) => None,
403    }
404}
405
406/// Build the section list for the currently-active vendor's snapshot.
407pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
408    sections_with_metadata_for(tab, now, pace_tolerance)
409        .into_iter()
410        .map(|projected| projected.section)
411        .collect()
412}
413
414/// Rich projection used by machine-readable frontends. The TUI continues to
415/// expose the source-compatible [`sections_for`] result above.
416pub(crate) fn sections_with_metadata_for(
417    tab: &TabState,
418    now: DateTime<Utc>,
419    pace_tolerance: u32,
420) -> Vec<SectionProjection> {
421    let mut sections = match tab {
422        TabState::Loading => SectionBuilder::new(vec![
423            Section::Spacer,
424            Section::Text {
425                label: "".into(),
426                value: "  Loading…".into(),
427            },
428        ]),
429        TabState::Error { message: e, plan } => {
430            let mut rows = Vec::new();
431            if let Some(plan) = plan {
432                rows.push(Section::Title {
433                    left: plan.clone(),
434                    right: None,
435                });
436            }
437            rows.extend([
438                Section::Spacer,
439                Section::Text {
440                    label: "Error".into(),
441                    value: e.clone(),
442                },
443                Section::Spacer,
444                Section::Text {
445                    label: "".into(),
446                    value: "Press `r` to retry, `q` to quit.".into(),
447                },
448            ]);
449            SectionBuilder::new(rows)
450        }
451        TabState::Ready(r) => {
452            let snapshot = &r.snapshot;
453            let last_error = &r.last_error;
454            let mut sections = match snapshot {
455                VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
456                VendorSnapshot::AnthropicApi(s) => anthropic_api_sections(s),
457                VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
458                VendorSnapshot::Copilot(s) => copilot_sections(s, now),
459                VendorSnapshot::Zai(s) => zai_sections(s, now, pace_tolerance),
460                VendorSnapshot::Openrouter(s) => openrouter_sections(s),
461                VendorSnapshot::Deepseek(s) => deepseek_sections(s),
462                VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
463                VendorSnapshot::Kilo(s) => kilo_sections(s),
464                VendorSnapshot::Novita(s) => novita_sections(s),
465                VendorSnapshot::Moonshot(s) => moonshot_sections(s),
466                VendorSnapshot::Grok(s) => grok_sections(s),
467                VendorSnapshot::SuperGrok(s) => supergrok_sections(s, now),
468                VendorSnapshot::Grokbot(s) => grokbot_sections(s, now),
469                VendorSnapshot::Antigravity(s) => antigravity_sections(s, now),
470                VendorSnapshot::Cursor(s) => cursor_sections(s, now),
471                VendorSnapshot::Minimax(s) => minimax_sections(s, now, pace_tolerance),
472                VendorSnapshot::Kiro(s) => kiro_sections(s, now),
473                VendorSnapshot::NousResearch(s) => nous_sections(s, now),
474                VendorSnapshot::OpenCodeGo(s) => opencode_go_sections(s, now, pace_tolerance),
475                VendorSnapshot::CommandCode(s) => commandcode_sections(s, now),
476                VendorSnapshot::Ollama(s) => ollama_sections(s, now, pace_tolerance),
477                VendorSnapshot::Custom(s) => custom_sections(s),
478            };
479            // Inject the (already-absolute) fetched-at instant into the title
480            // row, right-aligned. Pre-snapshotted in app::refresh_one so it
481            // doesn't drift between redraws.
482            let updated = match r.fetched_at {
483                Some(at) => format!("Updated {}", local_time_hms(at)),
484                None => "Updated —".to_string(),
485            };
486            if let Some(SectionProjection {
487                section: Section::Title { right, .. },
488                ..
489            }) = sections.0.first_mut()
490            {
491                *right = Some(updated);
492            }
493            // Error footer (when present) still lives in the body.
494            if let Some((label, msg)) = warning_label(snapshot, last_error) {
495                sections.push(Section::Spacer);
496                sections.push(Section::Text { label, value: msg });
497            }
498            sections
499        }
500    };
501    for projected in &mut sections.0 {
502        sanitize_section(&mut projected.section);
503    }
504    sections.0
505}
506
507/// Sanitize at the final projection boundary so every vendor field, cached
508/// diagnostic, and fetch error is inert before ratatui writes it to a terminal.
509fn sanitize_section(section: &mut Section) {
510    let clean = |value: &mut String| {
511        *value = crate::display::sanitize_untrusted_field(value);
512    };
513    match section {
514        Section::Title { left, right } => {
515            clean(left);
516            if let Some(right) = right {
517                clean(right);
518            }
519        }
520        Section::Metric {
521            label,
522            value_label,
523            footnote,
524            ..
525        } => {
526            clean(label);
527            clean(value_label);
528            clean(footnote);
529        }
530        Section::Text { label, value } => {
531            clean(label);
532            clean(value);
533        }
534        Section::Block { label, body } => {
535            clean(label);
536            for line in body {
537                clean(line);
538            }
539        }
540        Section::Spacer => {}
541    }
542}
543
544/// Translate cache diagnostics at the presentation boundary. Cache files keep
545/// their established `(u16, String)` form: only non-zero codes are HTTP, while
546/// Kimi's stable schema marker identifies its code-zero schema warning.
547fn warning_label(
548    snapshot: &VendorSnapshot,
549    last_error: &Option<(u16, String)>,
550) -> Option<(String, String)> {
551    let (code, message) = last_error.as_ref()?;
552    if *code != 0 {
553        return Some((format!("HTTP {code}"), message.clone()));
554    }
555    if message.is_empty() {
556        return None;
557    }
558    let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
559        && matches!(
560            crate::kimi::vendor::warning_kind(*code, message),
561            crate::kimi::vendor::WarningKind::SchemaDrift
562        ) {
563        "Kimi API schema drift"
564    } else {
565        "Warning"
566    };
567    // The stable marker is already the schema-warning label. Keep the label
568    // visible but do not repeat that sentinel as a redundant body value.
569    let value = if label == message {
570        String::new()
571    } else {
572        message.clone()
573    };
574    Some((label.into(), value))
575}
576
577fn anthropic_api_sections(s: &crate::usage::AnthropicApiSnapshot) -> SectionBuilder {
578    let mut v = SectionBuilder::new(vec![Section::Title {
579        left: "Anthropic API".into(),
580        right: None,
581    }]);
582    match (s.limit.filter(|l| *l > 0.0), s.pct()) {
583        (Some(limit), Some(pct)) => {
584            let p = pct.clamp(0, 100) as u16;
585            v.push_metric(
586                Section::Metric {
587                    label: "Spend (mo)".into(),
588                    pct: p,
589                    severity: severity_for(pct),
590                    value_label: format!("{} of ${:.0}", usd(s.spent), limit),
591                    footnote: format!("{pct}% of monthly limit"),
592                },
593                None,
594            );
595        }
596        _ => {
597            v.push(Section::Text {
598                label: "Spend (mo)".into(),
599                value: usd(s.spent),
600            });
601        }
602    }
603    v.push(Section::Spacer);
604    v.push(Section::Text {
605        label: "".into(),
606        value: "Month-to-date cost via the Admin usage API.".into(),
607    });
608    v.push(Section::Text {
609        label: "".into(),
610        value: "Prepaid credit balance is Console-only (no API).".into(),
611    });
612    v.push(Section::Text {
613        label: "".into(),
614        value: "Excludes Priority Tier cost (not reported by this API).".into(),
615    });
616    v
617}
618
619fn anthropic_sections(
620    s: &crate::usage::AnthropicSnapshot,
621    now: DateTime<Utc>,
622    tol: u32,
623) -> SectionBuilder {
624    let mut v = SectionBuilder::new(vec![Section::Title {
625        left: format!("Claude {}", s.plan),
626        right: None,
627    }]);
628
629    push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
630    push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
631    if let Some(w) = &s.sonnet {
632        push_window(&mut v, "Sonnet only", w, now, tol, false);
633    }
634    for sw in &s.scoped {
635        push_window(
636            &mut v,
637            &format!("{} (7d)", sw.label),
638            &sw.window,
639            now,
640            tol,
641            false,
642        );
643    }
644    if let Some(e) = &s.extra {
645        v.push(Section::Spacer);
646        let pct = e.percent().clamp(0, 100) as u16;
647        // An uncapped plan (`monthly_limit: null`) has spend but no
648        // denominator: show the amount alone rather than "of $0.00" or a
649        // percentage nobody can vouch for (#30).
650        let (value_label, footnote) = match e.fmt_limit() {
651            Some(l) => (
652                format!("{} of {}", e.fmt_spent(), l),
653                format!("{pct}% of monthly limit consumed"),
654            ),
655            None => (e.fmt_spent(), "no monthly limit reported".to_string()),
656        };
657        v.push_metric(
658            Section::Metric {
659                label: "Extra usage".into(),
660                pct,
661                severity: severity_for(pct as i32),
662                value_label,
663                footnote,
664            },
665            None,
666        );
667    }
668    v
669}
670
671fn openai_sections(
672    s: &crate::usage::OpenAiSnapshot,
673    now: DateTime<Utc>,
674    tol: u32,
675) -> SectionBuilder {
676    let mut v = SectionBuilder::new(vec![Section::Title {
677        left: s.plan.clone(),
678        right: None,
679    }]);
680    if let Some(session) = &s.session {
681        push_window(&mut v, "Codex 5h", session, now, tol, true);
682    }
683    if let Some(weekly) = &s.weekly {
684        push_window(&mut v, "Codex weekly", weekly, now, tol, true);
685    }
686    if s.session.is_none() && s.weekly.is_none() {
687        v.push(Section::Spacer);
688        v.push(Section::Text {
689            label: "".into(),
690            value: "  no usage windows reported".into(),
691        });
692    }
693    if let Some(cr) = &s.code_review {
694        push_window(&mut v, "Code review", cr, now, tol, false);
695    }
696    // A named limit can be the binding one while the headline window reads
697    // low, so it gets a real row rather than a footnote.
698    for limit in &s.additional_limits {
699        if let Some(w) = &limit.session {
700            push_window(&mut v, &format!("{} (5h)", limit.name), w, now, tol, false);
701        }
702        if let Some(w) = &limit.weekly {
703            push_window(&mut v, &format!("{} (7d)", limit.name), w, now, tol, false);
704        }
705    }
706    // No percentage reflects a model the account cannot dispatch to, so say it
707    // outright rather than leaving the user to infer it from healthy bars.
708    if !s.unavailable_models.is_empty() {
709        v.push(Section::Spacer);
710        v.push(Section::Block {
711            label: "Unavailable".into(),
712            body: s
713                .unavailable_models
714                .iter()
715                .map(|m| match m.available_at {
716                    Some(at) => format!("{} — back {}", m.model, countdown::format(Some(at), now)),
717                    None => format!("{} — at capacity", m.model),
718                })
719                .collect(),
720        });
721    }
722    if let Some(c) = &s.credits {
723        v.push(Section::Spacer);
724        let balance = if c.unlimited {
725            "unlimited".into()
726        } else {
727            c.balance.clone()
728        };
729        let mut body = vec![format!("balance: {}", balance)];
730        if let Some((lo, hi)) = c.approx_local_messages {
731            body.push(format!("≈ {lo}-{hi} local messages"));
732        }
733        if let Some((lo, hi)) = c.approx_cloud_messages {
734            body.push(format!("≈ {lo}-{hi} cloud messages"));
735        }
736        v.push(Section::Block {
737            label: "Credits".into(),
738            body,
739        });
740    }
741    push_reset_credits(&mut v, &s.reset_credits, now);
742    v
743}
744
745fn copilot_sections(s: &crate::copilot::types::Snapshot, now: DateTime<Utc>) -> SectionBuilder {
746    let mut sections = SectionBuilder::new(vec![Section::Title {
747        left: format!("GitHub Copilot {}", s.plan),
748        right: None,
749    }]);
750    for (label, quota) in s.quotas() {
751        let pct = quota.used_pct();
752        let detail = if quota.unlimited {
753            "Unlimited".to_string()
754        } else {
755            quota
756                .used_and_entitlement()
757                .map(|(used, entitlement)| format!("{used} of {entitlement} used"))
758                .unwrap_or_else(|| format!("{}% remaining", quota.percent_remaining))
759        };
760        sections.push(Section::Spacer);
761        sections.push_metric(
762            Section::Metric {
763                label: label.to_string(),
764                pct: pct.clamp(0, 100) as u16,
765                severity: severity_for(pct),
766                value_label: if quota.unlimited {
767                    "Unlimited".to_string()
768                } else {
769                    format!("{pct}%")
770                },
771                footnote: detail,
772            },
773            s.reset_at,
774        );
775    }
776    sections.push(Section::Spacer);
777    sections.push(Section::Text {
778        label: "Resets".into(),
779        value: countdown::format(s.reset_at, now),
780    });
781    sections
782}
783
784fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>, tol: u32) -> SectionBuilder {
785    let mut v = SectionBuilder::new(vec![Section::Title {
786        left: s.plan.clone(),
787        right: None,
788    }]);
789    if let Some(w) = &s.session {
790        push_window(&mut v, "Session (5h)", w, now, tol, true);
791    }
792    if let Some(w) = &s.weekly {
793        push_window(&mut v, "Weekly", w, now, tol, true);
794    }
795    if let Some(w) = &s.mcp {
796        push_window(&mut v, "MCP tools (monthly)", w, now, tol, true);
797    }
798    if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
799        v.push(Section::Spacer);
800        v.push(Section::Text {
801            label: "".into(),
802            value: "  no usage windows reported".into(),
803        });
804    }
805    v
806}
807
808fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> SectionBuilder {
809    let mut v = SectionBuilder::new(vec![Section::Title {
810        left: s.label.clone(),
811        right: None,
812    }]);
813    let pct = s.consumed_pct().clamp(0, 100) as u16;
814    v.push(Section::Spacer);
815    v.push_metric(
816        Section::Metric {
817            label: "Credit balance".into(),
818            pct,
819            // One severity policy for every frontend: this value is what the
820            // Omarchy, GNOME and KDE panels colour their row with, so it has to
821            // agree with the Waybar tooltip about what "in debt" looks like.
822            severity: crate::openrouter::vendor::severity(s),
823            value_label: usd(s.balance()),
824            footnote: format!(
825                "{} of {} used ({pct}%)",
826                usd(s.total_usage),
827                usd(s.total_credits)
828            ),
829        },
830        None,
831    );
832    v.push(Section::Spacer);
833    v.push(Section::Block {
834        label: "Usage by period".into(),
835        body: vec![format!(
836            "today ${:.2} · week ${:.2} · month ${:.2}",
837            s.usage_daily, s.usage_weekly, s.usage_monthly
838        )],
839    });
840    if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
841        v.push(Section::Spacer);
842        v.push(Section::Block {
843            label: "Per-key limit".into(),
844            body: vec![format!("{} of {} remaining", usd(rem), usd(limit))],
845        });
846    }
847    v.push(Section::Spacer);
848    v.push(Section::Block {
849        label: "Tier".into(),
850        body: vec![if s.is_free_tier {
851            "free tier".into()
852        } else {
853            "paid tier".into()
854        }],
855    });
856    v
857}
858
859/// Antigravity holds two independent pools (Gemini, Claude & GPT OSS), each
860/// with a 5-hour and a weekly window. Grouped by window type so the two pools
861/// sit side by side, matching the GNOME dropdown.
862fn antigravity_sections(
863    s: &crate::usage::AntigravitySnapshot,
864    now: DateTime<Utc>,
865) -> SectionBuilder {
866    use crate::antigravity::vendor::{GROUP_PRIMARY, GROUP_THIRD_PARTY};
867
868    let mut v = SectionBuilder::new(vec![Section::Title {
869        left: s.plan.clone(),
870        right: None,
871    }]);
872    for (heading, primary, third_party) in [
873        (
874            "Session",
875            s.session.as_ref(),
876            s.third_party_session.as_ref(),
877        ),
878        ("Weekly", s.weekly.as_ref(), s.third_party_weekly.as_ref()),
879    ] {
880        // A cadence no bucket reported gets no heading either — an empty
881        // "Session" with nothing under it reads as a failed fetch.
882        if primary.is_none() && third_party.is_none() {
883            continue;
884        }
885        v.push(Section::Spacer);
886        v.push(Section::Text {
887            label: heading.into(),
888            value: String::new(),
889        });
890        if let Some(w) = primary {
891            push_window(&mut v, GROUP_PRIMARY, w, now, 5, false);
892        }
893        if let Some(w) = third_party {
894            push_window(&mut v, GROUP_THIRD_PARTY, w, now, 5, false);
895        }
896    }
897    // Figures read from the Cloud Code API fallback can lag what the local
898    // product would show; say where they came from.
899    if s.source == crate::usage::AntigravitySource::Remote {
900        v.push(Section::Spacer);
901        v.push(Section::Text {
902            label: "Source".into(),
903            value: "Google API".into(),
904        });
905    }
906    v
907}
908
909/// A Cursor pool row. The billing cycle carries an exact window only when the
910/// API stated both ends; when it did not, the row goes out with its reset time
911/// and no window rather than a guessed month a frontend would pace as exact.
912fn push_cursor_pool(v: &mut SectionBuilder, section: Section, s: &crate::usage::CursorSnapshot) {
913    match s.cycle_window() {
914        Some(window) => v.push_metric_in_window(section, s.reset_at, window),
915        None => v.push_metric(section, s.reset_at),
916    }
917}
918
919fn cursor_sections(s: &crate::usage::CursorSnapshot, now: DateTime<Utc>) -> SectionBuilder {
920    let mut v = SectionBuilder::new(vec![Section::Title {
921        left: format!("Cursor {}", s.plan),
922        right: None,
923    }]);
924    if s.unlimited {
925        v.push(Section::Spacer);
926        v.push(Section::Text {
927            label: "Plan".into(),
928            value: "Unlimited — pools don't cap".into(),
929        });
930    } else {
931        // Two included-usage pools, mirroring the dashboard's two bars.
932        v.push(Section::Spacer);
933        push_cursor_pool(
934            &mut v,
935            Section::Metric {
936                label: "Cursor Models".into(),
937                pct: s.auto_pct.clamp(0, 100) as u16,
938                severity: severity_for(s.auto_pct),
939                value_label: format!("{}%", s.auto_pct),
940                footnote: "Auto + Composer".into(),
941            },
942            s,
943        );
944        v.push(Section::Spacer);
945        push_cursor_pool(
946            &mut v,
947            Section::Metric {
948                label: "Other Models".into(),
949                pct: s.api_pct.clamp(0, 100) as u16,
950                severity: severity_for(s.api_pct),
951                value_label: format!("{}%", s.api_pct),
952                footnote: format!(
953                    "Named / API models · on-demand {}",
954                    if s.on_demand_enabled { "on" } else { "off" }
955                ),
956            },
957            s,
958        );
959        if let Some(used) = s.on_demand_used_cents {
960            v.push(Section::Spacer);
961            v.push(Section::Text {
962                label: "On-Demand".into(),
963                value: match s.on_demand_limit_cents {
964                    Some(limit) => format!(
965                        "{} / {}",
966                        crate::usage::fmt_minor(used, 2, Some("USD")),
967                        crate::usage::fmt_minor(limit, 2, Some("USD"))
968                    ),
969                    None => crate::usage::fmt_minor(used, 2, Some("USD")),
970                },
971            });
972        }
973    }
974    v.push(Section::Spacer);
975    v.push(Section::Text {
976        label: "Resets".into(),
977        value: countdown::format(s.reset_at, now),
978    });
979    v
980}
981
982fn nous_sections(s: &crate::nous::types::AccountSnapshot, now: DateTime<Utc>) -> SectionBuilder {
983    let mut sections = SectionBuilder::new(vec![Section::Title {
984        left: "Nous Research".into(),
985        right: None,
986    }]);
987    if let Some(value) = s.usage_percent() {
988        let pct = value.round().clamp(0.0, 100.0) as i32;
989        sections.push_metric(
990            Section::Metric {
991                label: "Usage".into(),
992                pct: pct as u16,
993                severity: severity_for(pct),
994                value_label: format!("{pct}%"),
995                footnote: "current period".into(),
996            },
997            s.current_period_end,
998        );
999    }
1000    sections.push(Section::Spacer);
1001    if let Some(remaining) = s.credits_remaining {
1002        sections.push(Section::Text {
1003            label: "Subscription credits".into(),
1004            value: format!("{remaining:.2} remaining"),
1005        });
1006    }
1007    if let Some(purchased) = s.purchased_credits_remaining {
1008        sections.push(Section::Text {
1009            label: "Top-up credits".into(),
1010            value: format!("{purchased:.2} remaining"),
1011        });
1012    }
1013    if let Some(total_usable) = s.total_usable_credits {
1014        sections.push(Section::Text {
1015            label: "Total usable credits".into(),
1016            value: format!("{total_usable:.2}"),
1017        });
1018    }
1019    if let Some(period_end) = s.current_period_end {
1020        sections.push(Section::Text {
1021            label: "Renews".into(),
1022            value: countdown::format(Some(period_end), now),
1023        });
1024    }
1025    sections
1026}
1027
1028fn commandcode_sections(
1029    s: &crate::commandcode::types::Snapshot,
1030    now: DateTime<Utc>,
1031) -> SectionBuilder {
1032    let title = match s.plan.as_deref() {
1033        Some(plan) if !plan.is_empty() => format!("Command Code {plan}"),
1034        _ => "Command Code".to_string(),
1035    };
1036    let mut sections = SectionBuilder::new(vec![Section::Title {
1037        left: title,
1038        right: None,
1039    }]);
1040    for (label, window) in [
1041        ("Session (5h)", s.five_hour.as_ref()),
1042        ("Weekly", s.weekly.as_ref()),
1043        ("Monthly", s.monthly_window().as_ref()),
1044    ] {
1045        if let Some(window) = window {
1046            let pct = window.pct();
1047            sections.push_metric(
1048                Section::Metric {
1049                    label: label.into(),
1050                    pct: pct.clamp(0, 100) as u16,
1051                    severity: severity_for(pct),
1052                    value_label: format!("{pct}%"),
1053                    footnote: format!("{} of {}", usd(window.used), usd(window.cap)),
1054                },
1055                window.resets_at,
1056            );
1057            sections.push(Section::Text {
1058                label: "Resets".into(),
1059                value: countdown::format(window.resets_at, now),
1060            });
1061        }
1062    }
1063    if let Some(credits) = s.credits.as_ref() {
1064        sections.push(Section::Spacer);
1065        sections.push(Section::Text {
1066            label: "Credits".into(),
1067            value: usd(credits.remaining()),
1068        });
1069    }
1070    sections
1071}
1072
1073fn opencode_go_sections(
1074    s: &crate::opencode_go::types::Usage,
1075    now: DateTime<Utc>,
1076    tol: u32,
1077) -> SectionBuilder {
1078    use crate::opencode_go::vendor::{ROLLING_WINDOW, WEEKLY_WINDOW};
1079
1080    let mut sections = SectionBuilder::new(vec![Section::Title {
1081        left: "OpenCode Go".into(),
1082        right: None,
1083    }]);
1084    let mut any = false;
1085    for (label, window, duration) in [
1086        ("Rolling (5h)", s.rolling.as_ref(), ROLLING_WINDOW),
1087        ("Weekly (7d)", s.weekly.as_ref(), WEEKLY_WINDOW),
1088    ] {
1089        let Some(window) = window else {
1090            continue;
1091        };
1092        any = true;
1093        let pct = window.percent.round().clamp(0.0, 100.0) as i32;
1094        let projected = crate::usage::UsageWindow {
1095            utilization_pct: pct,
1096            resets_at: Some(window.resets_at),
1097            window_duration: duration,
1098        };
1099        push_window(&mut sections, label, &projected, now, tol, true);
1100    }
1101    // Monthly keeps its reset countdown but no pacing and no `window_secs`:
1102    // the cycle follows the subscription date (28/29/31-day months), so no
1103    // fixed denominator is exact. `push_metric` (not `push_metric_in_window`)
1104    // is what withholds the window from machine-readable frontends.
1105    if let Some(window) = s.monthly.as_ref() {
1106        any = true;
1107        let pct = window.percent.round().clamp(0.0, 100.0) as i32;
1108        sections.push_metric(
1109            Section::Metric {
1110                label: "Monthly".into(),
1111                pct: pct as u16,
1112                severity: severity_for(pct),
1113                value_label: format!("{pct}%"),
1114                footnote: format!(
1115                    "Resets in {}",
1116                    countdown::format(Some(window.resets_at), now)
1117                ),
1118            },
1119            Some(window.resets_at),
1120        );
1121    }
1122    if !any {
1123        sections.push(Section::Spacer);
1124        sections.push(Section::Text {
1125            label: "".into(),
1126            value: "  no usage windows reported".into(),
1127        });
1128    }
1129    sections
1130}
1131
1132/// Kiro has a single credit pool, so the panel is a single metric bar plus
1133/// the reset row — the same shape as `anthropic_api_sections` but with a
1134/// real percentage (Kiro always reports both used and limit) instead of an
1135/// optional configured one.
1136fn kiro_sections(s: &crate::usage::KiroSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1137    let pct = s.pct();
1138    let mut v = SectionBuilder::new(vec![
1139        Section::Title {
1140            left: format!("Kiro {}", s.plan),
1141            right: None,
1142        },
1143        Section::Spacer,
1144    ]);
1145    v.push_metric(
1146        Section::Metric {
1147            label: "Credits".into(),
1148            pct: pct.clamp(0, 100) as u16,
1149            severity: severity_for(pct),
1150            value_label: format!("{pct}%"),
1151            footnote: format!("{:.2} of {:.0}", s.used, s.limit),
1152        },
1153        s.reset_at,
1154    );
1155    v.push(Section::Spacer);
1156    v.push(Section::Text {
1157        label: "Resets".into(),
1158        value: countdown::format(s.reset_at, now),
1159    });
1160    v
1161}
1162
1163/// MiniMax groups quota by model bucket, so the panel is laid out by window
1164/// (Session, Weekly) with one row per pool — the same shape as Antigravity's
1165/// two-group panel. Pacing is shown: both windows report a real duration, so
1166/// the marker is meaningful.
1167fn minimax_sections(
1168    s: &crate::usage::MinimaxSnapshot,
1169    now: DateTime<Utc>,
1170    tol: u32,
1171) -> SectionBuilder {
1172    use crate::minimax::vendor::{POOL_GENERAL, POOL_VIDEO};
1173
1174    let mut v = SectionBuilder::new(vec![Section::Title {
1175        left: s.plan.clone(),
1176        right: None,
1177    }]);
1178    for (heading, general, video) in [
1179        ("Session", &s.session, s.video_session.as_ref()),
1180        ("Weekly", &s.weekly, s.video_weekly.as_ref()),
1181    ] {
1182        v.push(Section::Spacer);
1183        v.push(Section::Text {
1184            label: heading.into(),
1185            value: String::new(),
1186        });
1187        push_window(&mut v, POOL_GENERAL, general, now, tol, true);
1188        if let Some(w) = video {
1189            push_window(&mut v, POOL_VIDEO, w, now, tol, true);
1190        }
1191    }
1192    v
1193}
1194
1195fn kilo_sections(s: &crate::usage::KiloSnapshot) -> SectionBuilder {
1196    SectionBuilder::new(vec![
1197        Section::Title {
1198            left: s.label.clone(),
1199            right: None,
1200        },
1201        Section::Spacer,
1202        Section::Text {
1203            label: "Balance".into(),
1204            value: usd(s.balance),
1205        },
1206    ])
1207}
1208
1209fn novita_sections(s: &crate::usage::NovitaSnapshot) -> SectionBuilder {
1210    let mut v = SectionBuilder::new(vec![
1211        Section::Title {
1212            left: "Novita".into(),
1213            right: None,
1214        },
1215        Section::Spacer,
1216        Section::Text {
1217            label: "Balance".into(),
1218            value: usd(s.available),
1219        },
1220        Section::Block {
1221            label: "Breakdown".into(),
1222            body: vec![format!(
1223                "top-up ${:.2} · credit limit ${:.2}",
1224                s.cash, s.credit_limit
1225            )],
1226        },
1227    ]);
1228    if s.outstanding > 0.0 {
1229        v.push(Section::Spacer);
1230        v.push(Section::Block {
1231            label: "Owed".into(),
1232            body: vec![usd(s.outstanding)],
1233        });
1234    }
1235    v
1236}
1237
1238fn moonshot_sections(s: &crate::usage::MoonshotSnapshot) -> SectionBuilder {
1239    let cur = &s.currency;
1240    let fmt = |v: f64| money(v, cur);
1241    SectionBuilder::new(vec![
1242        Section::Title {
1243            left: "Kimi (Moonshot)".into(),
1244            right: None,
1245        },
1246        Section::Spacer,
1247        Section::Text {
1248            label: "Balance".into(),
1249            value: fmt(s.available),
1250        },
1251        Section::Block {
1252            label: "Breakdown".into(),
1253            body: vec![format!("cash {} · voucher {}", fmt(s.cash), fmt(s.voucher))],
1254        },
1255    ])
1256}
1257
1258fn grok_sections(s: &crate::usage::GrokSnapshot) -> SectionBuilder {
1259    SectionBuilder::new(vec![
1260        Section::Title {
1261            left: "Grok (xAI)".into(),
1262            right: None,
1263        },
1264        Section::Spacer,
1265        Section::Text {
1266            label: "Prepaid balance".into(),
1267            value: usd(s.balance),
1268        },
1269    ])
1270}
1271
1272/// A user-declared `[[custom]]` provider: the plan (if the response carried
1273/// one), one gauge per configured metric, then the free-form text rows. The
1274/// vendor never states a reset countdown of its own; a metric's `resets_at`
1275/// and optional exact `window_secs` ride along as reset metadata so every
1276/// frontend paces it exactly like a built-in.
1277fn custom_sections(s: &crate::custom::types::CustomSnapshot) -> SectionBuilder {
1278    let mut v = SectionBuilder::new(vec![
1279        Section::Title {
1280            left: s.plan.clone().unwrap_or_default(),
1281            right: None,
1282        },
1283        Section::Spacer,
1284    ]);
1285    for metric in &s.metrics {
1286        let pct = metric.pct.min(100);
1287        let section = Section::Metric {
1288            label: metric.label.clone(),
1289            pct,
1290            severity: severity_for(i32::from(pct)),
1291            value_label: format!("{pct}%"),
1292            footnote: metric.footnote.clone(),
1293        };
1294        match metric.window_secs {
1295            Some(secs) => v.push_metric_in_window(
1296                section,
1297                metric.resets_at,
1298                chrono::Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX)),
1299            ),
1300            None => v.push_metric(section, metric.resets_at),
1301        }
1302    }
1303    if !s.texts.is_empty() {
1304        v.push(Section::Spacer);
1305        for text in &s.texts {
1306            v.push(Section::Text {
1307                label: text.label.clone(),
1308                value: text.value.clone(),
1309            });
1310        }
1311    }
1312    v
1313}
1314
1315fn supergrok_sections(s: &crate::usage::SuperGrokSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1316    let pct = s.weekly_pct;
1317    let mut v = SectionBuilder::new(vec![
1318        Section::Title {
1319            left: s.plan.clone(),
1320            right: None,
1321        },
1322        Section::Spacer,
1323    ]);
1324    let metric = Section::Metric {
1325        label: format!("{} usage", s.period.label()),
1326        pct: pct.clamp(0, 100) as u16,
1327        severity: severity_for(pct),
1328        value_label: format!("{pct}%"),
1329        footnote: String::new(),
1330    };
1331    // Only the weekly period has an exact length; a month varies and an
1332    // unknown period says nothing, so neither can be paced.
1333    if s.period == crate::usage::SuperGrokPeriod::Weekly {
1334        v.push_metric_in_window(metric, s.reset_at, chrono::Duration::days(7));
1335    } else {
1336        v.push_metric(metric, s.reset_at);
1337    }
1338    for product in &s.products {
1339        // Product slices share the overall pool. They must not carry the
1340        // window reset or a severity colour — only the overall usage meter
1341        // is the binding constraint. The "Breakdown" group lets frontends
1342        // draw them compactly under a heading instead of as peers of it.
1343        v.push_metric_in_group(
1344            Section::Metric {
1345                label: product.label.clone(),
1346                pct: product.percent.clamp(0, 100) as u16,
1347                severity: PaceSeverity::Low,
1348                value_label: format!("{}%", product.percent),
1349                footnote: String::new(),
1350            },
1351            "Breakdown",
1352        );
1353    }
1354    // A $0.00 prepaid row reads as "you have no money" when the field merely
1355    // says no credit was purchased on top of the subscription — and a unified
1356    // billing account keeps its real dollars in the Management API wallet
1357    // (`[grok]`), not here. Show the row only when there is credit to show.
1358    if let Some(bal) = s.prepaid_balance.filter(|bal| *bal > 0.0) {
1359        v.push(Section::Spacer);
1360        v.push(Section::Text {
1361            label: "Prepaid API".into(),
1362            value: usd(bal),
1363        });
1364    }
1365    push_reset_credits(&mut v, &s.reset_credits, now);
1366    v
1367}
1368
1369fn ollama_sections(
1370    s: &crate::usage::OllamaSnapshot,
1371    now: DateTime<Utc>,
1372    pace_tolerance: u32,
1373) -> SectionBuilder {
1374    let mut v = SectionBuilder::new(vec![Section::Title {
1375        left: format!("Ollama Cloud {}", s.plan),
1376        right: None,
1377    }]);
1378    if let Some(w) = &s.session {
1379        push_window(&mut v, "Session (5h)", w, now, pace_tolerance, true);
1380    }
1381    if let Some(w) = &s.weekly {
1382        push_window(&mut v, "Weekly", w, now, pace_tolerance, true);
1383    }
1384    if let Some(w) = &s.monthly {
1385        push_window(&mut v, "Monthly", w, now, pace_tolerance, true);
1386    }
1387    push_top_models(&mut v, &s.session_models, "Top models (5h)");
1388    push_top_models(&mut v, &s.weekly_models, "Top models (weekly)");
1389    push_top_models(&mut v, &s.monthly_models, "Top models (monthly)");
1390    if let Some(cost) = &s.activity_cost {
1391        v.push(Section::Spacer);
1392        v.push(Section::Block {
1393            label: "Activity".into(),
1394            body: vec![format!(
1395                "{} · {}",
1396                usd_str(cost),
1397                s.activity_period.as_deref().unwrap_or("last 4 weeks")
1398            )],
1399        });
1400    }
1401    v
1402}
1403
1404fn push_top_models(
1405    sections: &mut SectionBuilder,
1406    models: &[crate::usage::OllamaModelUsage],
1407    label: &str,
1408) {
1409    if models.is_empty() {
1410        return;
1411    }
1412    let mut sorted: Vec<&crate::usage::OllamaModelUsage> = models.iter().collect();
1413    sorted.sort_by_key(|m| std::cmp::Reverse(m.request_count));
1414    let body: Vec<String> = sorted
1415        .into_iter()
1416        .take(5)
1417        .map(|m| format!("{}: {} requests", m.name, m.request_count))
1418        .collect();
1419    sections.push(Section::Spacer);
1420    sections.push(Section::Block {
1421        label: label.into(),
1422        body,
1423    });
1424}
1425
1426fn usd_str(cost: &str) -> String {
1427    cost.parse::<f64>()
1428        .map(usd)
1429        .unwrap_or_else(|_| cost.to_string())
1430}
1431
1432fn push_reset_credits(
1433    v: &mut SectionBuilder,
1434    credits: &crate::usage::ResetCredits,
1435    now: DateTime<Utc>,
1436) {
1437    if credits.is_empty() {
1438        return;
1439    }
1440    v.push(Section::Spacer);
1441    v.push(Section::Block {
1442        label: "Reset credits".into(),
1443        body: reset_credit_lines(credits, now),
1444    });
1445}
1446
1447fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> SectionBuilder {
1448    let currency = &s.currency;
1449    let fmt = |v: f64| money(v, currency);
1450    let avail = if s.is_available {
1451        "available"
1452    } else {
1453        "unavailable"
1454    };
1455    let mut v = SectionBuilder::new(vec![Section::Title {
1456        left: "DeepSeek".into(),
1457        right: None,
1458    }]);
1459    v.push(Section::Spacer);
1460    v.push(Section::Text {
1461        label: "Balance".into(),
1462        value: fmt(s.balance),
1463    });
1464    v.push(Section::Block {
1465        label: "Breakdown".into(),
1466        body: vec![format!(
1467            "granted {} · topped-up {}",
1468            fmt(s.granted),
1469            fmt(s.topped_up)
1470        )],
1471    });
1472    v.push(Section::Spacer);
1473    v.push(Section::Block {
1474        label: "API".into(),
1475        body: vec![avail.into()],
1476    });
1477    v
1478}
1479
1480/// Kimi reports each quota as used/limit against a limit of 100, so the pair
1481/// is the percentage in longhand. Projecting both onto a `UsageWindow` lets
1482/// the shared `push_window` draw them, which is what keeps the row identical
1483/// to every other vendor's instead of a hand-rolled near-copy.
1484fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, tol: u32) -> SectionBuilder {
1485    use crate::kimi::vendor::{ROLLING_WINDOW, WEEKLY_WINDOW};
1486
1487    let plan = s.plan.as_deref().unwrap_or("Kimi");
1488    let mut v = SectionBuilder::new(vec![Section::Title {
1489        left: plan.into(),
1490        right: None,
1491    }]);
1492    let window = |pct, resets_at, window_duration| crate::usage::UsageWindow {
1493        utilization_pct: pct,
1494        resets_at,
1495        window_duration,
1496    };
1497
1498    if s.window_limit > 0 {
1499        let w = window(s.window_pct(), s.window_reset_at, ROLLING_WINDOW);
1500        push_window(&mut v, "Rolling window (5h)", &w, now, tol, false);
1501    }
1502    if s.has_weekly {
1503        let w = window(s.weekly_pct(), s.weekly_reset_at, WEEKLY_WINDOW);
1504        push_window(&mut v, "Weekly quota", &w, now, tol, false);
1505    }
1506    if let Some(monthly_pct) = s.monthly_pct {
1507        // The monthly pool resets from the order date, so there is no fixed
1508        // window length: the metric carries its reset but no window metadata,
1509        // and nothing paces it.
1510        v.push(Section::Spacer);
1511        v.push_metric(
1512            Section::Metric {
1513                label: "Monthly".into(),
1514                pct: monthly_pct.clamp(0, 100) as u16,
1515                severity: severity_for(monthly_pct),
1516                value_label: format!("{monthly_pct}%"),
1517                footnote: format!("Resets in {}", countdown::format(s.monthly_reset_at, now)),
1518            },
1519            s.monthly_reset_at,
1520        );
1521    }
1522
1523    v
1524}
1525
1526/// Grok Bot: one weekly meter for the included pool — plus the honest
1527/// window length when both period instants were reported, so the report
1528/// carries exact `window_secs` — or the no-included-allowance state, which is
1529/// a text row, never a 0% meter.
1530fn grokbot_sections(s: &crate::usage::GrokbotSnapshot, now: DateTime<Utc>) -> SectionBuilder {
1531    let mut v = SectionBuilder::new(vec![Section::Title {
1532        left: s.plan.clone(),
1533        right: None,
1534    }]);
1535    v.push(Section::Spacer);
1536    if !s.has_included_allowance {
1537        v.push(Section::Text {
1538            label: "Included usage".into(),
1539            value: "no included allowance on this account".into(),
1540        });
1541        return v;
1542    }
1543    let metric = Section::Metric {
1544        label: "Weekly".into(),
1545        pct: s.weekly_pct.clamp(0, 100) as u16,
1546        severity: severity_for(s.weekly_pct),
1547        value_label: format!("{}%", s.weekly_pct),
1548        footnote: format!("Resets in {}", countdown::format(s.reset_at, now)),
1549    };
1550    match s.window {
1551        Some(window) => v.push_metric_in_window(metric, s.reset_at, window),
1552        None => v.push_metric(metric, s.reset_at),
1553    }
1554    // At 100% with the account still serving, on-demand may be picking up the
1555    // rest — a footnote row, not its own meter.
1556    if let Some(note) = s.on_demand_note() {
1557        v.push(Section::Spacer);
1558        v.push(Section::Text {
1559            label: "On-demand".into(),
1560            value: note.into(),
1561        });
1562    }
1563    v
1564}
1565
1566fn push_window(
1567    sections: &mut SectionBuilder,
1568    label: &str,
1569    w: &crate::usage::UsageWindow,
1570    now: DateTime<Utc>,
1571    tol: u32,
1572    show_pacing: bool,
1573) {
1574    let pct = w.utilization_pct.clamp(0, 100) as u16;
1575    let reset_text = countdown::format(w.resets_at, now);
1576    let footnote = if show_pacing {
1577        let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
1578        format!(
1579            "Resets in {} · {}% elapsed · {}",
1580            reset_text, p.elapsed_pct, p.point_label
1581        )
1582    } else {
1583        format!("Resets in {}", reset_text)
1584    };
1585    sections.push(Section::Spacer);
1586    sections.push_metric_in_window(
1587        Section::Metric {
1588            label: label.into(),
1589            pct,
1590            severity: severity_for(pct as i32),
1591            value_label: format!("{pct}%"),
1592            footnote,
1593        },
1594        w.resets_at,
1595        w.window_duration,
1596    );
1597}
1598
1599/// Render the given sections into `area`. Lays them out vertically; metric
1600/// rows take 2 lines (label+gauge / footnote), text and spacer rows take 1.
1601///
1602/// The trailing "Updated …" footer is detected (the last `Text` section)
1603/// and pinned to the bottom of the area, with the slack absorbed *between*
1604/// content and footer. This way shorter vendor panels (OpenRouter, Z.AI)
1605/// don't leave a giant gap below the footer.
1606pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
1607    if sections.is_empty() {
1608        return;
1609    }
1610    let bubble = bubble_theme(theme);
1611    // Heuristic: if the last section is a Text starting with "  Updated",
1612    // pin it to the bottom. Otherwise just lay everything out top-down.
1613    let pin_last =
1614        matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
1615
1616    let body_end = if pin_last {
1617        sections.len() - 1
1618    } else {
1619        sections.len()
1620    };
1621    let mut constraints: Vec<Constraint> =
1622        sections[..body_end].iter().map(section_height).collect();
1623
1624    if pin_last {
1625        constraints.push(Constraint::Min(0)); // slack between body and footer
1626        constraints.push(section_height(sections.last().unwrap()));
1627    } else {
1628        constraints.push(Constraint::Min(0));
1629    }
1630
1631    let chunks = Layout::default()
1632        .direction(ratatui::layout::Direction::Vertical)
1633        .constraints(constraints)
1634        .split(area);
1635
1636    for (i, s) in sections[..body_end].iter().enumerate() {
1637        render_section(f, chunks[i], theme, &bubble, s);
1638    }
1639    if pin_last {
1640        render_section(
1641            f,
1642            chunks[chunks.len() - 1],
1643            theme,
1644            &bubble,
1645            sections.last().unwrap(),
1646        );
1647    }
1648}
1649
1650fn section_height(s: &Section) -> Constraint {
1651    match s {
1652        Section::Title { .. } => Constraint::Length(2),
1653        Section::Metric { .. } => Constraint::Length(3),
1654        Section::Text { .. } => Constraint::Length(1),
1655        Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
1656        Section::Spacer => Constraint::Length(1),
1657    }
1658}
1659
1660fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
1661    match s {
1662        Section::Title { left, right } => {
1663            // Left: bold accent-colored plan/vendor label. Right: dim-styled
1664            // "Updated HH:MM:SS" pinned to the right edge of the title row.
1665            let left_line = Line::from(Span::styled(
1666                format!("  {} {left}", bubble.symbols.selected),
1667                bubble.title,
1668            ));
1669            f.render_widget(Paragraph::new(left_line), area);
1670            if let Some(rt) = right {
1671                let right_line =
1672                    Line::from(Span::styled(format!("{rt}  "), bubble.muted)).right_aligned();
1673                f.render_widget(Paragraph::new(right_line), area);
1674            }
1675        }
1676        Section::Metric {
1677            label,
1678            pct,
1679            severity,
1680            value_label,
1681            footnote,
1682        } => render_metric(
1683            f,
1684            area,
1685            theme,
1686            bubble,
1687            label,
1688            *pct,
1689            *severity,
1690            value_label,
1691            footnote,
1692        ),
1693        Section::Text { label, value } => {
1694            if label.is_empty() && value.contains("Loading") {
1695                render_loading(f, area, bubble);
1696                return;
1697            }
1698            if label == "Error" {
1699                let line = Line::from(vec![
1700                    bubble.error(format!("  {} ", bubble.symbols.cross)),
1701                    Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
1702                ]);
1703                f.render_widget(Paragraph::new(line), area);
1704                return;
1705            }
1706            let mut spans = Vec::new();
1707            if !label.is_empty() {
1708                spans.push(Span::styled(
1709                    format!("  {label}  "),
1710                    bubble.text.add_modifier(Modifier::BOLD),
1711                ));
1712            }
1713            spans.push(Span::styled(value.clone(), bubble.muted));
1714            f.render_widget(Paragraph::new(Line::from(spans)), area);
1715        }
1716        Section::Block { label, body } => render_block(f, area, bubble, label, body),
1717        Section::Spacer => {}
1718    }
1719}
1720
1721fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
1722    let frames = SpinnerFrames::DOTS;
1723    let frame_count = frames.frames().len().max(1);
1724    let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
1725    let mut spinner = Spinner::new()
1726        .frames(frames)
1727        .label("Fetching usage data")
1728        .theme(*bubble);
1729    for _ in 0..(frame % frame_count) {
1730        spinner.tick();
1731    }
1732    f.render_widget(&spinner, area);
1733}
1734
1735#[allow(clippy::too_many_arguments)]
1736fn render_metric(
1737    f: &mut Frame,
1738    area: Rect,
1739    theme: &Theme,
1740    bubble: &BubbleTheme,
1741    label: &str,
1742    pct: u16,
1743    severity: PaceSeverity,
1744    value_label: &str,
1745    footnote: &str,
1746) {
1747    let bar_color = severity_color(theme, bubble, severity);
1748    let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
1749
1750    let inner = Layout::default()
1751        .direction(ratatui::layout::Direction::Vertical)
1752        .constraints([
1753            Constraint::Length(1),
1754            Constraint::Length(1),
1755            Constraint::Length(1),
1756        ])
1757        .split(area);
1758
1759    // Row 1: label
1760    let label_line = Line::from(Span::styled(
1761        format!("  {label}"),
1762        bubble.text.add_modifier(Modifier::BOLD),
1763    ));
1764    f.render_widget(Paragraph::new(label_line), inner[0]);
1765
1766    // Row 2: gauge spanning most of the width + value annotation on the right
1767    let row = inner[1];
1768    let value_w = crate::display::text_width(value_label) as u16 + 2;
1769    let gauge_area = Rect {
1770        x: row.x + 2,
1771        y: row.y,
1772        width: row.width.saturating_sub(value_w + 4),
1773        height: 1,
1774    };
1775    let value_area = Rect {
1776        x: gauge_area.x + gauge_area.width + 1,
1777        y: row.y,
1778        width: value_w,
1779        height: 1,
1780    };
1781    let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
1782    let progress = Progress::from_percent(pct)
1783        .theme(progress_theme)
1784        .show_percentage(false);
1785    f.render_widget(&progress, gauge_area);
1786    let value = Paragraph::new(Line::from(Span::styled(
1787        value_label.to_string(),
1788        Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
1789    )));
1790    f.render_widget(value, value_area);
1791
1792    // Row 3: footnote (dim)
1793    let foot = Line::from(Span::styled(format!("    {footnote}"), bubble.muted));
1794    f.render_widget(Paragraph::new(foot), inner[2]);
1795}
1796
1797fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
1798    let mut lines = vec![Line::from(Span::styled(
1799        format!("  {label}"),
1800        bubble.text.add_modifier(Modifier::BOLD),
1801    ))];
1802    for b in body {
1803        lines.push(Line::from(Span::styled(format!("    {b}"), bubble.muted)));
1804    }
1805    f.render_widget(Paragraph::new(lines), area);
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810    use super::*;
1811    use crate::usage::{
1812        AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
1813        OpenAiSource, OpenRouterSnapshot, ResetCredit, ResetCredits, UsageWindow, ZaiSnapshot,
1814    };
1815    use chrono::TimeZone;
1816
1817    fn now() -> DateTime<Utc> {
1818        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
1819    }
1820
1821    fn ready(snapshot: VendorSnapshot) -> TabState {
1822        TabState::Ready(Box::new(crate::tui::app::ReadyTab {
1823            snapshot,
1824            stale: false,
1825            last_error: None,
1826            fetched_at: Some(now() - chrono::Duration::seconds(15)),
1827        }))
1828    }
1829
1830    fn supergrok(period: crate::usage::SuperGrokPeriod) -> VendorSnapshot {
1831        VendorSnapshot::SuperGrok(crate::usage::SuperGrokSnapshot {
1832            plan: "SuperGrok".into(),
1833            account: "digest".into(),
1834            weekly_pct: 40,
1835            period,
1836            reset_at: Some(now() + chrono::Duration::days(2)),
1837            prepaid_balance: None,
1838            reset_credits: crate::usage::ResetCredits::default(),
1839            products: Vec::new(),
1840        })
1841    }
1842
1843    fn only_metric(sections: &[SectionProjection]) -> &SectionProjection {
1844        let mut metrics = sections
1845            .iter()
1846            .filter(|projection| matches!(projection.section, Section::Metric { .. }));
1847        let metric = metrics.next().expect("one metric row");
1848        assert!(metrics.next().is_none(), "expected exactly one metric row");
1849        metric
1850    }
1851
1852    /// Only a rolling window has a length a frontend can pace against. The
1853    /// shared `UsageWindow` helper always knows it; SuperGrok knows it for a
1854    /// week and not for a month, whose length varies.
1855    #[test]
1856    fn window_length_is_reported_only_when_exact() {
1857        use crate::usage::SuperGrokPeriod;
1858
1859        let weekly =
1860            sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Weekly)), now(), 5);
1861        assert_eq!(only_metric(&weekly).window, Some(chrono::Duration::days(7)));
1862
1863        let monthly =
1864            sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Monthly)), now(), 5);
1865        assert_eq!(only_metric(&monthly).window, None);
1866
1867        let unknown =
1868            sections_with_metadata_for(&ready(supergrok(SuperGrokPeriod::Unknown)), now(), 5);
1869        assert_eq!(only_metric(&unknown).window, None);
1870
1871        let kimi = sections_with_metadata_for(
1872            &ready(VendorSnapshot::Kimi(KimiSnapshot {
1873                plan: None,
1874                weekly_limit: 100,
1875                weekly_used: 10,
1876                weekly_remaining: 90,
1877                weekly_reset_at: Some(now() + chrono::Duration::days(3)),
1878                has_weekly: true,
1879                monthly_pct: None,
1880                monthly_reset_at: None,
1881                window_limit: 0,
1882                window_used: 0,
1883                window_remaining: 0,
1884                window_reset_at: None,
1885            })),
1886            now(),
1887            5,
1888        );
1889        assert_eq!(
1890            only_metric(&kimi).window,
1891            Some(crate::kimi::vendor::WEEKLY_WINDOW)
1892        );
1893    }
1894
1895    #[test]
1896    fn cursor_pools_carry_the_billing_cycle_window_only_when_it_is_exact() {
1897        let mut snap = cursor_snap();
1898        snap.cycle_start = Some(now() - chrono::Duration::days(22));
1899        let exact =
1900            sections_with_metadata_for(&ready(VendorSnapshot::Cursor(snap.clone())), now(), 5);
1901        let windows: Vec<_> = exact
1902            .iter()
1903            .filter(|p| matches!(p.section, Section::Metric { .. }))
1904            .map(|p| p.window)
1905            .collect();
1906        assert_eq!(
1907            windows,
1908            vec![
1909                Some(chrono::Duration::days(31)),
1910                Some(chrono::Duration::days(31))
1911            ]
1912        );
1913
1914        // Without `billingCycleStart` the cycle length is unknown. Reporting a
1915        // guessed month here would reach a frontend as an exact window and be
1916        // paced as one; every pool goes out with no window instead. The reset
1917        // time is unaffected.
1918        snap.cycle_start = None;
1919        let unknown = sections_with_metadata_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
1920        let pools: Vec<_> = unknown
1921            .iter()
1922            .filter(|p| matches!(p.section, Section::Metric { .. }))
1923            .collect();
1924        assert_eq!(pools.len(), 2);
1925        assert!(
1926            pools.iter().all(|p| p.window.is_none()),
1927            "an unstated billing cycle must not report a window length"
1928        );
1929        assert!(
1930            pools.iter().all(|p| p.reset_at.is_some()),
1931            "the reset time still travels with the row"
1932        );
1933    }
1934
1935    #[test]
1936    fn copilot_sections_carry_quota_reset_metadata() {
1937        let reset_at = now() + chrono::Duration::days(4);
1938        let snapshot = VendorSnapshot::Copilot(crate::copilot::types::Snapshot {
1939            plan: "Pro".into(),
1940            premium: Some(crate::copilot::types::Quota {
1941                percent_remaining: 25,
1942                entitlement: Some(300),
1943                remaining: Some(75),
1944                unlimited: false,
1945            }),
1946            chat: None,
1947            completions: None,
1948            reset_at: Some(reset_at),
1949        });
1950        let sections = sections_with_metadata_for(&ready(snapshot), now(), 5);
1951        assert!(matches!(
1952            &sections[0].section,
1953            Section::Title { left, .. } if left == "GitHub Copilot Pro"
1954        ));
1955        let metric = sections
1956            .iter()
1957            .find(|projection| matches!(&projection.section, Section::Metric { .. }))
1958            .expect("premium metric");
1959        assert_eq!(metric.reset_at, Some(reset_at));
1960        assert!(matches!(
1961            &metric.section,
1962            Section::Metric { label, pct, value_label, footnote, .. }
1963                if label == "Premium requests"
1964                    && *pct == 75
1965                    && value_label == "75%"
1966                    && footnote == "225 of 300 used"
1967        ));
1968    }
1969
1970    #[test]
1971    fn anthropic_sections_include_all_three_windows_when_present() {
1972        let snap = AnthropicSnapshot {
1973            plan: "Max 20x".into(),
1974            session: UsageWindow {
1975                utilization_pct: 60,
1976                resets_at: Some(now() + chrono::Duration::hours(1)),
1977                window_duration: chrono::Duration::hours(5),
1978            },
1979            weekly: UsageWindow {
1980                utilization_pct: 30,
1981                resets_at: Some(now() + chrono::Duration::days(3)),
1982                window_duration: chrono::Duration::days(7),
1983            },
1984            sonnet: Some(UsageWindow {
1985                utilization_pct: 5,
1986                resets_at: Some(now() + chrono::Duration::hours(2)),
1987                window_duration: chrono::Duration::days(7),
1988            }),
1989            scoped: vec![],
1990            extra: Some(ExtraUsage {
1991                limit: Some(Cents(5000)),
1992                spent: Cents(250),
1993                currency: None,
1994                decimal_places: Some(2),
1995            }),
1996        };
1997        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
1998        // Title (carries "Updated …" inline now) + 4 metrics (3 windows +
1999        // extra) each preceded by a Spacer. 1 + 4*2 = 9 sections.
2000        assert_eq!(sections.len(), 9);
2001        assert!(matches!(sections[0], Section::Title { .. }));
2002        // Title's right-aligned slot should carry the timestamp.
2003        if let Section::Title { right, .. } = &sections[0] {
2004            assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
2005        } else {
2006            panic!("expected first section to be Title");
2007        }
2008        let metric_count = sections
2009            .iter()
2010            .filter(|s| matches!(s, Section::Metric { .. }))
2011            .count();
2012        assert_eq!(metric_count, 4);
2013    }
2014
2015    #[test]
2016    fn anthropic_uncapped_extra_shows_spend_without_a_denominator() {
2017        // The #30 shape: `monthly_limit: null` (Pro). The panel must show the
2018        // spend alone — not "of $0.00", not an invented percentage.
2019        let snap = AnthropicSnapshot {
2020            plan: "Pro".into(),
2021            session: UsageWindow {
2022                utilization_pct: 10,
2023                resets_at: None,
2024                window_duration: chrono::Duration::hours(5),
2025            },
2026            weekly: UsageWindow {
2027                utilization_pct: 20,
2028                resets_at: None,
2029                window_duration: chrono::Duration::days(7),
2030            },
2031            sonnet: None,
2032            scoped: vec![],
2033            extra: Some(ExtraUsage {
2034                limit: None,
2035                spent: Cents(14157),
2036                currency: Some("BRL".into()),
2037                decimal_places: Some(2),
2038            }),
2039        };
2040        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
2041        let extra = sections
2042            .iter()
2043            .find_map(|s| match s {
2044                Section::Metric {
2045                    label,
2046                    pct,
2047                    value_label,
2048                    footnote,
2049                    ..
2050                } if label == "Extra usage" => Some((*pct, value_label.clone(), footnote.clone())),
2051                _ => None,
2052            })
2053            .expect("uncapped extra usage must still render a section");
2054        assert_eq!(extra.0, 0);
2055        // Non-vacuous currency pin: fmt_dollars would say "$141.57" here.
2056        assert_eq!(extra.1, "R$141.57");
2057        assert!(
2058            !extra.1.contains(" of "),
2059            "no denominator to show: {}",
2060            extra.1
2061        );
2062        assert_eq!(extra.2, "no monthly limit reported");
2063    }
2064
2065    #[test]
2066    fn anthropic_omits_sonnet_and_extra_when_absent() {
2067        let snap = AnthropicSnapshot {
2068            plan: "Pro".into(),
2069            session: UsageWindow {
2070                utilization_pct: 10,
2071                resets_at: None,
2072                window_duration: chrono::Duration::hours(5),
2073            },
2074            weekly: UsageWindow {
2075                utilization_pct: 5,
2076                resets_at: None,
2077                window_duration: chrono::Duration::days(7),
2078            },
2079            sonnet: None,
2080            scoped: vec![],
2081            extra: None,
2082        };
2083        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
2084        let metric_count = sections
2085            .iter()
2086            .filter(|s| matches!(s, Section::Metric { .. }))
2087            .count();
2088        assert_eq!(metric_count, 2);
2089    }
2090
2091    #[test]
2092    fn openrouter_always_has_balance_metric_and_period_block() {
2093        let snap = OpenRouterSnapshot {
2094            label: "OR".into(),
2095            total_credits: 100.0,
2096            total_usage: 25.0,
2097            usage_daily: 1.0,
2098            usage_weekly: 5.0,
2099            usage_monthly: 25.0,
2100            is_free_tier: false,
2101            limit: None,
2102            limit_remaining: None,
2103        };
2104        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
2105        assert!(matches!(sections[0], Section::Title { .. }));
2106        assert!(
2107            sections
2108                .iter()
2109                .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
2110        );
2111        assert!(
2112            sections
2113                .iter()
2114                .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
2115        );
2116    }
2117
2118    /// #118 reached every frontend, not just Waybar: the panel row is what the
2119    /// Omarchy, GNOME and KDE plugins colour and label from, so the debt has to
2120    /// survive the projection with its sign and its severity intact.
2121    #[test]
2122    fn openrouter_debt_reaches_the_panel_row_red_and_signed() {
2123        let snap = OpenRouterSnapshot {
2124            label: "OR".into(),
2125            total_credits: 0.0,
2126            total_usage: 5.71,
2127            usage_daily: 1.0,
2128            usage_weekly: 5.0,
2129            usage_monthly: 5.71,
2130            is_free_tier: false,
2131            limit: None,
2132            limit_remaining: None,
2133        };
2134        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap.clone())), now(), 5);
2135        let metric = sections
2136            .iter()
2137            .find_map(|s| match s {
2138                Section::Metric {
2139                    label,
2140                    value_label,
2141                    severity,
2142                    footnote,
2143                    ..
2144                } if label == "Credit balance" => Some((value_label, severity, footnote)),
2145                _ => None,
2146            })
2147            .expect("no credit balance metric");
2148        assert_eq!(metric.0, "-$5.71");
2149        assert_eq!(*metric.1, PaceSeverity::Critical);
2150        assert_eq!(metric.2, "$5.71 of $0.00 used (0%)");
2151
2152        // ...and the same number in the dense Overview list.
2153        let (_, cells) = compact_cells(&VendorSnapshot::Openrouter(snap));
2154        assert_eq!(cells[0].0, "-$5.71");
2155    }
2156
2157    /// The panels express pace as a footnote on the row; the arrow is the
2158    /// widget's idiom and the bar tick the menu bar's. All three Z.AI windows
2159    /// report a duration and a reset, so all three carry one.
2160    #[test]
2161    fn zai_windows_are_paced_like_every_other_percentage_vendor() {
2162        let window = |pct: i32, hours: i64, span: chrono::Duration| crate::usage::UsageWindow {
2163            utilization_pct: pct,
2164            resets_at: Some(now() + chrono::Duration::hours(hours)),
2165            window_duration: span,
2166        };
2167        let snap = ZaiSnapshot {
2168            plan: "GLM Coding Pro".into(),
2169            session: Some(window(40, 2, chrono::Duration::hours(5))),
2170            weekly: Some(window(60, 48, chrono::Duration::days(7))),
2171            mcp: Some(window(10, 200, chrono::Duration::days(30))),
2172        };
2173
2174        let footnotes: Vec<String> = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5)
2175            .into_iter()
2176            .filter_map(|section| match section {
2177                Section::Metric { footnote, .. } => Some(footnote),
2178                _ => None,
2179            })
2180            .collect();
2181
2182        assert_eq!(footnotes.len(), 3, "{footnotes:?}");
2183        for footnote in &footnotes {
2184            assert!(footnote.contains("% elapsed"), "{footnote}");
2185        }
2186        // 40% used with 60% of a 5h window gone: behind pace, not ahead.
2187        assert_eq!(
2188            footnotes[0], "Resets in 2h 00m · 60% elapsed · 20pts under",
2189            "{footnotes:?}"
2190        );
2191    }
2192
2193    #[test]
2194    fn zai_no_windows_renders_message() {
2195        let snap = ZaiSnapshot {
2196            plan: "GLM".into(),
2197            session: None,
2198            weekly: None,
2199            mcp: None,
2200        };
2201        let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
2202        assert!(sections.iter().any(|s| matches!(
2203            s,
2204            Section::Text { value, .. } if value.contains("no usage windows reported")
2205        )));
2206    }
2207
2208    #[test]
2209    fn openai_no_windows_renders_message() {
2210        let snap = OpenAiSnapshot {
2211            plan: "ChatGPT Plus".into(),
2212            session: None,
2213            weekly: None,
2214            code_review: None,
2215            additional_limits: Vec::new(),
2216            unavailable_models: Vec::new(),
2217            credits: None,
2218            reset_credits: ResetCredits::default(),
2219            source: OpenAiSource::CodexOauth,
2220        };
2221        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2222        assert!(sections.iter().any(|s| matches!(
2223            s,
2224            Section::Text { value, .. } if value.contains("no usage windows reported")
2225        )));
2226    }
2227
2228    #[test]
2229    fn loading_state_yields_loading_section() {
2230        let sections = sections_for(&TabState::Loading, now(), 5);
2231        assert!(sections.iter().any(|s| matches!(
2232            s,
2233            Section::Text { value, .. } if value.contains("Loading")
2234        )));
2235    }
2236
2237    #[test]
2238    fn error_state_includes_retry_hint() {
2239        let sections = sections_for(&TabState::error("token expired"), now(), 5);
2240        assert!(sections.iter().any(|s| matches!(
2241            s,
2242            Section::Text { value, .. } if value.contains("token expired")
2243        )));
2244        assert!(sections.iter().any(|s| matches!(
2245            s,
2246            Section::Text { value, .. } if value.contains("`r` to retry")
2247        )));
2248    }
2249
2250    #[test]
2251    fn error_state_keeps_oauth_plan_as_title() {
2252        let sections = sections_for(
2253            &TabState::error_with_plan("HTTP 401", Some("Claude Max 5x".into())),
2254            now(),
2255            5,
2256        );
2257        assert!(matches!(
2258            &sections[0],
2259            Section::Title { left, .. } if left == "Claude Max 5x"
2260        ));
2261        assert!(sections.iter().any(|s| matches!(
2262            s,
2263            Section::Text { value, .. } if value.contains("HTTP 401")
2264        )));
2265        assert!(!sections.iter().any(|s| matches!(s, Section::Metric { .. })));
2266    }
2267
2268    #[test]
2269    fn openai_with_credits_renders_block() {
2270        let snap = OpenAiSnapshot {
2271            plan: "ChatGPT Plus".into(),
2272            session: Some(UsageWindow {
2273                utilization_pct: 1,
2274                resets_at: None,
2275                window_duration: chrono::Duration::hours(5),
2276            }),
2277            weekly: Some(UsageWindow {
2278                utilization_pct: 0,
2279                resets_at: None,
2280                window_duration: chrono::Duration::days(7),
2281            }),
2282            code_review: None,
2283            additional_limits: Vec::new(),
2284            unavailable_models: Vec::new(),
2285            credits: Some(OpenAiCredits {
2286                balance: "$5.00".into(),
2287                has_credits: true,
2288                unlimited: false,
2289                approx_local_messages: Some((100, 200)),
2290                approx_cloud_messages: Some((30, 50)),
2291            }),
2292            reset_credits: ResetCredits::default(),
2293            source: OpenAiSource::CodexOauth,
2294        };
2295        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2296        assert!(
2297            sections
2298                .iter()
2299                .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
2300        );
2301    }
2302
2303    #[test]
2304    fn openai_weekly_only_omits_session_section() {
2305        let snap = OpenAiSnapshot {
2306            plan: "ChatGPT Prolite".into(),
2307            session: None,
2308            weekly: Some(UsageWindow {
2309                utilization_pct: 66,
2310                resets_at: None,
2311                window_duration: chrono::Duration::days(7),
2312            }),
2313            code_review: None,
2314            additional_limits: Vec::new(),
2315            unavailable_models: Vec::new(),
2316            credits: None,
2317            reset_credits: ResetCredits::default(),
2318            source: OpenAiSource::CodexOauth,
2319        };
2320        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2321        assert!(sections.iter().any(|section| matches!(
2322            section,
2323            Section::Metric { label, .. } if label == "Codex weekly"
2324        )));
2325        assert!(!sections.iter().any(|section| matches!(
2326            section,
2327            Section::Metric { label, .. } if label == "Codex 5h"
2328        )));
2329    }
2330
2331    /// Both vendors reach every frontend through these sections — the TUI
2332    /// panel, `usage --json`, and from there the Omarchy, GNOME and KDE
2333    /// surfaces. One row, one wording, whichever provider banked the reset.
2334    #[test]
2335    fn banked_resets_reach_the_panel_for_both_providers() {
2336        let now = now();
2337        let credits = ResetCredits {
2338            available: 2,
2339            credits: vec![
2340                ResetCredit {
2341                    title: Some("Full reset (Weekly + 5 hr)".into()),
2342                    expires_at: Some(now + chrono::Duration::days(13)),
2343                },
2344                ResetCredit {
2345                    title: Some("Full reset (Weekly + 5 hr)".into()),
2346                    expires_at: Some(now + chrono::Duration::days(13) + chrono::Duration::hours(6)),
2347                },
2348            ],
2349        };
2350        let codex = OpenAiSnapshot {
2351            plan: "ChatGPT Plus".into(),
2352            session: None,
2353            weekly: None,
2354            code_review: None,
2355            additional_limits: Vec::new(),
2356            unavailable_models: Vec::new(),
2357            credits: None,
2358            reset_credits: credits.clone(),
2359            source: OpenAiSource::CodexOauth,
2360        };
2361        let supergrok = crate::usage::SuperGrokSnapshot {
2362            plan: "SuperGrok".into(),
2363            account: "scope".into(),
2364            weekly_pct: 30,
2365            period: crate::usage::SuperGrokPeriod::Weekly,
2366            reset_at: Some(now + chrono::Duration::days(3)),
2367            prepaid_balance: None,
2368            reset_credits: credits,
2369            products: Vec::new(),
2370        };
2371
2372        for snapshot in [
2373            VendorSnapshot::Openai(codex),
2374            VendorSnapshot::SuperGrok(supergrok),
2375        ] {
2376            let sections = sections_for(&ready(snapshot), now, 5);
2377            let body = sections.iter().find_map(|section| match section {
2378                Section::Block { label, body } if label == "Reset credits" => Some(body.clone()),
2379                _ => None,
2380            });
2381            let body = body.expect("reset credits block");
2382            assert_eq!(body.len(), 2, "{body:?}");
2383            assert!(
2384                body.iter()
2385                    .all(|line| line.contains("Full reset (Weekly + 5 hr)")),
2386                "{body:?}"
2387            );
2388        }
2389    }
2390
2391    /// The row is absent, not zeroed: an account that has never earned a reset
2392    /// should not carry a permanent "0 resets available" line.
2393    #[test]
2394    fn a_provider_with_no_banked_resets_shows_no_reset_row() {
2395        let snap = OpenAiSnapshot {
2396            plan: "ChatGPT Plus".into(),
2397            session: None,
2398            weekly: None,
2399            code_review: None,
2400            additional_limits: Vec::new(),
2401            unavailable_models: Vec::new(),
2402            credits: None,
2403            reset_credits: ResetCredits::default(),
2404            source: OpenAiSource::CodexOauth,
2405        };
2406        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
2407        assert!(!sections.iter().any(|section| matches!(
2408            section,
2409            Section::Block { label, .. } if label == "Reset credits"
2410        )));
2411    }
2412
2413    #[test]
2414    fn supergrok_does_not_repeat_the_window_reset_as_its_own_section() {
2415        let now = now();
2416        let snap = crate::usage::SuperGrokSnapshot {
2417            plan: "SuperGrok".into(),
2418            account: "scope".into(),
2419            weekly_pct: 0,
2420            period: crate::usage::SuperGrokPeriod::Weekly,
2421            reset_at: Some(now + chrono::Duration::days(6)),
2422            prepaid_balance: Some(0.0),
2423            reset_credits: ResetCredits::default(),
2424            products: Vec::new(),
2425        };
2426        let sections = sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5);
2427        assert!(!sections.iter().any(|section| matches!(
2428            section,
2429            Section::Text { label, .. } if label == "Resets"
2430        )));
2431        // Zero prepaid is noise (see `supergrok_hides_a_zero_prepaid_balance`),
2432        // so even a present-but-zero field draws no row.
2433        assert!(!sections.iter().any(|section| matches!(
2434            section,
2435            Section::Text { label, .. } if label == "Prepaid API"
2436        )));
2437    }
2438
2439    /// A $0.00 prepaid line reads as "no money" when the billing document
2440    /// merely reports that nothing was purchased on top of the subscription.
2441    /// The row appears only when there is credit to show.
2442    #[test]
2443    fn supergrok_hides_a_zero_prepaid_balance_but_keeps_a_real_one() {
2444        let now = now();
2445        let base = |prepaid: Option<f64>| crate::usage::SuperGrokSnapshot {
2446            plan: "SuperGrok".into(),
2447            account: "scope".into(),
2448            weekly_pct: 40,
2449            period: crate::usage::SuperGrokPeriod::Weekly,
2450            reset_at: Some(now + chrono::Duration::days(6)),
2451            prepaid_balance: prepaid,
2452            reset_credits: ResetCredits::default(),
2453            products: Vec::new(),
2454        };
2455        let labels = |snap| {
2456            sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5)
2457                .into_iter()
2458                .filter_map(|section| match section {
2459                    Section::Text { label, value, .. } => Some((label, value)),
2460                    _ => None,
2461                })
2462                .collect::<Vec<_>>()
2463        };
2464        assert!(
2465            !labels(base(Some(0.0)))
2466                .iter()
2467                .any(|(label, _)| label == "Prepaid API")
2468        );
2469        assert_eq!(
2470            labels(base(Some(4.22))).last(),
2471            Some(&("Prepaid API".to_string(), "$4.22".to_string()))
2472        );
2473    }
2474
2475    #[test]
2476    fn supergrok_lists_product_slices_beside_the_overall_meter() {
2477        let now = now();
2478        let snap = crate::usage::SuperGrokSnapshot {
2479            plan: "SuperGrok".into(),
2480            account: "scope".into(),
2481            weekly_pct: 90,
2482            period: crate::usage::SuperGrokPeriod::Weekly,
2483            reset_at: Some(now + chrono::Duration::days(3)),
2484            prepaid_balance: None,
2485            reset_credits: ResetCredits::default(),
2486            products: vec![
2487                crate::usage::SuperGrokProduct {
2488                    label: "Grok Build".into(),
2489                    percent: 87,
2490                },
2491                crate::usage::SuperGrokProduct {
2492                    label: "Grok Chat".into(),
2493                    percent: 3,
2494                },
2495            ],
2496        };
2497        let labels: Vec<_> = sections_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5)
2498            .into_iter()
2499            .filter_map(|section| match section {
2500                Section::Metric { label, pct, .. } => Some((label, pct)),
2501                _ => None,
2502            })
2503            .collect();
2504        assert_eq!(
2505            labels,
2506            vec![
2507                ("Weekly usage".into(), 90),
2508                ("Grok Build".into(), 87),
2509                ("Grok Chat".into(), 3),
2510            ]
2511        );
2512    }
2513
2514    /// Product slices report the "Breakdown" group so frontends can draw them
2515    /// under a heading; the overall meter stays ungrouped, and neither gains
2516    /// reset metadata it must not have.
2517    #[test]
2518    fn supergrok_product_slices_carry_the_breakdown_group() {
2519        let now = now();
2520        let snap = crate::usage::SuperGrokSnapshot {
2521            plan: "SuperGrok".into(),
2522            account: "scope".into(),
2523            weekly_pct: 90,
2524            period: crate::usage::SuperGrokPeriod::Weekly,
2525            reset_at: Some(now + chrono::Duration::days(3)),
2526            prepaid_balance: None,
2527            reset_credits: ResetCredits::default(),
2528            products: vec![crate::usage::SuperGrokProduct {
2529                label: "Grok Build".into(),
2530                percent: 87,
2531            }],
2532        };
2533        let projected = sections_with_metadata_for(&ready(VendorSnapshot::SuperGrok(snap)), now, 5);
2534        let mut metrics = projected
2535            .iter()
2536            .filter(|p| matches!(p.section, Section::Metric { .. }));
2537        let overall = metrics.next().expect("overall usage metric");
2538        let build = metrics.next().expect("one product slice metric");
2539        assert!(metrics.next().is_none(), "expected exactly two metric rows");
2540        assert_eq!(overall.group, None);
2541        assert!(overall.reset_at.is_some());
2542        assert_eq!(build.group, Some("Breakdown"));
2543        assert_eq!(build.reset_at, None);
2544        assert_eq!(build.window, None);
2545    }
2546
2547    #[test]
2548    fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
2549        let now = now();
2550        let snap = KimiSnapshot {
2551            plan: Some("LEVEL_INTERMEDIATE".into()),
2552            weekly_limit: 100,
2553            weekly_used: 26,
2554            weekly_remaining: 74,
2555            weekly_reset_at: Some(now + chrono::Duration::days(4)),
2556            has_weekly: true,
2557            monthly_pct: None,
2558            monthly_reset_at: None,
2559            window_limit: 100,
2560            window_used: 15,
2561            window_remaining: 85,
2562            window_reset_at: Some(now + chrono::Duration::hours(2)),
2563        };
2564        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
2565        let metrics: Vec<_> = sections
2566            .iter()
2567            .filter(|s| matches!(s, Section::Metric { .. }))
2568            .collect();
2569        assert_eq!(metrics.len(), 2);
2570        assert!(sections.iter().any(|s| matches!(
2571            s,
2572            Section::Metric { label, .. } if label == "Weekly quota"
2573        )));
2574        assert!(sections.iter().any(|s| matches!(
2575            s,
2576            Section::Metric { label, .. } if label == "Rolling window (5h)"
2577        )));
2578
2579        let find_footnote = |label: &str| -> (String, String) {
2580            sections
2581                .iter()
2582                .find_map(|s| match s {
2583                    Section::Metric {
2584                        label: l,
2585                        value_label,
2586                        footnote,
2587                        ..
2588                    } if l == label => Some((value_label.clone(), footnote.clone())),
2589                    _ => None,
2590                })
2591                .unwrap_or_else(|| panic!("missing metric {label}"))
2592        };
2593
2594        // The bar carries the percentage, so the footnote is the plain
2595        // `Resets in …` every other window row shows — not the counters, which
2596        // against Kimi's limit of 100 only restate the percentage.
2597        let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
2598        assert_eq!(weekly_value, "26%");
2599        assert_eq!(weekly_footnote, "Resets in 4d 0h");
2600
2601        let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
2602        assert_eq!(window_value, "15%");
2603        assert_eq!(window_footnote, "Resets in 2h 00m");
2604    }
2605
2606    /// Every vendor holding both a short and a long window opens on the short
2607    /// one — Claude's `Session (5h)`, Codex's `Codex 5h`, GLM's `Session (5h)`,
2608    /// OpenCode Go's `Rolling`. Kimi's rolling bucket is that window, so it
2609    /// leads both projections this module feeds: the section list the Quattro
2610    /// panel and the KDE plasmoid render in order, and the Overview's compact
2611    /// cells.
2612    #[test]
2613    fn kimi_leads_with_the_rolling_window_like_every_other_two_window_vendor() {
2614        let now = now();
2615        let snap = KimiSnapshot {
2616            plan: Some("LEVEL_INTERMEDIATE".into()),
2617            weekly_limit: 100,
2618            weekly_used: 26,
2619            weekly_remaining: 74,
2620            weekly_reset_at: Some(now + chrono::Duration::days(4)),
2621            has_weekly: true,
2622            monthly_pct: None,
2623            monthly_reset_at: None,
2624            window_limit: 100,
2625            window_used: 15,
2626            window_remaining: 85,
2627            window_reset_at: Some(now + chrono::Duration::hours(2)),
2628        };
2629        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap.clone())), now, 5);
2630        let labels: Vec<&str> = sections
2631            .iter()
2632            .filter_map(|s| match s {
2633                Section::Metric { label, .. } => Some(label.as_str()),
2634                _ => None,
2635            })
2636            .collect();
2637        assert_eq!(labels, ["Rolling window (5h)", "Weekly quota"]);
2638
2639        let (_, cells) = compact_cells(&VendorSnapshot::Kimi(snap));
2640        let texts: Vec<&str> = cells.iter().map(|(text, _)| text.as_str()).collect();
2641        assert_eq!(texts, ["5h 15%", "wk 26%"]);
2642    }
2643
2644    #[test]
2645    fn kimi_sections_omit_window_when_limit_zero() {
2646        let snap = KimiSnapshot {
2647            plan: None,
2648            weekly_limit: 100,
2649            weekly_used: 10,
2650            weekly_remaining: 90,
2651            weekly_reset_at: None,
2652            has_weekly: true,
2653            monthly_pct: None,
2654            monthly_reset_at: None,
2655            window_limit: 0,
2656            window_used: 0,
2657            window_remaining: 0,
2658            window_reset_at: None,
2659        };
2660        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
2661        let metric_count = sections
2662            .iter()
2663            .filter(|s| matches!(s, Section::Metric { .. }))
2664            .count();
2665        assert_eq!(metric_count, 1);
2666    }
2667
2668    /// The newer `usages`-map shape has no weekly bucket: the panel drops the
2669    /// weekly row and lists the monthly pool — with its reset, but with no
2670    /// window metadata, because the reset hangs on the order date and there
2671    /// is no fixed length to pace against.
2672    #[test]
2673    fn kimi_sections_on_the_monthly_shape_drop_weekly_and_add_monthly() {
2674        let now = now();
2675        let snap = KimiSnapshot {
2676            plan: Some("Allegretto".into()),
2677            weekly_limit: 0,
2678            weekly_used: 0,
2679            weekly_remaining: 0,
2680            weekly_reset_at: None,
2681            has_weekly: false,
2682            monthly_pct: Some(42),
2683            monthly_reset_at: Some(now + chrono::Duration::days(30)),
2684            window_limit: 100,
2685            window_used: 15,
2686            window_remaining: 85,
2687            window_reset_at: Some(now + chrono::Duration::hours(2)),
2688        };
2689        let projected =
2690            sections_with_metadata_for(&ready(VendorSnapshot::Kimi(snap.clone())), now, 5);
2691        let metrics: Vec<_> = projected
2692            .iter()
2693            .filter(|p| matches!(p.section, Section::Metric { .. }))
2694            .collect();
2695        assert_eq!(metrics.len(), 2);
2696        let monthly = metrics
2697            .iter()
2698            .find(|p| matches!(&p.section, Section::Metric { label, .. } if label == "Monthly"))
2699            .expect("a Monthly metric");
2700        assert!(matches!(
2701            &monthly.section,
2702            Section::Metric { value_label, footnote, .. }
2703            if value_label == "42%" && footnote == "Resets in 30d 0h"
2704        ));
2705        assert_eq!(monthly.reset_at, Some(now + chrono::Duration::days(30)));
2706        assert_eq!(monthly.window, None, "no fixed window length, no pacing");
2707        assert!(!metrics.iter().any(
2708            |p| matches!(&p.section, Section::Metric { label, .. } if label == "Weekly quota")
2709        ));
2710
2711        // The Overview cells follow the same presence rules.
2712        let (_, cells) = compact_cells(&VendorSnapshot::Kimi(snap));
2713        let texts: Vec<&str> = cells.iter().map(|(text, _)| text.as_str()).collect();
2714        assert_eq!(texts, ["5h 15%", "mo 42%"]);
2715    }
2716
2717    fn cursor_snap() -> crate::usage::CursorSnapshot {
2718        crate::usage::CursorSnapshot {
2719            plan: "Ultra".into(),
2720            auto_pct: 98,
2721            api_pct: 100,
2722            total_pct: 99,
2723            unlimited: false,
2724            on_demand_enabled: false,
2725            on_demand_used_cents: None,
2726            on_demand_limit_cents: None,
2727            reset_at: Some(now() + chrono::Duration::days(9)),
2728            cycle_start: None,
2729        }
2730    }
2731
2732    #[test]
2733    fn compact_cells_flatten_key_metrics_for_the_overview() {
2734        // Percent vendor (Cursor): plan + two colored pool cells.
2735        let (plan, cells) = compact_cells(&VendorSnapshot::Cursor(cursor_snap()));
2736        assert_eq!(plan, "Ultra");
2737        assert_eq!(cells[0].0, "auto 98%");
2738        assert_eq!(cells[1].0, "premium 100%");
2739        assert_eq!(cells[1].1, PaceSeverity::Critical); // 100% is critical
2740
2741        // Balance vendor (Kilo): no plan, a single money cell, calm severity.
2742        let (plan, cells) = compact_cells(&VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
2743            label: "Kilo".into(),
2744            balance: 8.42,
2745        }));
2746        assert!(plan.is_empty());
2747        assert_eq!(cells, vec![("$8.42".to_string(), PaceSeverity::Low)]);
2748    }
2749
2750    #[test]
2751    fn terminal_controls_are_removed_from_detail_and_overview_fields() {
2752        let error = TabState::error("bad\x1b]52;c;Y2FuYXJ5\x07 value");
2753        let sections = sections_for(&error, now(), 5);
2754        assert!(matches!(
2755            &sections[1],
2756            Section::Text { value, .. }
2757                if value == "bad]52;c;Y2FuYXJ5 value"
2758                    && !value.chars().any(|ch| ch.is_control())
2759        ));
2760
2761        let mut snapshot = cursor_snap();
2762        snapshot.plan = "Ultra\x1b[2J\x07".into();
2763        let (plan, _) = compact_cells(&VendorSnapshot::Cursor(snapshot));
2764        assert_eq!(plan, "Ultra[2J");
2765        assert!(!plan.chars().any(char::is_control));
2766    }
2767
2768    #[test]
2769    fn headline_pct_is_the_worst_window_or_combined_total() {
2770        // Cursor: the combined total, not the worse pool (mirrors the menu bar).
2771        assert_eq!(
2772            headline_pct(&VendorSnapshot::Cursor(cursor_snap())),
2773            Some(99)
2774        );
2775
2776        // Balance-only vendors have no meaningful percentage → no bar.
2777        let kilo = VendorSnapshot::Kilo(crate::usage::KiloSnapshot {
2778            label: "Kilo".into(),
2779            balance: 8.42,
2780        });
2781        assert_eq!(headline_pct(&kilo), None);
2782    }
2783
2784    #[test]
2785    fn cursor_sections_show_both_pools_and_reset() {
2786        let mut snapshot = cursor_snap();
2787        snapshot.on_demand_enabled = true;
2788        snapshot.on_demand_used_cents = Some(1785);
2789        snapshot.on_demand_limit_cents = Some(35000);
2790        let sections = sections_for(&ready(VendorSnapshot::Cursor(snapshot)), now(), 5);
2791        let metrics: Vec<_> = sections
2792            .iter()
2793            .filter_map(|s| match s {
2794                Section::Metric {
2795                    label, value_label, ..
2796                } => Some((label.clone(), value_label.clone())),
2797                _ => None,
2798            })
2799            .collect();
2800        assert_eq!(metrics.len(), 2, "two pools");
2801        assert!(
2802            metrics
2803                .iter()
2804                .any(|(l, v)| l == "Cursor Models" && v == "98%")
2805        );
2806        assert!(
2807            metrics
2808                .iter()
2809                .any(|(l, v)| l == "Other Models" && v == "100%")
2810        );
2811        assert!(sections.iter().any(|section| matches!(
2812            section,
2813            Section::Text { label, value }
2814                if label == "On-Demand" && value == "$17.85 / $350.00"
2815        )));
2816        assert!(sections.iter().any(|s| matches!(
2817            s,
2818            Section::Text { label, value } if label == "Resets" && value.contains("9d")
2819        )));
2820    }
2821
2822    #[test]
2823    fn cursor_unlimited_plan_shows_no_pool_bars() {
2824        let mut snap = cursor_snap();
2825        snap.unlimited = true;
2826        let sections = sections_for(&ready(VendorSnapshot::Cursor(snap)), now(), 5);
2827        let metric_count = sections
2828            .iter()
2829            .filter(|s| matches!(s, Section::Metric { .. }))
2830            .count();
2831        assert_eq!(metric_count, 0);
2832        assert!(sections.iter().any(|s| matches!(
2833            s,
2834            Section::Text { value, .. } if value.contains("Unlimited")
2835        )));
2836    }
2837
2838    fn kiro_snap() -> crate::usage::KiroSnapshot {
2839        crate::usage::KiroSnapshot {
2840            plan: "KIRO POWER".into(),
2841            used: 9943.38,
2842            limit: 10000.0,
2843            reset_at: Some(now() + chrono::Duration::days(1)),
2844        }
2845    }
2846
2847    #[test]
2848    fn kiro_compact_cell_shows_the_credit_percentage() {
2849        let (plan, cells) = compact_cells(&VendorSnapshot::Kiro(kiro_snap()));
2850        assert_eq!(plan, "KIRO POWER");
2851        assert_eq!(
2852            cells,
2853            vec![("credits 99%".to_string(), PaceSeverity::Critical)]
2854        );
2855    }
2856
2857    #[test]
2858    fn kiro_headline_pct_is_the_credit_percentage() {
2859        assert_eq!(headline_pct(&VendorSnapshot::Kiro(kiro_snap())), Some(99));
2860    }
2861
2862    #[test]
2863    fn kiro_sections_show_the_credit_metric_and_reset() {
2864        let sections = sections_for(&ready(VendorSnapshot::Kiro(kiro_snap())), now(), 5);
2865        let metrics: Vec<_> = sections
2866            .iter()
2867            .filter_map(|s| match s {
2868                Section::Metric {
2869                    label, value_label, ..
2870                } => Some((label.clone(), value_label.clone())),
2871                _ => None,
2872            })
2873            .collect();
2874        assert_eq!(metrics, vec![("Credits".to_string(), "99%".to_string())]);
2875        assert!(sections.iter().any(|s| matches!(
2876            s,
2877            Section::Text { label, value } if label == "Resets" && value.contains("1d")
2878        )));
2879    }
2880
2881    fn grokbot_snap() -> crate::usage::GrokbotSnapshot {
2882        crate::usage::GrokbotSnapshot {
2883            plan: "Grok Bot Plan".into(),
2884            has_included_allowance: true,
2885            weekly_pct: 42,
2886            has_available_usage: true,
2887            on_demand_enabled: false,
2888            period_start: Some(now() - chrono::Duration::days(3)),
2889            reset_at: Some(now() + chrono::Duration::days(4)),
2890            window: Some(chrono::Duration::days(7)),
2891        }
2892    }
2893
2894    #[test]
2895    fn grokbot_sections_show_one_weekly_meter_with_the_derived_window() {
2896        let sections =
2897            sections_with_metadata_for(&ready(VendorSnapshot::Grokbot(grokbot_snap())), now(), 5);
2898        let metric = only_metric(&sections);
2899        let Section::Metric {
2900            label,
2901            value_label,
2902            footnote,
2903            ..
2904        } = &metric.section
2905        else {
2906            unreachable!()
2907        };
2908        assert_eq!(label, "Weekly");
2909        assert_eq!(value_label, "42%");
2910        assert!(footnote.contains("Resets in"), "{footnote}");
2911        // The honest derived window, so a frontend paces against 7d exactly.
2912        assert_eq!(metric.window, Some(chrono::Duration::days(7)));
2913        assert_eq!(metric.reset_at, grokbot_snap().reset_at);
2914    }
2915
2916    #[test]
2917    fn grokbot_no_allowance_state_is_a_text_row_not_a_meter() {
2918        let snap = crate::usage::GrokbotSnapshot {
2919            has_included_allowance: false,
2920            weekly_pct: 0,
2921            ..grokbot_snap()
2922        };
2923        let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap.clone())), now(), 5);
2924        assert!(
2925            sections
2926                .iter()
2927                .all(|s| !matches!(s, Section::Metric { .. })),
2928            "no meter without an included allowance"
2929        );
2930        assert!(sections.iter().any(|s| matches!(
2931            s,
2932            Section::Text { value, .. } if value.contains("no included allowance")
2933        )));
2934        assert_eq!(headline_pct(&VendorSnapshot::Grokbot(snap)), None);
2935        let (_, cells) = compact_cells(&VendorSnapshot::Grokbot(grokbot_snap()));
2936        assert_eq!(cells.len(), 1);
2937        assert!(cells[0].0.contains("42%"), "{cells:?}");
2938    }
2939
2940    #[test]
2941    fn grokbot_on_demand_footnote_is_a_text_row_when_it_applies() {
2942        let mut snap = grokbot_snap();
2943        snap.weekly_pct = 100;
2944        snap.has_available_usage = true;
2945        snap.on_demand_enabled = true;
2946        let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2947        assert!(sections.iter().any(|s| matches!(
2948            s,
2949            Section::Text { label, value } if label == "On-demand" && value.contains("on-demand")
2950        )));
2951
2952        let mut snap = grokbot_snap();
2953        snap.weekly_pct = 100;
2954        snap.has_available_usage = true;
2955        snap.on_demand_enabled = false;
2956        let sections = sections_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2957        assert!(
2958            sections
2959                .iter()
2960                .all(|s| !matches!(s, Section::Text { label, .. } if label == "On-demand")),
2961            "on-demand off: no footnote"
2962        );
2963    }
2964
2965    #[test]
2966    fn grokbot_without_a_period_start_reports_no_window() {
2967        let mut snap = grokbot_snap();
2968        snap.period_start = None;
2969        snap.window = None;
2970        let sections = sections_with_metadata_for(&ready(VendorSnapshot::Grokbot(snap)), now(), 5);
2971        assert_eq!(only_metric(&sections).window, None);
2972    }
2973
2974    #[test]
2975    fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
2976        let snap = KimiSnapshot {
2977            plan: None,
2978            weekly_limit: 100,
2979            weekly_used: 10,
2980            weekly_remaining: 90,
2981            weekly_reset_at: None,
2982            has_weekly: true,
2983            monthly_pct: None,
2984            monthly_reset_at: None,
2985            window_limit: 0,
2986            window_used: 0,
2987            window_remaining: 0,
2988            window_reset_at: None,
2989        };
2990        let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
2991        let TabState::Ready(tab) = &mut schema else {
2992            unreachable!()
2993        };
2994        tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
2995        let schema_sections = sections_for(&schema, now(), 5);
2996        assert!(schema_sections.iter().any(|section| matches!(
2997            section,
2998            Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
2999        )));
3000
3001        let mut generic = ready(VendorSnapshot::Kimi(snap));
3002        let TabState::Ready(tab) = &mut generic else {
3003            unreachable!()
3004        };
3005        tab.last_error = Some((0, "cache lock unavailable".into()));
3006        let generic_sections = sections_for(&generic, now(), 5);
3007        assert!(generic_sections.iter().any(|section| matches!(
3008            section,
3009            Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
3010        )));
3011        assert!(!generic_sections.iter().any(|section| matches!(
3012            section,
3013            Section::Text { label, .. } if label.starts_with("HTTP")
3014        )));
3015
3016        let http = warning_label(
3017            &VendorSnapshot::Kimi(KimiSnapshot {
3018                plan: None,
3019                weekly_limit: 0,
3020                weekly_used: 0,
3021                weekly_remaining: 0,
3022                weekly_reset_at: None,
3023                has_weekly: true,
3024                monthly_pct: None,
3025                monthly_reset_at: None,
3026                window_limit: 0,
3027                window_used: 0,
3028                window_remaining: 0,
3029                window_reset_at: None,
3030            }),
3031            &Some((503, "service unavailable".into())),
3032        );
3033        assert_eq!(
3034            http,
3035            Some(("HTTP 503".into(), "service unavailable".into()))
3036        );
3037    }
3038
3039    fn antigravity_snap(source: crate::usage::AntigravitySource) -> VendorSnapshot {
3040        VendorSnapshot::Antigravity(crate::usage::AntigravitySnapshot {
3041            plan: "Pro".into(),
3042            account: "acct:test".into(),
3043            source,
3044            session: Some(UsageWindow {
3045                utilization_pct: 43,
3046                resets_at: Some(now() + chrono::Duration::hours(2)),
3047                window_duration: chrono::Duration::hours(5),
3048            }),
3049            weekly: None,
3050            third_party_session: None,
3051            third_party_weekly: None,
3052        })
3053    }
3054
3055    /// Figures read off the API while nothing runs say so; a running product's
3056    /// do not, since that is the normal case.
3057    #[test]
3058    fn antigravity_names_the_remote_source_and_only_that() {
3059        use crate::usage::AntigravitySource;
3060
3061        let remote = sections_for(
3062            &ready(antigravity_snap(AntigravitySource::Remote)),
3063            now(),
3064            5,
3065        );
3066        let n = remote.len();
3067        assert!(matches!(remote[n - 2], Section::Spacer));
3068        assert!(matches!(
3069            &remote[n - 1],
3070            Section::Text { label, value }
3071                if label == "Source" && value == "Google API"
3072        ));
3073
3074        let local = sections_for(&ready(antigravity_snap(AntigravitySource::Local)), now(), 5);
3075        assert!(
3076            !local
3077                .iter()
3078                .any(|s| matches!(s, Section::Text { label, .. } if label == "Source"))
3079        );
3080    }
3081
3082    /// A custom provider's rows come out in declaration order: title, gauges,
3083    /// then texts. Only a metric that states its window length carries one;
3084    /// every metric's own `resets_at` rides along as reset metadata.
3085    #[test]
3086    fn custom_sections_follow_declaration_order_and_carry_reset_metadata() {
3087        use crate::custom::types::{CustomMetric, CustomSnapshot, CustomText};
3088
3089        let session_reset = now() + chrono::Duration::hours(3);
3090        let monthly_reset = now() + chrono::Duration::days(12);
3091        let snapshot = VendorSnapshot::Custom(CustomSnapshot {
3092            plan: Some("Team".into()),
3093            metrics: vec![
3094                CustomMetric {
3095                    label: "Session".into(),
3096                    pct: 40,
3097                    footnote: "40 of 100".into(),
3098                    resets_at: Some(session_reset),
3099                    window_secs: Some(18_000),
3100                },
3101                CustomMetric {
3102                    label: "Monthly".into(),
3103                    pct: 120,
3104                    footnote: String::new(),
3105                    resets_at: Some(monthly_reset),
3106                    window_secs: None,
3107                },
3108            ],
3109            texts: vec![CustomText {
3110                label: "Region".into(),
3111                value: "eu".into(),
3112            }],
3113        });
3114
3115        let sections = sections_with_metadata_for(&ready(snapshot.clone()), now(), 5);
3116        assert!(matches!(
3117            &sections[0].section,
3118            Section::Title { left, right } if left == "Team" && right.is_some()
3119        ));
3120        assert!(matches!(sections[1].section, Section::Spacer));
3121        assert!(matches!(
3122            &sections[2].section,
3123            Section::Metric { label, pct, value_label, footnote, .. }
3124                if label == "Session" && *pct == 40 && value_label == "40%" && footnote == "40 of 100"
3125        ));
3126        assert_eq!(sections[2].reset_at, Some(session_reset));
3127        assert_eq!(sections[2].window, Some(chrono::Duration::hours(5)));
3128        // An over-100 percentage is clamped for the gauge; no window is invented.
3129        assert!(matches!(
3130            &sections[3].section,
3131            Section::Metric { label, pct, value_label, .. }
3132                if label == "Monthly" && *pct == 100 && value_label == "100%"
3133        ));
3134        assert_eq!(sections[3].reset_at, Some(monthly_reset));
3135        assert_eq!(sections[3].window, None);
3136        assert!(matches!(sections[4].section, Section::Spacer));
3137        assert!(matches!(
3138            &sections[5].section,
3139            Section::Text { label, value } if label == "Region" && value == "eu"
3140        ));
3141        assert_eq!(sections.len(), 6);
3142
3143        let (plan, cells) = compact_cells(&snapshot);
3144        assert_eq!(plan, "Team");
3145        assert_eq!(cells[0].0, "Session 40%");
3146        assert_eq!(cells[1].0, "Monthly 120%");
3147        assert_eq!(headline_pct(&snapshot), Some(40));
3148
3149        // No plan and no texts: an empty title, no trailing spacer, no bar.
3150        let bare = VendorSnapshot::Custom(CustomSnapshot {
3151            plan: None,
3152            metrics: vec![],
3153            texts: vec![],
3154        });
3155        let sections = sections_with_metadata_for(&ready(bare.clone()), now(), 5);
3156        assert!(matches!(&sections[0].section, Section::Title { left, .. } if left.is_empty()));
3157        assert_eq!(sections.len(), 2);
3158        assert_eq!(compact_cells(&bare), (String::new(), vec![]));
3159        assert_eq!(headline_pct(&bare), None);
3160    }
3161}