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