Skip to main content

ai_usagebar/tui/
panels.rs

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