Skip to main content

ai_usagebar/tui/
panels.rs

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