Skip to main content

ai_usagebar/supergrok/
vendor.rs

1//! SuperGrok renderer — current subscription credit % + reset countdown.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{
9    placeholders, reset_credit_lines, reset_credits, substitute, updated_at_hm, usd,
10};
11use crate::pacing::PaceSeverity;
12use crate::pango::{self, color_span, escape, severity_color, severity_for};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::SuperGrokSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{sgk_pct}% · {sgk_reset}";
22
23const DEFAULT_ICON: &str = "󰚩";
24
25pub fn build_placeholders(
26    snap: &SuperGrokSnapshot,
27    now: DateTime<Utc>,
28) -> HashMap<&'static str, String> {
29    let pct = snap.weekly_pct.to_string();
30    let reset = countdown::format(snap.reset_at, now);
31    let prepaid = snap.prepaid_balance.map(usd).unwrap_or_else(|| "—".into());
32
33    placeholders(vec![
34        ("icon", DEFAULT_ICON.to_string()),
35        ("vendor_short", VendorId::Supergrok.short_name().to_string()),
36        // Cross-vendor compatibility aliases for the single current pool.
37        ("plan", snap.plan.clone()),
38        ("session_pct", pct.clone()),
39        ("session_reset", reset.clone()),
40        ("weekly_pct", pct.clone()),
41        ("weekly_reset", reset.clone()),
42        // SuperGrok-specific.
43        ("sgk_plan", snap.plan.clone()),
44        ("sgk_pct", pct),
45        ("sgk_reset", reset),
46        ("sgk_period", snap.period.label().to_string()),
47        ("sgk_prepaid", prepaid),
48        (
49            "sgk_resets_available",
50            snap.reset_credits.available.to_string(),
51        ),
52        ("sgk_resets", reset_credits(&snap.reset_credits)),
53    ])
54}
55
56pub fn severity(snap: &SuperGrokSnapshot) -> PaceSeverity {
57    severity_for(snap.weekly_pct)
58}
59
60pub fn render(
61    outcome: &VendorOutcome,
62    snap: &SuperGrokSnapshot,
63    theme: &Theme,
64    opts: &RenderOpts,
65    now: DateTime<Utc>,
66) -> WaybarOutput {
67    let class = Class::from(severity(snap));
68    let format = opts
69        .format
70        .clone()
71        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
72    let mut values = build_placeholders(snap, now);
73    for key in ["plan", "sgk_plan"] {
74        if let Some(value) = values.get_mut(key) {
75            *value = escape(value);
76        }
77    }
78
79    let mut text = substitute(&format, &values);
80    if outcome.stale {
81        text.push_str(" ⏸");
82    }
83
84    let wrapper_color = severity_color(severity(snap), theme).to_string();
85    let icon_prefix = match opts.icon.as_deref() {
86        Some(ic) if !ic.is_empty() => format!("{ic} "),
87        _ => String::new(),
88    };
89    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
90
91    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
92        substitute(fmt, &values)
93    } else {
94        render_tooltip(outcome, snap, theme, now)
95    };
96
97    WaybarOutput {
98        text: bar_text,
99        tooltip,
100        class,
101    }
102}
103
104/// One usage-percent row in the same shape as `tooltip::push_window`:
105/// label, progress bar + bold %, then optional dim reset line.
106fn push_pct_row(
107    lines: &mut Vec<TooltipLine>,
108    theme: &Theme,
109    label: &str,
110    pct: i32,
111    reset_at: Option<DateTime<Utc>>,
112    now: DateTime<Utc>,
113) {
114    let fg = &theme.fg;
115    let dim = &theme.dim;
116    let color = severity_color(severity_for(pct), theme);
117    let bar = pango::progress_bar(pct, color, theme, None);
118    lines.push(TooltipLine::Body(format!(
119        " <span foreground='{fg}'>{label}</span>"
120    )));
121    lines.push(TooltipLine::Body(format!(
122        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>"
123    )));
124    if reset_at.is_some() {
125        lines.push(TooltipLine::Body(format!(
126            " <span foreground='{dim}'>  ⏱  Resets in {}</span>",
127            escape(&countdown::format(reset_at, now))
128        )));
129    }
130}
131
132/// A product slice is information about the pool, not a meter of it: one dim
133/// line — icon, label padded to the widest sibling, right-aligned bold % —
134/// with no gauge and no severity colour, so only the overall meter above can
135/// read as the binding constraint. Percentages never exceed three digits, so
136/// `{:>4}%` aligns "100%" with "3%". Labels are untrusted billing text: pad
137/// by rendered width first, then escape at this sink.
138fn push_product_row(
139    lines: &mut Vec<TooltipLine>,
140    theme: &Theme,
141    product: &crate::usage::SuperGrokProduct,
142    label_width: usize,
143) {
144    let dim = &theme.dim;
145    let label = crate::display::pad_end(&product.label, label_width);
146    let pct = format!("{:>4}%", product.percent);
147    lines.push(TooltipLine::Body(format!(
148        " <span foreground='{dim}'>  󰚩  {}  </span><span foreground='{dim}' font_weight='bold'>{pct}</span>",
149        escape(&label)
150    )));
151}
152
153fn render_tooltip(
154    outcome: &VendorOutcome,
155    snap: &SuperGrokSnapshot,
156    theme: &Theme,
157    now: DateTime<Utc>,
158) -> String {
159    let blue = &theme.blue;
160    let dim = &theme.dim;
161    let fg = &theme.fg;
162
163    let mut lines: Vec<TooltipLine> = Vec::new();
164    lines.push(TooltipLine::Center(format!(
165        "<span font_weight='bold' foreground='{blue}'>{}</span>",
166        escape(&snap.plan)
167    )));
168    lines.push(TooltipLine::Sep);
169    lines.push(TooltipLine::Body("".into()));
170
171    let period_label = format!("  󰔟  {} usage", snap.period.label());
172    push_pct_row(
173        &mut lines,
174        theme,
175        &period_label,
176        snap.weekly_pct,
177        snap.reset_at,
178        now,
179    );
180    if !snap.products.is_empty() {
181        let label_width = snap
182            .products
183            .iter()
184            .map(|product| crate::display::text_width(&product.label))
185            .max()
186            .unwrap_or(0);
187        for product in &snap.products {
188            push_product_row(&mut lines, theme, product, label_width);
189        }
190    }
191
192    // Same rule as the panel: a $0.00 prepaid line is noise, not information
193    // (unified billing accounts keep their dollars in the Management API
194    // wallet, which the `[grok]` vendor reports).
195    if let Some(bal) = snap.prepaid_balance.filter(|bal| *bal > 0.0) {
196        let bal_s = usd(bal);
197        lines.push(TooltipLine::Body("".into()));
198        lines.push(TooltipLine::Body(format!(
199            " <span foreground='{dim}'>  󰢗  Prepaid API  {}</span>",
200            escape(&bal_s)
201        )));
202    }
203
204    if snap.reset_credits.available > 0 {
205        lines.push(TooltipLine::Body("".into()));
206        lines.push(TooltipLine::Body(format!(
207            " <span foreground='{fg}'>  󰁯  Reset credits</span>"
208        )));
209        for line in reset_credit_lines(&snap.reset_credits, now) {
210            lines.push(TooltipLine::Body(format!(
211                " <span foreground='{dim}'>     {}</span>",
212                escape(&line)
213            )));
214        }
215    }
216
217    if let Some((code, msg)) = outcome.last_error.as_ref() {
218        let (icon, ecolor) = if *code >= 500 {
219            ("󰅚", theme.red.as_str())
220        } else {
221            ("󰀪", theme.orange.as_str())
222        };
223        let label = if *code == 0 {
224            "Refresh error".to_string()
225        } else {
226            format!("HTTP {code}")
227        };
228        lines.push(TooltipLine::Body("".into()));
229        lines.push(TooltipLine::Sep);
230        lines.push(TooltipLine::Body(format!(
231            " <span foreground='{ecolor}'>  {icon}  {label}</span>"
232        )));
233        lines.push(TooltipLine::Body(format!(
234            "     <span foreground='{dim}'>{}</span>",
235            escape(msg)
236        )));
237    }
238
239    let updated = updated_at_hm(now, outcome.cache_age);
240    lines.push(TooltipLine::Body("".into()));
241    lines.push(TooltipLine::Sep);
242    lines.push(TooltipLine::Body(format!(
243        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
244    )));
245
246    render_bordered(&lines, theme)
247}
248
249impl From<FetchOutcome> for VendorOutcome {
250    fn from(o: FetchOutcome) -> Self {
251        o.map(crate::usage::VendorSnapshot::SuperGrok)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::usage::SuperGrokPeriod;
259    use chrono::TimeZone;
260
261    fn now() -> DateTime<Utc> {
262        Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()
263    }
264
265    fn sample_snap() -> SuperGrokSnapshot {
266        SuperGrokSnapshot {
267            plan: "SuperGrok".into(),
268            account: "user-1".into(),
269            weekly_pct: 34,
270            period: SuperGrokPeriod::Weekly,
271            reset_at: Some(now() + chrono::Duration::hours(20)),
272            prepaid_balance: Some(0.0),
273            reset_credits: Default::default(),
274            products: Vec::new(),
275        }
276    }
277
278    fn sample_outcome(snap: SuperGrokSnapshot) -> VendorOutcome {
279        VendorOutcome {
280            snapshot: crate::usage::VendorSnapshot::SuperGrok(snap),
281            stale: false,
282            last_error: None,
283            cache_age: Some(std::time::Duration::from_secs(10)),
284        }
285    }
286
287    fn opts() -> RenderOpts {
288        RenderOpts {
289            format: None,
290            tooltip_format: None,
291            icon: None,
292            pace_tolerance: 5,
293            format_pace_color: false,
294            tooltip_pace_pts: false,
295        }
296    }
297
298    #[test]
299    fn renders_weekly_pct_and_reset() {
300        let snap = sample_snap();
301        let o = sample_outcome(snap.clone());
302        let out = render(&o, &snap, &Theme::default(), &opts(), now());
303        assert!(out.text.contains("34%"));
304        assert!(out.tooltip.contains("usage"));
305        assert!(!out.tooltip.contains("Build credits"));
306        assert!(out.tooltip.contains("Weekly"));
307        assert!(out.tooltip.contains("SuperGrok"));
308        // Usage-% vendors (Anthropic / OpenAI) draw a filled progress bar in
309        // the tooltip; SuperGrok must match that shape rather than bare %.
310        assert!(
311            out.tooltip.contains('█') || out.tooltip.contains('░'),
312            "tooltip missing progress bar cells: {}",
313            out.tooltip
314        );
315        assert!(out.tooltip.contains("Resets in"));
316    }
317
318    #[test]
319    fn tooltip_lists_product_slices() {
320        let mut snap = sample_snap();
321        snap.products = vec![
322            crate::usage::SuperGrokProduct {
323                label: "Grok Build".into(),
324                percent: 20,
325            },
326            crate::usage::SuperGrokProduct {
327                label: "Grok Chat".into(),
328                percent: 14,
329            },
330        ];
331        let o = sample_outcome(snap.clone());
332        let out = render(&o, &snap, &Theme::default(), &opts(), now());
333        assert!(out.tooltip.contains("Grok Build"));
334        assert!(out.tooltip.contains("Grok Chat"));
335        // Each slice is one dim line: no gauge, no reset of its own, and the
336        // percentages right-align in one column ("  20%" / "  14%") under the
337        // overall meter's bar — the only bar line in the box.
338        let product_lines: Vec<&str> = out
339            .tooltip
340            .lines()
341            .filter(|line| line.contains("Grok Build") || line.contains("Grok Chat"))
342            .collect();
343        assert_eq!(product_lines.len(), 2, "{}", out.tooltip);
344        for line in &product_lines {
345            assert!(!line.contains('█') && !line.contains('░'), "{line}");
346            assert!(!line.contains("Resets"), "{line}");
347        }
348        assert!(product_lines[0].contains("  20%</span>"));
349        assert!(product_lines[1].contains("  14%</span>"));
350        assert_eq!(
351            out.tooltip
352                .lines()
353                .filter(|line| line.contains('█'))
354                .count(),
355            1,
356            "{}",
357            out.tooltip
358        );
359    }
360
361    /// A hostile product name must not inject Pango markup into the tooltip:
362    /// the label is escaped at the render sink, after width-based padding.
363    #[test]
364    fn tooltip_escapes_product_labels() {
365        let mut snap = sample_snap();
366        snap.products = vec![crate::usage::SuperGrokProduct {
367            label: "Grok<b>Build</b>".into(),
368            percent: 7,
369        }];
370        let o = sample_outcome(snap.clone());
371        let out = render(&o, &snap, &Theme::default(), &opts(), now());
372        assert!(out.tooltip.contains("Grok&lt;b&gt;"), "{}", out.tooltip);
373        assert!(!out.tooltip.contains("<b>Build"), "{}", out.tooltip);
374    }
375
376    /// The prepaid line appears only when there is credit to show — a $0.00
377    /// row reads as "no money" when the billing document merely reports that
378    /// nothing was purchased on top of the subscription. The `{sgk_prepaid}`
379    /// placeholder keeps reporting the raw figure either way.
380    #[test]
381    fn tooltip_lists_prepaid_only_when_there_is_credit() {
382        let mut snap = sample_snap();
383        snap.prepaid_balance = Some(0.0);
384        let zero = render(
385            &sample_outcome(snap.clone()),
386            &snap,
387            &Theme::default(),
388            &opts(),
389            now(),
390        );
391        assert!(!zero.tooltip.contains("Prepaid API"), "{}", zero.tooltip);
392        assert_eq!(
393            build_placeholders(&snap, now())
394                .get("sgk_prepaid")
395                .map(String::as_str),
396            Some("$0.00")
397        );
398
399        snap.prepaid_balance = Some(4.22);
400        let out = render(
401            &sample_outcome(snap.clone()),
402            &snap,
403            &Theme::default(),
404            &opts(),
405            now(),
406        );
407        assert!(out.tooltip.contains("Prepaid API"), "{}", out.tooltip);
408        assert!(out.tooltip.contains("$4.22"), "{}", out.tooltip);
409    }
410
411    #[test]
412    fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
413        let snap = sample_snap();
414        let quiet = render(
415            &sample_outcome(snap.clone()),
416            &snap,
417            &Theme::default(),
418            &opts(),
419            now(),
420        );
421        assert!(!quiet.tooltip.contains("available"), "{}", quiet.tooltip);
422
423        let mut snap = snap;
424        snap.reset_credits = crate::usage::ResetCredits {
425            available: 1,
426            credits: vec![crate::usage::ResetCredit {
427                title: None,
428                expires_at: Some(now() + chrono::Duration::days(7)),
429            }],
430        };
431        let out = render(
432            &sample_outcome(snap.clone()),
433            &snap,
434            &Theme::default(),
435            &opts(),
436            now(),
437        );
438        assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
439        assert!(out.tooltip.contains("Expires"), "{}", out.tooltip);
440
441        let ph = build_placeholders(&snap, now());
442        assert_eq!(
443            ph.get("sgk_resets_available").map(String::as_str),
444            Some("1")
445        );
446        assert!(ph["sgk_resets"].starts_with("1 reset available"));
447    }
448
449    #[test]
450    fn high_usage_is_critical() {
451        let mut snap = sample_snap();
452        snap.weekly_pct = 95;
453        assert_eq!(severity(&snap), PaceSeverity::Critical);
454    }
455
456    #[test]
457    fn placeholders_include_generic_aliases() {
458        let snap = sample_snap();
459        let ph = build_placeholders(&snap, now());
460        assert_eq!(ph.get("vendor_short").map(String::as_str), Some("sgk"));
461        assert_eq!(ph.get("weekly_pct").map(String::as_str), Some("34"));
462        assert_eq!(ph.get("session_pct").map(String::as_str), Some("34"));
463        assert_eq!(ph.get("sgk_period").map(String::as_str), Some("Weekly"));
464        assert_eq!(ph.get("sgk_prepaid").map(String::as_str), Some("$0.00"));
465    }
466}