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            };
86            // Inject the (already-absolute) fetched-at instant into the title
87            // row, right-aligned. Pre-snapshotted in app::refresh_one so it
88            // doesn't drift between redraws.
89            let updated = match r.fetched_at {
90                Some(at) => format!("Updated {}", local_time_hms(at)),
91                None => "Updated —".to_string(),
92            };
93            if let Some(Section::Title { right, .. }) = sections.first_mut() {
94                *right = Some(updated);
95            }
96            // Error footer (when present) still lives in the body.
97            if let Some((code, msg)) = last_error
98                && *code != 0
99            {
100                sections.push(Section::Spacer);
101                sections.push(Section::Text {
102                    label: format!("HTTP {code}"),
103                    value: msg.clone(),
104                });
105            }
106            sections
107        }
108    }
109}
110
111fn anthropic_sections(
112    s: &crate::usage::AnthropicSnapshot,
113    now: DateTime<Utc>,
114    tol: u32,
115) -> Vec<Section> {
116    let mut v = vec![Section::Title {
117        left: format!("Claude {}", s.plan),
118        right: None,
119    }];
120
121    push_window(&mut v, "Session (5h)", &s.session, now, tol, true);
122    push_window(&mut v, "Weekly (7d)", &s.weekly, now, tol, true);
123    if let Some(w) = &s.sonnet {
124        push_window(&mut v, "Sonnet only", w, now, tol, false);
125    }
126    if let Some(e) = &s.extra {
127        v.push(Section::Spacer);
128        let pct = e.percent().clamp(0, 100) as u16;
129        v.push(Section::Metric {
130            label: "Extra usage".into(),
131            pct,
132            severity: severity_for(pct as i32),
133            value_label: format!("{} of {}", e.spent.fmt_dollars(), e.limit.fmt_dollars()),
134            footnote: format!("{}% of monthly limit consumed", pct),
135        });
136    }
137    v
138}
139
140fn openai_sections(s: &crate::usage::OpenAiSnapshot, now: DateTime<Utc>, tol: u32) -> Vec<Section> {
141    let mut v = vec![Section::Title {
142        left: s.plan.clone(),
143        right: None,
144    }];
145    push_window(&mut v, "Codex 5h", &s.session, now, tol, true);
146    push_window(&mut v, "Codex weekly", &s.weekly, now, tol, true);
147    if let Some(cr) = &s.code_review {
148        push_window(&mut v, "Code review", cr, now, tol, false);
149    }
150    if let Some(c) = &s.credits {
151        v.push(Section::Spacer);
152        let balance = if c.unlimited {
153            "unlimited".into()
154        } else {
155            c.balance.clone()
156        };
157        let mut body = vec![format!("balance: {}", balance)];
158        if let Some((lo, hi)) = c.approx_local_messages {
159            body.push(format!("≈ {lo}-{hi} local messages"));
160        }
161        if let Some((lo, hi)) = c.approx_cloud_messages {
162            body.push(format!("≈ {lo}-{hi} cloud messages"));
163        }
164        v.push(Section::Block {
165            label: "Credits".into(),
166            body,
167        });
168    }
169    v
170}
171
172fn zai_sections(s: &crate::usage::ZaiSnapshot, now: DateTime<Utc>) -> Vec<Section> {
173    let mut v = vec![Section::Title {
174        left: s.plan.clone(),
175        right: None,
176    }];
177    if let Some(w) = &s.session {
178        push_window(&mut v, "Session (5h)", w, now, 5, false);
179    }
180    if let Some(w) = &s.weekly {
181        push_window(&mut v, "Weekly", w, now, 5, false);
182    }
183    if let Some(w) = &s.mcp {
184        push_window(&mut v, "MCP tools (monthly)", w, now, 5, false);
185    }
186    if s.session.is_none() && s.weekly.is_none() && s.mcp.is_none() {
187        v.push(Section::Spacer);
188        v.push(Section::Text {
189            label: "".into(),
190            value: "  no usage windows reported".into(),
191        });
192    }
193    v
194}
195
196fn openrouter_sections(s: &crate::usage::OpenRouterSnapshot) -> Vec<Section> {
197    let mut v = vec![Section::Title {
198        left: s.label.clone(),
199        right: None,
200    }];
201    let pct = s.consumed_pct().clamp(0, 100) as u16;
202    v.push(Section::Spacer);
203    v.push(Section::Metric {
204        label: "Credit balance".into(),
205        pct,
206        severity: severity_for(pct as i32),
207        value_label: format!("${:.2}", s.balance()),
208        footnote: format!(
209            "${:.2} of ${:.2} used ({pct}%)",
210            s.total_usage, s.total_credits
211        ),
212    });
213    v.push(Section::Spacer);
214    v.push(Section::Block {
215        label: "Usage by period".into(),
216        body: vec![format!(
217            "today ${:.2} · week ${:.2} · month ${:.2}",
218            s.usage_daily, s.usage_weekly, s.usage_monthly
219        )],
220    });
221    if let (Some(limit), Some(rem)) = (s.limit, s.limit_remaining) {
222        v.push(Section::Spacer);
223        v.push(Section::Block {
224            label: "Per-key limit".into(),
225            body: vec![format!("${:.2} of ${:.2} remaining", rem, limit)],
226        });
227    }
228    v.push(Section::Spacer);
229    v.push(Section::Block {
230        label: "Tier".into(),
231        body: vec![if s.is_free_tier {
232            "free tier".into()
233        } else {
234            "paid tier".into()
235        }],
236    });
237    v
238}
239
240fn deepseek_sections(s: &crate::usage::DeepseekSnapshot) -> Vec<Section> {
241    let currency = &s.currency;
242    let fmt = |v: f64| match currency.as_str() {
243        "USD" => format!("${v:.2}"),
244        "CNY" => format!("¥{v:.2}"),
245        _ => format!("{v:.2} {currency}"),
246    };
247    let avail = if s.is_available {
248        "available"
249    } else {
250        "unavailable"
251    };
252    let mut v = vec![Section::Title {
253        left: "DeepSeek".into(),
254        right: None,
255    }];
256    v.push(Section::Spacer);
257    v.push(Section::Text {
258        label: "Balance".into(),
259        value: fmt(s.balance),
260    });
261    v.push(Section::Block {
262        label: "Breakdown".into(),
263        body: vec![format!(
264            "granted {} · topped-up {}",
265            fmt(s.granted),
266            fmt(s.topped_up)
267        )],
268    });
269    v.push(Section::Spacer);
270    v.push(Section::Block {
271        label: "API".into(),
272        body: vec![avail.into()],
273    });
274    v
275}
276
277fn push_window(
278    sections: &mut Vec<Section>,
279    label: &str,
280    w: &crate::usage::UsageWindow,
281    now: DateTime<Utc>,
282    tol: u32,
283    show_pacing: bool,
284) {
285    let pct = w.utilization_pct.clamp(0, 100) as u16;
286    let reset_text = countdown::format(w.resets_at, now);
287    let footnote = if show_pacing {
288        let p = pacing::calc(w.utilization_pct, w.resets_at, now, w.window_duration, tol);
289        format!(
290            "Resets in {} · {}% elapsed · {}",
291            reset_text, p.elapsed_pct, p.point_label
292        )
293    } else {
294        format!("Resets in {}", reset_text)
295    };
296    sections.push(Section::Spacer);
297    sections.push(Section::Metric {
298        label: label.into(),
299        pct,
300        severity: severity_for(pct as i32),
301        value_label: format!("{pct}%"),
302        footnote,
303    });
304}
305
306/// Render the given sections into `area`. Lays them out vertically; metric
307/// rows take 2 lines (label+gauge / footnote), text and spacer rows take 1.
308///
309/// The trailing "Updated …" footer is detected (the last `Text` section)
310/// and pinned to the bottom of the area, with the slack absorbed *between*
311/// content and footer. This way shorter vendor panels (OpenRouter, Z.AI)
312/// don't leave a giant gap below the footer.
313pub fn render(f: &mut Frame, area: Rect, theme: &Theme, sections: &[Section]) {
314    if sections.is_empty() {
315        return;
316    }
317    let bubble = bubble_theme(theme);
318    // Heuristic: if the last section is a Text starting with "  Updated",
319    // pin it to the bottom. Otherwise just lay everything out top-down.
320    let pin_last =
321        matches!(sections.last(), Some(Section::Text { value, .. }) if value.contains("Updated"));
322
323    let body_end = if pin_last {
324        sections.len() - 1
325    } else {
326        sections.len()
327    };
328    let mut constraints: Vec<Constraint> =
329        sections[..body_end].iter().map(section_height).collect();
330
331    if pin_last {
332        constraints.push(Constraint::Min(0)); // slack between body and footer
333        constraints.push(section_height(sections.last().unwrap()));
334    } else {
335        constraints.push(Constraint::Min(0));
336    }
337
338    let chunks = Layout::default()
339        .direction(ratatui::layout::Direction::Vertical)
340        .constraints(constraints)
341        .split(area);
342
343    for (i, s) in sections[..body_end].iter().enumerate() {
344        render_section(f, chunks[i], theme, &bubble, s);
345    }
346    if pin_last {
347        render_section(
348            f,
349            chunks[chunks.len() - 1],
350            theme,
351            &bubble,
352            sections.last().unwrap(),
353        );
354    }
355}
356
357fn section_height(s: &Section) -> Constraint {
358    match s {
359        Section::Title { .. } => Constraint::Length(2),
360        Section::Metric { .. } => Constraint::Length(3),
361        Section::Text { .. } => Constraint::Length(1),
362        Section::Block { body, .. } => Constraint::Length(1 + body.len() as u16),
363        Section::Spacer => Constraint::Length(1),
364    }
365}
366
367fn render_section(f: &mut Frame, area: Rect, theme: &Theme, bubble: &BubbleTheme, s: &Section) {
368    match s {
369        Section::Title { left, right } => {
370            // Left: bold accent-colored plan/vendor label. Right: dim-styled
371            // "Updated HH:MM:SS" pinned to the right edge of the title row.
372            let left_line = Line::from(Span::styled(
373                format!("  {} {left}", bubble.symbols.selected),
374                bubble.title,
375            ));
376            f.render_widget(Paragraph::new(left_line), area);
377            if let Some(rt) = right {
378                let right_line =
379                    Line::from(Span::styled(format!("{rt}  "), bubble.muted)).right_aligned();
380                f.render_widget(Paragraph::new(right_line), area);
381            }
382        }
383        Section::Metric {
384            label,
385            pct,
386            severity,
387            value_label,
388            footnote,
389        } => render_metric(
390            f,
391            area,
392            theme,
393            bubble,
394            label,
395            *pct,
396            *severity,
397            value_label,
398            footnote,
399        ),
400        Section::Text { label, value } => {
401            if label.is_empty() && value.contains("Loading") {
402                render_loading(f, area, bubble);
403                return;
404            }
405            if label == "Error" {
406                let line = Line::from(vec![
407                    bubble.error(format!("  {} ", bubble.symbols.cross)),
408                    Span::styled(value.clone(), bubble.error.add_modifier(Modifier::BOLD)),
409                ]);
410                f.render_widget(Paragraph::new(line), area);
411                return;
412            }
413            let mut spans = Vec::new();
414            if !label.is_empty() {
415                spans.push(Span::styled(
416                    format!("  {label}  "),
417                    bubble.text.add_modifier(Modifier::BOLD),
418                ));
419            }
420            spans.push(Span::styled(value.clone(), bubble.muted));
421            f.render_widget(Paragraph::new(Line::from(spans)), area);
422        }
423        Section::Block { label, body } => render_block(f, area, bubble, label, body),
424        Section::Spacer => {}
425    }
426}
427
428fn render_loading(f: &mut Frame, area: Rect, bubble: &BubbleTheme) {
429    let frames = SpinnerFrames::DOTS;
430    let frame_count = frames.frames().len().max(1);
431    let frame = chrono::Utc::now().timestamp_millis().unsigned_abs() as usize / 120;
432    let mut spinner = Spinner::new()
433        .frames(frames)
434        .label("Fetching usage data")
435        .theme(*bubble);
436    for _ in 0..(frame % frame_count) {
437        spinner.tick();
438    }
439    f.render_widget(&spinner, area);
440}
441
442#[allow(clippy::too_many_arguments)]
443fn render_metric(
444    f: &mut Frame,
445    area: Rect,
446    theme: &Theme,
447    bubble: &BubbleTheme,
448    label: &str,
449    pct: u16,
450    severity: PaceSeverity,
451    value_label: &str,
452    footnote: &str,
453) {
454    let bar_color = severity_color(theme, bubble, severity);
455    let bar_empty = color(&theme.bar_empty).unwrap_or(bubble.palette.selected_background);
456
457    let inner = Layout::default()
458        .direction(ratatui::layout::Direction::Vertical)
459        .constraints([
460            Constraint::Length(1),
461            Constraint::Length(1),
462            Constraint::Length(1),
463        ])
464        .split(area);
465
466    // Row 1: label
467    let label_line = Line::from(Span::styled(
468        format!("  {label}"),
469        bubble.text.add_modifier(Modifier::BOLD),
470    ));
471    f.render_widget(Paragraph::new(label_line), inner[0]);
472
473    // Row 2: gauge spanning most of the width + value annotation on the right
474    let row = inner[1];
475    let value_w = value_label.chars().count() as u16 + 2;
476    let gauge_area = Rect {
477        x: row.x + 2,
478        y: row.y,
479        width: row.width.saturating_sub(value_w + 4),
480        height: 1,
481    };
482    let value_area = Rect {
483        x: gauge_area.x + gauge_area.width + 1,
484        y: row.y,
485        width: value_w,
486        height: 1,
487    };
488    let progress_theme = progress_theme(*bubble, bar_color, bar_empty);
489    let progress = Progress::from_percent(pct)
490        .theme(progress_theme)
491        .show_percentage(false);
492    f.render_widget(&progress, gauge_area);
493    let value = Paragraph::new(Line::from(Span::styled(
494        value_label.to_string(),
495        Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
496    )));
497    f.render_widget(value, value_area);
498
499    // Row 3: footnote (dim)
500    let foot = Line::from(Span::styled(format!("    {footnote}"), bubble.muted));
501    f.render_widget(Paragraph::new(foot), inner[2]);
502}
503
504fn render_block(f: &mut Frame, area: Rect, bubble: &BubbleTheme, label: &str, body: &[String]) {
505    let mut lines = vec![Line::from(Span::styled(
506        format!("  {label}"),
507        bubble.text.add_modifier(Modifier::BOLD),
508    ))];
509    for b in body {
510        lines.push(Line::from(Span::styled(format!("    {b}"), bubble.muted)));
511    }
512    f.render_widget(Paragraph::new(lines), area);
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::usage::{
519        AnthropicSnapshot, Cents, ExtraUsage, OpenAiCredits, OpenAiSnapshot, OpenAiSource,
520        OpenRouterSnapshot, UsageWindow, ZaiSnapshot,
521    };
522    use chrono::TimeZone;
523
524    fn now() -> DateTime<Utc> {
525        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
526    }
527
528    fn ready(snapshot: VendorSnapshot) -> TabState {
529        TabState::Ready(Box::new(crate::tui::app::ReadyTab {
530            snapshot,
531            stale: false,
532            last_error: None,
533            fetched_at: Some(now() - chrono::Duration::seconds(15)),
534        }))
535    }
536
537    #[test]
538    fn anthropic_sections_include_all_three_windows_when_present() {
539        let snap = AnthropicSnapshot {
540            plan: "Max 20x".into(),
541            session: UsageWindow {
542                utilization_pct: 60,
543                resets_at: Some(now() + chrono::Duration::hours(1)),
544                window_duration: chrono::Duration::hours(5),
545            },
546            weekly: UsageWindow {
547                utilization_pct: 30,
548                resets_at: Some(now() + chrono::Duration::days(3)),
549                window_duration: chrono::Duration::days(7),
550            },
551            sonnet: Some(UsageWindow {
552                utilization_pct: 5,
553                resets_at: Some(now() + chrono::Duration::hours(2)),
554                window_duration: chrono::Duration::days(7),
555            }),
556            extra: Some(ExtraUsage {
557                limit: Cents(5000),
558                spent: Cents(250),
559            }),
560        };
561        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
562        // Title (carries "Updated …" inline now) + 4 metrics (3 windows +
563        // extra) each preceded by a Spacer. 1 + 4*2 = 9 sections.
564        assert_eq!(sections.len(), 9);
565        assert!(matches!(sections[0], Section::Title { .. }));
566        // Title's right-aligned slot should carry the timestamp.
567        if let Section::Title { right, .. } = &sections[0] {
568            assert!(right.as_deref().is_some_and(|r| r.starts_with("Updated ")));
569        } else {
570            panic!("expected first section to be Title");
571        }
572        let metric_count = sections
573            .iter()
574            .filter(|s| matches!(s, Section::Metric { .. }))
575            .count();
576        assert_eq!(metric_count, 4);
577    }
578
579    #[test]
580    fn anthropic_omits_sonnet_and_extra_when_absent() {
581        let snap = AnthropicSnapshot {
582            plan: "Pro".into(),
583            session: UsageWindow {
584                utilization_pct: 10,
585                resets_at: None,
586                window_duration: chrono::Duration::hours(5),
587            },
588            weekly: UsageWindow {
589                utilization_pct: 5,
590                resets_at: None,
591                window_duration: chrono::Duration::days(7),
592            },
593            sonnet: None,
594            extra: None,
595        };
596        let sections = sections_for(&ready(VendorSnapshot::Anthropic(snap)), now(), 5);
597        let metric_count = sections
598            .iter()
599            .filter(|s| matches!(s, Section::Metric { .. }))
600            .count();
601        assert_eq!(metric_count, 2);
602    }
603
604    #[test]
605    fn openrouter_always_has_balance_metric_and_period_block() {
606        let snap = OpenRouterSnapshot {
607            label: "OR".into(),
608            total_credits: 100.0,
609            total_usage: 25.0,
610            usage_daily: 1.0,
611            usage_weekly: 5.0,
612            usage_monthly: 25.0,
613            is_free_tier: false,
614            limit: None,
615            limit_remaining: None,
616        };
617        let sections = sections_for(&ready(VendorSnapshot::Openrouter(snap)), now(), 5);
618        assert!(matches!(sections[0], Section::Title { .. }));
619        assert!(
620            sections
621                .iter()
622                .any(|s| matches!(s, Section::Metric { label, .. } if label == "Credit balance"))
623        );
624        assert!(
625            sections
626                .iter()
627                .any(|s| matches!(s, Section::Block { label, .. } if label == "Usage by period"))
628        );
629    }
630
631    #[test]
632    fn zai_no_windows_renders_message() {
633        let snap = ZaiSnapshot {
634            plan: "GLM".into(),
635            session: None,
636            weekly: None,
637            mcp: None,
638        };
639        let sections = sections_for(&ready(VendorSnapshot::Zai(snap)), now(), 5);
640        assert!(sections.iter().any(|s| matches!(
641            s,
642            Section::Text { value, .. } if value.contains("no usage windows reported")
643        )));
644    }
645
646    #[test]
647    fn loading_state_yields_loading_section() {
648        let sections = sections_for(&TabState::Loading, now(), 5);
649        assert!(sections.iter().any(|s| matches!(
650            s,
651            Section::Text { value, .. } if value.contains("Loading")
652        )));
653    }
654
655    #[test]
656    fn error_state_includes_retry_hint() {
657        let sections = sections_for(&TabState::Error("token expired".into()), now(), 5);
658        assert!(sections.iter().any(|s| matches!(
659            s,
660            Section::Text { value, .. } if value.contains("token expired")
661        )));
662        assert!(sections.iter().any(|s| matches!(
663            s,
664            Section::Text { value, .. } if value.contains("`r` to retry")
665        )));
666    }
667
668    #[test]
669    fn openai_with_credits_renders_block() {
670        let snap = OpenAiSnapshot {
671            plan: "ChatGPT Plus".into(),
672            session: UsageWindow {
673                utilization_pct: 1,
674                resets_at: None,
675                window_duration: chrono::Duration::hours(5),
676            },
677            weekly: UsageWindow {
678                utilization_pct: 0,
679                resets_at: None,
680                window_duration: chrono::Duration::days(7),
681            },
682            code_review: None,
683            credits: Some(OpenAiCredits {
684                balance: "$5.00".into(),
685                has_credits: true,
686                unlimited: false,
687                approx_local_messages: Some((100, 200)),
688                approx_cloud_messages: Some((30, 50)),
689            }),
690            source: OpenAiSource::CodexOauth,
691        };
692        let sections = sections_for(&ready(VendorSnapshot::Openai(snap)), now(), 5);
693        assert!(
694            sections
695                .iter()
696                .any(|s| matches!(s, Section::Block { label, .. } if label == "Credits"))
697        );
698    }
699}