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