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/// Build the section list for the currently-active vendor's snapshot.
55pub fn sections_for(tab: &TabState, now: DateTime<Utc>, pace_tolerance: u32) -> Vec<Section> {
56    match tab {
57        TabState::Loading => vec![
58            Section::Spacer,
59            Section::Text {
60                label: "".into(),
61                value: "  Loading…".into(),
62            },
63        ],
64        TabState::Error(e) => vec![
65            Section::Spacer,
66            Section::Text {
67                label: "Error".into(),
68                value: e.clone(),
69            },
70            Section::Spacer,
71            Section::Text {
72                label: "".into(),
73                value: "Press `r` to retry, `q` to quit.".into(),
74            },
75        ],
76        TabState::Ready(r) => {
77            let snapshot = &r.snapshot;
78            let last_error = &r.last_error;
79            let mut sections = match snapshot {
80                VendorSnapshot::Anthropic(s) => anthropic_sections(s, now, pace_tolerance),
81                VendorSnapshot::Openai(s) => openai_sections(s, now, pace_tolerance),
82                VendorSnapshot::Zai(s) => zai_sections(s, now),
83                VendorSnapshot::Openrouter(s) => openrouter_sections(s),
84                VendorSnapshot::Deepseek(s) => deepseek_sections(s),
85                VendorSnapshot::Kimi(s) => kimi_sections(s, now, pace_tolerance),
86            };
87            // Inject the (already-absolute) fetched-at instant into the title
88            // row, right-aligned. Pre-snapshotted in app::refresh_one so it
89            // doesn't drift between redraws.
90            let updated = match r.fetched_at {
91                Some(at) => format!("Updated {}", local_time_hms(at)),
92                None => "Updated —".to_string(),
93            };
94            if let Some(Section::Title { right, .. }) = sections.first_mut() {
95                *right = Some(updated);
96            }
97            // Error footer (when present) still lives in the body.
98            if let Some((label, msg)) = warning_label(snapshot, last_error) {
99                sections.push(Section::Spacer);
100                sections.push(Section::Text { label, value: msg });
101            }
102            sections
103        }
104    }
105}
106
107/// Translate cache diagnostics at the presentation boundary. Cache files keep
108/// their established `(u16, String)` form: only non-zero codes are HTTP, while
109/// Kimi's stable schema marker identifies its code-zero schema warning.
110fn warning_label(
111    snapshot: &VendorSnapshot,
112    last_error: &Option<(u16, String)>,
113) -> Option<(String, String)> {
114    let (code, message) = last_error.as_ref()?;
115    if *code != 0 {
116        return Some((format!("HTTP {code}"), message.clone()));
117    }
118    if message.is_empty() {
119        return None;
120    }
121    let label = if matches!(snapshot, VendorSnapshot::Kimi(_))
122        && matches!(
123            crate::kimi::vendor::warning_kind(*code, message),
124            crate::kimi::vendor::WarningKind::SchemaDrift
125        ) {
126        "Kimi API schema drift"
127    } else {
128        "Warning"
129    };
130    // The stable marker is already the schema-warning label. Keep the label
131    // visible but do not repeat that sentinel as a redundant body value.
132    let value = if label == message {
133        String::new()
134    } else {
135        message.clone()
136    };
137    Some((label.into(), value))
138}
139
140fn anthropic_sections(
141    s: &crate::usage::AnthropicSnapshot,
142    now: DateTime<Utc>,
143    tol: u32,
144) -> Vec<Section> {
145    let mut v = vec![Section::Title {
146        left: format!("Claude {}", s.plan),
147        right: None,
148    }];
149
150    push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
151    push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
152    if let Some(w) = &s.sonnet {
153        push_window(&mut v, "Sonnet only", w, now, tol, false);
154    }
155    for sw in &s.scoped {
156        push_window(
157            &mut v,
158            &format!("{} (7d)", sw.label),
159            &sw.window,
160            now,
161            tol,
162            false,
163        );
164    }
165    if let Some(e) = &s.extra {
166        v.push(Section::Spacer);
167        let pct = e.percent().clamp(0, 100) as u16;
168        v.push(Section::Metric {
169            label: "Extra usage".into(),
170            pct,
171            severity: severity_for(pct as i32),
172            value_label: format!("{} of {}", e.spent.fmt_dollars(), e.limit.fmt_dollars()),
173            footnote: format!("{}% of monthly limit consumed", pct),
174        });
175    }
176    v
177}
178
179fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
180    let mut v = vec![Section::Title {
181        left: s.plan.clone(),
182        right: None,
183    }];
184    push_window(&mut v, "Codex 5h", &s.session, now, tol, true);
185    push_window(&mut v, "Codex weekly", &s.weekly, now, tol, true);
186    if let Some(cr) = &s.code_review {
187        push_window(&mut v, "Code review", cr, now, tol, false);
188    }
189    if let Some(c) = &s.credits {
190        v.push(Section::Spacer);
191        let balance = if c.unlimited {
192            "unlimited".into()
193        } else {
194            c.balance.clone()
195        };
196        let mut body = vec![format!("balance: {}", balance)];
197        if let Some((lo, hi)) = c.approx_local_messages {
198            body.push(format!("≈ {lo}-{hi} local messages"));
199        }
200        if let Some((lo, hi)) = c.approx_cloud_messages {
201            body.push(format!("≈ {lo}-{hi} cloud messages"));
202        }
203        v.push(Section::Block {
204            label: "Credits".into(),
205            body,
206        });
207    }
208    v
209}
210
211fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
212    let mut v = vec![Section::Title {
213        left: s.plan.clone(),
214        right: None,
215    }];
216    if let Some(w) = &s.session {
217        push_window(&mut v, "Session (5h)", w, now, 5, false);
218    }
219    if let Some(w) = &s.weekly {
220        push_window(&mut v, "Weekly", w, now, 5, false);
221    }
222    if let Some(w) = &s.mcp {
223        push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
224    }
225    if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
226        v.push(Section::Spacer);
227        v.push(Section::Text {
228            label: "".into(),
229            value: "  no usage windows reported".into(),
230        });
231    }
232    v
233}
234
235fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
236    let mut v = vec![Section::Title {
237        left: s.label.clone(),
238        right: None,
239    }];
240    let pct = s.consumed_pct().clamp(0, 100) as u16;
241    v.push(Section::Spacer);
242    v.push(Section::Metric {
243        label: "Credit balance".into(),
244        pct,
245        severity: severity_for(pct as i32),
246        value_label: format!("${:.2}", s.balance()),
247        footnote: format!(
248            "${:.2} of ${:.2} used ({pct}%)",
249            s.total_usage, s.total_credits
250        ),
251    });
252    v.push(Section::Spacer);
253    v.push(Section::Block {
254        label: "Usage by period".into(),
255        body: vec![format!(
256            "today ${:.2} · week ${:.2} · month ${:.2}",
257            s.usage_daily, s.usage_weekly, s.usage_monthly
258        )],
259    });
260    if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
261        v.push(Section::Spacer);
262        v.push(Section::Block {
263            label: "Per-key limit".into(),
264            body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
265        });
266    }
267    v.push(Section::Spacer);
268    v.push(Section::Block {
269        label: "Tier".into(),
270        body: vec![if s.is_free_tier {
271            "free tier".into()
272        } else {
273            "paid tier".into()
274        }],
275    });
276    v
277}
278
279fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
280    let currency = &s.currency;
281    let fmt = |v: f64| match currency.as_str() {
282        "USD" => format!("${v:.2}"),
283        "CNY" => format!("¥{v:.2}"),
284        _ => format!("{v:.2} {currency}"),
285    };
286    let avail = if s.is_available {
287        "available"
288    } else {
289        "unavailable"
290    };
291    let mut v = vec![Section::Title {
292        left: "DeepSeek".into(),
293        right: None,
294    }];
295    v.push(Section::Spacer);
296    v.push(Section::Text {
297        label: "Balance".into(),
298        value: fmt(s.balance),
299    });
300    v.push(Section::Block {
301        label: "Breakdown".into(),
302        body: vec![format!(
303            "granted {} · topped-up {}",
304            fmt(s.granted),
305            fmt(s.topped_up)
306        )],
307    });
308    v.push(Section::Spacer);
309    v.push(Section::Block {
310        label: "API".into(),
311        body: vec![avail.into()],
312    });
313    v
314}
315
316fn kimi_sections(s: &crate::usage::KimiSnapshot, now: DateTime<Utc>, _tol: u32) -> Vec<Section> {
317    let plan = s.plan.as_deref().unwrap_or("Kimi");
318    let mut v = vec![Section::Title {
319        left: plan.into(),
320        right: None,
321    }];
322
323    let weekly_pct = s.weekly_pct().clamp(0, 100) as u16;
324    v.push(Section::Spacer);
325    v.push(Section::Metric {
326        label: "Weekly quota".into(),
327        pct: weekly_pct,
328        severity: severity_for(s.weekly_pct()),
329        value_label: format!("{} / {}", s.weekly_used, s.weekly_limit),
330        footnote: format!(
331            "{} remaining · reset {}",
332            s.weekly_remaining,
333            countdown::format(s.weekly_reset_at, now)
334        ),
335    });
336
337    if s.window_limit > 0 {
338        let window_pct = s.window_pct().clamp(0, 100) as u16;
339        v.push(Section::Spacer);
340        v.push(Section::Metric {
341            label: "Rolling window (5h)".into(),
342            pct: window_pct,
343            severity: severity_for(s.window_pct()),
344            value_label: format!("{} / {}", s.window_used, s.window_limit),
345            footnote: format!(
346                "{} remaining · reset {}",
347                s.window_remaining,
348                countdown::format(s.window_reset_at, now)
349            ),
350        });
351    }
352
353    v
354}
355
356fn push_window(
357    sections: &mut Vec<Section>,
358    label: &str,
359    w: &crate::usage::UsageWindow,
360    now: DateTime<Utc>,
361    tol: u32,
362    show_pacing: bool,
363) {
364    let pct = w.utilization_pct.clamp(0, 100) as u16;
365    let reset_text = countdown::format(w.resets_at, now);
366    let footnote = if show_pacing {
367        let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
368        format!(
369            "Resets in {} · {}% elapsed · {}",
370            reset_text, p.elapsed_pct, p.point_label
371        )
372    } else {
373        format!("Resets in {}", reset_text)
374    };
375    sections.push(Section::Spacer);
376    sections.push(Section::Metric {
377        label: label.into(),
378        pct,
379        severity: severity_for(pct as i32),
380        value_label: format!("{pct}%"),
381        footnote,
382    });
383}
384
385/// Render the given sections into `area`. Lays them out vertically; metric
386/// rows take 2 lines (label+gauge / footnote), text and spacer rows take 1.
387///
388/// The trailing "Updated …" footer is detected (the last `Text` section)
389/// and pinned to the bottom of the area, with the slack absorbed *between*
390/// content and footer. This way shorter vendor panels (OpenRouter, Z.AI)
391/// don't leave a giant gap below the footer.
392pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
393    if sections.is_empty() {
394        return;
395    }
396    let bubble = bubble_theme(theme);
397    // Heuristic: if the last section is a Text starting with "  Updated",
398    // pin it to the bottom. Otherwise just lay everything out top-down.
399    let pin_last =
400        matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
401
402    let body_end = if pin_last {
403        sections.len() - 1
404    } else {
405        sections.len()
406    };
407    let mut constraints: Vec<Constraint> =
408        sections[..body_end].iter().map(section_height).collect();
409
410    if pin_last {
411        constraints.push(Constraint::Min(0)); // slack between body and footer
412        constraints.push(section_height(sections.last().unwrap()));
413    } else {
414        constraints.push(Constraint::Min(0));
415    }
416
417    let chunks = Layout::default()
418        .direction(ratatui::layout::Direction::Vertical)
419        .constraints(constraints)
420        .split(area);
421
422    for (i, s) in sections[..body_end].iter().enumerate() {
423        render_section(f, chunks[i], theme, &bubble, s);
424    }
425    if pin_last {
426        render_section(
427            f,
428            chunks[chunks.len() - 1],
429            theme,
430            &bubble,
431            sections.last().unwrap(),
432        );
433    }
434}
435
436fn section_height(s: &Section) -> Constraint {
437    match s {
438        Section::Title { .. } => Constraint::Length(2),
439        Section::Metric { .. } => Constraint::Length(3),
440        Section::Text { .. } => Constraint::Length(1),
441        Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
442        Section::Spacer => Constraint::Length(1),
443    }
444}
445
446fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
447    match s {
448        Section::Title { left, right } => {
449            // Left: bold accent-colored plan/vendor label. Right: dim-styled
450            // "Updated HH:MM:SS" pinned to the right edge of the title row.
451            let left_line = Line::from(Span::styled(
452                format!("  {} {left}", bubble.symbols.selected),
453                bubble.title,
454            ));
455            f.render_widget(Paragraph::new(left_line), area);
456            if let Some(rt) = right {
457                let right_line =
458                    Line::from(Span::styled(format!("{rt}  "), bubble.muted)).right_aligned();
459                f.render_widget(Paragraph::new(right_line), area);
460            }
461        }
462        Section::Metric {
463            label,
464            pct,
465            severity,
466            value_label,
467            footnote,
468        } => render_metric(
469            f,
470            area,
471            theme,
472            bubble,
473            label,
474            *pct,
475            *severity,
476            value_label,
477            footnote,
478        ),
479        Section::Text { label, value } => {
480            if label.is_empty() && value.contains("Loading") {
481                render_loading(f, area, bubble);
482                return;
483            }
484            if label == "Error" {
485                let line = Line::from(vec![
486                    bubble.error(format!("  {} ", bubble.symbols.cross)),
487                    Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
488                ]);
489                f.render_widget(Paragraph::new(line), area);
490                return;
491            }
492            let mut spans = Vec::new();
493            if !label.is_empty() {
494                spans.push(Span::styled(
495                    format!("  {label}  "),
496                    bubble.text.add_modifier(Modifier::BOLD),
497                ));
498            }
499            spans.push(Span::styled(value.clone(), bubble.muted));
500            f.render_widget(Paragraph::new(Line::from(spans)), area);
501        }
502        Section::Block { label, body } => render_block(f, area, bubble, label, body),
503        Section::Spacer => {}
504    }
505}
506
507fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
508    let frames = SpinnerFrames::DOTS;
509    let frame_count = frames.frames().len().max(1);
510    let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
511    let mut spinner = Spinner::new()
512        .frames(frames)
513        .label("Fetching usage data")
514        .theme(*bubble);
515    for _ in 0..(frame % frame_count) {
516        spinner.tick();
517    }
518    f.render_widget(&spinner, area);
519}
520
521#[allow(clippy::too_many_arguments)]
522fn render_metric(
523    f: &mut Frame,
524    area: Rect,
525    theme: &Theme,
526    bubble: &BubbleTheme,
527    label: &str,
528    pct: u16,
529    severity: PaceSeverity,
530    value_label: &str,
531    footnote: &str,
532) {
533    let bar_color = severity_color(theme, bubble, severity);
534    let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
535
536    let inner = Layout::default()
537        .direction(ratatui::layout::Direction::Vertical)
538        .constraints([
539            Constraint::Length(1),
540            Constraint::Length(1),
541            Constraint::Length(1),
542        ])
543        .split(area);
544
545    // Row 1: label
546    let label_line = Line::from(Span::styled(
547        format!("  {label}"),
548        bubble.text.add_modifier(Modifier::BOLD),
549    ));
550    f.render_widget(Paragraph::new(label_line), inner[0]);
551
552    // Row 2: gauge spanning most of the width + value annotation on the right
553    let row = inner[1];
554    let value_w = value_label.chars().count() as u16 + 2;
555    let gauge_area = Rect {
556        x: row.x + 2,
557        y: row.y,
558        width: row.width.saturating_sub(value_w + 4),
559        height: 1,
560    };
561    let value_area = Rect {
562        x: gauge_area.x + gauge_area.width + 1,
563        y: row.y,
564        width: value_w,
565        height: 1,
566    };
567    let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
568    let progress = Progress::from_percent(pct)
569        .theme(progress_theme)
570        .show_percentage(false);
571    f.render_widget(&progress, gauge_area);
572    let value = Paragraph::new(Line::from(Span::styled(
573        value_label.to_string(),
574        Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
575    )));
576    f.render_widget(value, value_area);
577
578    // Row 3: footnote (dim)
579    let foot = Line::from(Span::styled(format!("    {footnote}"), bubble.muted));
580    f.render_widget(Paragraph::new(foot), inner[2]);
581}
582
583fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
584    let mut lines = vec![Line::from(Span::styled(
585        format!("  {label}"),
586        bubble.text.add_modifier(Modifier::BOLD),
587    ))];
588    for b in body {
589        lines.push(Line::from(Span::styled(format!("    {b}"), bubble.muted)));
590    }
591    f.render_widget(Paragraph::new(lines), area);
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::usage::{
598        AnthropicSnapshot, Cents, ExtraUsage, KimiSnapshot, OpenAiCredits, OpenAiSnapshot,
599        OpenAiSource, OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
600    };
601    use chrono::TimeZone;
602
603    fn now() -> DateTime<Utc> {
604        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
605    }
606
607    fn ready(snapshot: VendorSnapshot) -> TabState {
608        TabState::Ready(Box::new(crate::tui::app::ReadyTab {
609            snapshot,
610            stale: false,
611            last_error: None,
612            fetched_at: Some(now() - chrono::Duration::seconds(15)),
613        }))
614    }
615
616    #[test]
617    fn anthropic_sections_include_all_three_windows_when_present() {
618        let snap = AnthropicSnapshot {
619            plan: "Max 20x".into(),
620            session: UsageWindow {
621                utilization_pct: 60,
622                resets_at: Some(now() + chrono::Duration::hours(1)),
623                window_duration: chrono::Duration::hours(5),
624            },
625            weekly: UsageWindow {
626                utilization_pct: 30,
627                resets_at: Some(now() + chrono::Duration::days(3)),
628                window_duration: chrono::Duration::days(7),
629            },
630            sonnet: Some(UsageWindow {
631                utilization_pct: 5,
632                resets_at: Some(now() + chrono::Duration::hours(2)),
633                window_duration: chrono::Duration::days(7),
634            }),
635            scoped: vec![],
636            extra: Some(ExtraUsage {
637                limit: Cents(5000),
638                spent: Cents(250),
639            }),
640        };
641        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
642        // Title (carries "Updated …" inline now) + 4 metrics (3 windows +
643        // extra) each preceded by a Spacer. 1 + 4*2 = 9 sections.
644        assert_eq!(sections.len(), 9);
645        assert!(matches!(sections[0], Section::Title { .. }));
646        // Title's right-aligned slot should carry the timestamp.
647        if let Section::Title { right, .. } = &sections[0] {
648            assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
649        } else {
650            panic!("expected first section to be Title");
651        }
652        let metric_count = sections
653            .iter()
654            .filter(|s| matches!(s, Section::Metric { .. }))
655            .count();
656        assert_eq!(metric_count, 4);
657    }
658
659    #[test]
660    fn anthropic_omits_sonnet_and_extra_when_absent() {
661        let snap = AnthropicSnapshot {
662            plan: "Pro".into(),
663            session: UsageWindow {
664                utilization_pct: 10,
665                resets_at: None,
666                window_duration: chrono::Duration::hours(5),
667            },
668            weekly: UsageWindow {
669                utilization_pct: 5,
670                resets_at: None,
671                window_duration: chrono::Duration::days(7),
672            },
673            sonnet: None,
674            scoped: vec![],
675            extra: None,
676        };
677        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
678        let metric_count = sections
679            .iter()
680            .filter(|s| matches!(s, Section::Metric { .. }))
681            .count();
682        assert_eq!(metric_count, 2);
683    }
684
685    #[test]
686    fn openrouter_always_has_balance_metric_and_period_block() {
687        let snap = OpenRouterSnapshot {
688            label: "OR".into(),
689            total_credits: 100.0,
690            total_usage: 25.0,
691            usage_daily: 1.0,
692            usage_weekly: 5.0,
693            usage_monthly: 25.0,
694            is_free_tier: false,
695            limit: None,
696            limit_remaining: None,
697        };
698        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
699        assert!(matches!(sections[0], Section::Title { .. }));
700        assert!(
701            sections
702                .iter()
703                .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
704        );
705        assert!(
706            sections
707                .iter()
708                .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
709        );
710    }
711
712    #[test]
713    fn zai_no_windows_renders_message() {
714        let snap = ZaiSnapshot {
715            plan: "GLM".into(),
716            session: None,
717            weekly: None,
718            mcp: None,
719        };
720        let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
721        assert!(sections.iter().any(|s| matches!(
722            s,
723            Section::Text { value, .. } if value.contains("no usage windows reported")
724        )));
725    }
726
727    #[test]
728    fn loading_state_yields_loading_section() {
729        let sections = sections_for(&TabState::Loading, now(), 5);
730        assert!(sections.iter().any(|s| matches!(
731            s,
732            Section::Text { value, .. } if value.contains("Loading")
733        )));
734    }
735
736    #[test]
737    fn error_state_includes_retry_hint() {
738        let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
739        assert!(sections.iter().any(|s| matches!(
740            s,
741            Section::Text { value, .. } if value.contains("token expired")
742        )));
743        assert!(sections.iter().any(|s| matches!(
744            s,
745            Section::Text { value, .. } if value.contains("`r` to retry")
746        )));
747    }
748
749    #[test]
750    fn openai_with_credits_renders_block() {
751        let snap = OpenAiSnapshot {
752            plan: "ChatGPT Plus".into(),
753            session: UsageWindow {
754                utilization_pct: 1,
755                resets_at: None,
756                window_duration: chrono::Duration::hours(5),
757            },
758            weekly: UsageWindow {
759                utilization_pct: 0,
760                resets_at: None,
761                window_duration: chrono::Duration::days(7),
762            },
763            code_review: None,
764            credits: Some(OpenAiCredits {
765                balance: "$5.00".into(),
766                has_credits: true,
767                unlimited: false,
768                approx_local_messages: Some((100, 200)),
769                approx_cloud_messages: Some((30, 50)),
770            }),
771            source: OpenAiSource::CodexOauth,
772        };
773        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
774        assert!(
775            sections
776                .iter()
777                .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
778        );
779    }
780
781    #[test]
782    fn kimi_sections_include_weekly_and_window_with_used_over_limit() {
783        let now = now();
784        let snap = KimiSnapshot {
785            plan: Some("LEVEL_INTERMEDIATE".into()),
786            weekly_limit: 100,
787            weekly_used: 26,
788            weekly_remaining: 74,
789            weekly_reset_at: Some(now + chrono::Duration::days(4)),
790            window_limit: 100,
791            window_used: 15,
792            window_remaining: 85,
793            window_reset_at: Some(now + chrono::Duration::hours(2)),
794        };
795        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now, 5);
796        let metrics: Vec<_> = sections
797            .iter()
798            .filter(|s| matches!(s, Section::Metric { .. }))
799            .collect();
800        assert_eq!(metrics.len(), 2);
801        assert!(sections.iter().any(|s| matches!(
802            s,
803            Section::Metric { label, .. } if label == "Weekly quota"
804        )));
805        assert!(sections.iter().any(|s| matches!(
806            s,
807            Section::Metric { label, .. } if label == "Rolling window (5h)"
808        )));
809
810        let find_footnote = |label: &str| -> (String, String) {
811            sections
812                .iter()
813                .find_map(|s| match s {
814                    Section::Metric {
815                        label: l,
816                        value_label,
817                        footnote,
818                        ..
819                    } if l == label => Some((value_label.clone(), footnote.clone())),
820                    _ => None,
821                })
822                .unwrap_or_else(|| panic!("missing metric {label}"))
823        };
824
825        let (weekly_value, weekly_footnote) = find_footnote("Weekly quota");
826        assert_eq!(weekly_value, "26 / 100");
827        assert!(weekly_footnote.contains("74 remaining"));
828        assert!(
829            weekly_footnote.contains("4d 0h"),
830            "weekly reset countdown: {weekly_footnote}"
831        );
832        assert!(!weekly_footnote.contains("2026-05-27T")); // not a raw RFC3339
833
834        let (window_value, window_footnote) = find_footnote("Rolling window (5h)");
835        assert_eq!(window_value, "15 / 100");
836        assert!(window_footnote.contains("85 remaining"));
837        assert!(
838            window_footnote.contains("2h 00m"),
839            "window reset countdown: {window_footnote}"
840        );
841        assert!(!window_footnote.contains("2026-05-23T14")); // not a raw RFC3339
842    }
843
844    #[test]
845    fn kimi_sections_omit_window_when_limit_zero() {
846        let snap = KimiSnapshot {
847            plan: None,
848            weekly_limit: 100,
849            weekly_used: 10,
850            weekly_remaining: 90,
851            weekly_reset_at: None,
852            window_limit: 0,
853            window_used: 0,
854            window_remaining: 0,
855            window_reset_at: None,
856        };
857        let sections = sections_for(&ready(VendorSnapshot::Kimi(snap)), now(), 5);
858        let metric_count = sections
859            .iter()
860            .filter(|s| matches!(s, Section::Metric { .. }))
861            .count();
862        assert_eq!(metric_count, 1);
863    }
864
865    #[test]
866    fn schema_drift_and_generic_code_zero_diagnostics_are_visible_without_http_labels() {
867        let snap = KimiSnapshot {
868            plan: None,
869            weekly_limit: 100,
870            weekly_used: 10,
871            weekly_remaining: 90,
872            weekly_reset_at: None,
873            window_limit: 0,
874            window_used: 0,
875            window_remaining: 0,
876            window_reset_at: None,
877        };
878        let mut schema = ready(VendorSnapshot::Kimi(snap.clone()));
879        let TabState::Ready(tab) = &mut schema else {
880            unreachable!()
881        };
882        tab.last_error = Some((0, crate::kimi::fetch::SCHEMA_DRIFT_MESSAGE.into()));
883        let schema_sections = sections_for(&schema, now(), 5);
884        assert!(schema_sections.iter().any(|section| matches!(
885            section,
886            Section::Text { label, value } if label == "Kimi API schema drift" && value.is_empty()
887        )));
888
889        let mut generic = ready(VendorSnapshot::Kimi(snap));
890        let TabState::Ready(tab) = &mut generic else {
891            unreachable!()
892        };
893        tab.last_error = Some((0, "cache lock unavailable".into()));
894        let generic_sections = sections_for(&generic, now(), 5);
895        assert!(generic_sections.iter().any(|section| matches!(
896            section,
897            Section::Text { label, value } if label == "Warning" && value == "cache lock unavailable"
898        )));
899        assert!(!generic_sections.iter().any(|section| matches!(
900            section,
901            Section::Text { label, .. } if label.starts_with("HTTP")
902        )));
903
904        let http = warning_label(
905            &VendorSnapshot::Kimi(KimiSnapshot {
906                plan: None,
907                weekly_limit: 0,
908                weekly_used: 0,
909                weekly_remaining: 0,
910                weekly_reset_at: None,
911                window_limit: 0,
912                window_used: 0,
913                window_remaining: 0,
914                window_reset_at: None,
915            }),
916            &Some((503, "service unavailable".into())),
917        );
918        assert_eq!(
919            http,
920            Some(("HTTP 503".into(), "service unavailable".into()))
921        );
922    }
923}