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