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