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