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