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