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