Skip to main content

ai_usagebar/minimax/
vendor.rs

1//! MiniMax renderer — bar text + bordered Pango tooltip.
2//!
3//! MiniMax reports quota per model bucket, so its rows are labeled by pool the
4//! same way Antigravity labels its Gemini / third-party groups. The text pool
5//! is what the bar shows; the video pool, when the plan has one, appears in the
6//! tooltip rather than competing for space on the bar.
7
8use std::collections::HashMap;
9
10use chrono::{DateTime, Utc};
11
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing::PaceSeverity;
15use crate::pango::{color_span, escape, severity_color, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{Line as TooltipLine, render_bordered};
18use crate::usage::{MinimaxSnapshot, UsageWindow};
19use crate::vendor::{RenderOpts, VendorOutcome};
20use crate::waybar::{Class, WaybarOutput};
21
22use super::fetch::FetchOutcome;
23
24/// The text & coding pool (`general` on the wire) — what the bars represent.
25pub const POOL_GENERAL: &str = "Text";
26/// The video-generation pool (`video` on the wire), shown when the plan has it.
27pub const POOL_VIDEO: &str = "Video";
28
29pub const DEFAULT_FORMAT: &str = "{minimax_session_pct}% · {minimax_session_reset}";
30
31pub fn build_placeholders(
32    snap: &MinimaxSnapshot,
33    now: DateTime<Utc>,
34) -> HashMap<&'static str, String> {
35    let session_pct = snap.session.utilization_pct;
36    let weekly_pct = snap.weekly.utilization_pct;
37    let session_reset = countdown::format(snap.session.resets_at, now);
38    let weekly_reset = countdown::format(snap.weekly.resets_at, now);
39    // The video pool is optional; its placeholders resolve to an em dash rather
40    // than vanishing, so a user format referencing them never leaves a gap.
41    let video = |w: &Option<UsageWindow>, pct: bool| -> String {
42        match w {
43            Some(w) if pct => w.utilization_pct.to_string(),
44            Some(w) => countdown::format(w.resets_at, now),
45            None => "—".to_string(),
46        }
47    };
48
49    placeholders(vec![
50        ("icon", "󰚩".to_string()),
51        ("vendor_short", "mmx".to_string()),
52        // Cross-vendor aliases — what the desktop surfaces read.
53        ("plan", snap.plan.clone()),
54        ("session_pct", session_pct.to_string()),
55        ("session_reset", session_reset.clone()),
56        ("weekly_pct", weekly_pct.to_string()),
57        ("weekly_reset", weekly_reset.clone()),
58        // MiniMax-specific placeholders.
59        ("minimax_plan", snap.plan.clone()),
60        ("minimax_session_pct", session_pct.to_string()),
61        ("minimax_session_reset", session_reset),
62        ("minimax_weekly_pct", weekly_pct.to_string()),
63        ("minimax_weekly_reset", weekly_reset),
64        ("minimax_video_pct", video(&snap.video_session, true)),
65        ("minimax_video_reset", video(&snap.video_session, false)),
66        ("minimax_video_weekly_pct", video(&snap.video_weekly, true)),
67    ])
68}
69
70/// Worst of the two text-pool windows. The video pool deliberately does not
71/// drive the bar color: running out of video quota should not paint the coding
72/// bar red.
73pub fn severity(snap: &MinimaxSnapshot) -> PaceSeverity {
74    severity_for(
75        snap.session
76            .utilization_pct
77            .max(snap.weekly.utilization_pct),
78    )
79}
80
81pub fn render(
82    outcome: &VendorOutcome,
83    snap: &MinimaxSnapshot,
84    theme: &Theme,
85    opts: &RenderOpts,
86    now: DateTime<Utc>,
87) -> WaybarOutput {
88    let class = Class::from(severity(snap));
89    let format = opts
90        .format
91        .clone()
92        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
93    let values = build_placeholders(snap, now);
94    // User formats are Pango markup after Waybar renders them. Escape API
95    // strings there, while retaining raw values for the default tooltip (which
96    // escapes exactly once at its markup insertion point).
97    let mut pango_values = values.clone();
98    for key in ["plan", "minimax_plan"] {
99        if let Some(value) = pango_values.get_mut(key) {
100            *value = escape(value);
101        }
102    }
103
104    let mut text = substitute(&format, &pango_values);
105    if outcome.stale {
106        text.push_str(" ⏸");
107    }
108
109    let wrapper_color = severity_color(severity(snap), theme).to_string();
110    let icon_prefix = match opts.icon.as_deref() {
111        Some(ic) if !ic.is_empty() => format!("{ic} "),
112        _ => String::new(),
113    };
114    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
115
116    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
117        substitute(fmt, &pango_values)
118    } else {
119        render_tooltip(outcome, snap, theme, now)
120    };
121
122    WaybarOutput {
123        text: bar_text,
124        tooltip,
125        class,
126    }
127}
128
129fn render_tooltip(
130    outcome: &VendorOutcome,
131    snap: &MinimaxSnapshot,
132    theme: &Theme,
133    now: DateTime<Utc>,
134) -> String {
135    let blue = &theme.blue;
136    let dim = &theme.dim;
137    let fg = &theme.fg;
138
139    let mut lines: Vec<TooltipLine> = Vec::new();
140    lines.push(TooltipLine::Center(format!(
141        "<span font_weight='bold' foreground='{blue}'>{}</span>",
142        escape(&snap.plan)
143    )));
144    lines.push(TooltipLine::Sep);
145
146    let mut pool = |label: &str, icon: &str, session: &UsageWindow, weekly: &UsageWindow| {
147        lines.push(TooltipLine::Body("".into()));
148        lines.push(TooltipLine::Body(format!(
149            " <span foreground='{fg}'>  {icon}  {label}</span>"
150        )));
151        for (what, w) in [("Session", session), ("Weekly", weekly)] {
152            let color = severity_color(severity_for(w.utilization_pct), theme);
153            lines.push(TooltipLine::Body(format!(
154                "   <span foreground='{dim}'>{what}</span>  \
155                 <span font_weight='bold' foreground='{color}'>{pct}%</span>",
156                pct = w.utilization_pct
157            )));
158            lines.push(TooltipLine::Body(format!(
159                " <span foreground='{dim}'>     reset {}</span>",
160                escape(&countdown::format(w.resets_at, now))
161            )));
162        }
163    };
164
165    pool(POOL_GENERAL, "󰅄", &snap.session, &snap.weekly);
166    if let (Some(vs), Some(vw)) = (&snap.video_session, &snap.video_weekly) {
167        pool(POOL_VIDEO, "󰕧", vs, vw);
168    }
169
170    if let Some((code, msg)) = outcome.last_error.as_ref() {
171        let (label, icon, ecolor) = if *code == 0 {
172            ("MiniMax error".to_string(), "󰅚", theme.red.as_str())
173        } else if *code >= 500 {
174            (format!("HTTP {code}"), "󰅚", theme.red.as_str())
175        } else {
176            (format!("HTTP {code}"), "󰀪", theme.orange.as_str())
177        };
178        lines.push(TooltipLine::Body("".into()));
179        lines.push(TooltipLine::Sep);
180        lines.push(TooltipLine::Body(format!(
181            " <span foreground='{ecolor}'>  {icon}  {label}</span>"
182        )));
183        if msg != &label {
184            lines.push(TooltipLine::Body(format!(
185                "     <span foreground='{dim}'>{}</span>",
186                escape(msg)
187            )));
188        }
189    }
190
191    let updated = updated_at_hm(now, outcome.cache_age);
192    lines.push(TooltipLine::Body("".into()));
193    lines.push(TooltipLine::Sep);
194    lines.push(TooltipLine::Body(format!(
195        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
196    )));
197
198    render_bordered(&lines, theme)
199}
200
201impl From<FetchOutcome> for VendorOutcome {
202    fn from(o: FetchOutcome) -> Self {
203        Self {
204            snapshot: crate::usage::VendorSnapshot::Minimax(o.snapshot),
205            stale: o.stale,
206            last_error: o.last_error,
207            cache_age: o.cache_age,
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use chrono::TimeZone;
216
217    fn now() -> DateTime<Utc> {
218        Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap()
219    }
220
221    fn window(pct: i32, mins_ahead: i64, dur: chrono::Duration) -> UsageWindow {
222        UsageWindow {
223            utilization_pct: pct,
224            resets_at: Some(now() + chrono::Duration::minutes(mins_ahead)),
225            window_duration: dur,
226        }
227    }
228
229    fn snap() -> MinimaxSnapshot {
230        MinimaxSnapshot {
231            plan: "MiniMax Token Plan".to_string(),
232            session: window(31, 45, chrono::Duration::hours(5)),
233            weekly: window(62, 3000, chrono::Duration::days(7)),
234            video_session: Some(window(5, 200, chrono::Duration::hours(24))),
235            video_weekly: Some(window(9, 3000, chrono::Duration::days(7))),
236        }
237    }
238
239    fn opts() -> RenderOpts {
240        RenderOpts {
241            format: None,
242            tooltip_format: None,
243            icon: None,
244            pace_tolerance: 5,
245            format_pace_color: false,
246            tooltip_pace_pts: false,
247        }
248    }
249
250    fn outcome(s: &MinimaxSnapshot) -> VendorOutcome {
251        VendorOutcome {
252            snapshot: crate::usage::VendorSnapshot::Minimax(s.clone()),
253            stale: false,
254            last_error: None,
255            cache_age: Some(std::time::Duration::ZERO),
256        }
257    }
258
259    #[test]
260    fn default_format_shows_session_percent_and_reset() {
261        let s = snap();
262        let out = render(&outcome(&s), &s, &Theme::default(), &opts(), now());
263        assert!(out.text.contains("31%"), "bar text was {:?}", out.text);
264    }
265
266    /// Cross-vendor aliases are what the GNOME/macOS surfaces read; without
267    /// them MiniMax would render blank rows on the desktop.
268    #[test]
269    fn exposes_cross_vendor_aliases() {
270        let v = build_placeholders(&snap(), now());
271        assert_eq!(v.get("session_pct").map(String::as_str), Some("31"));
272        assert_eq!(v.get("weekly_pct").map(String::as_str), Some("62"));
273        assert_eq!(v.get("vendor_short").map(String::as_str), Some("mmx"));
274        assert!(v.contains_key("session_reset"));
275    }
276
277    /// The video pool must not drag the coding bar into red.
278    #[test]
279    fn severity_ignores_the_video_pool() {
280        let mut s = snap();
281        s.session.utilization_pct = 10;
282        s.weekly.utilization_pct = 10;
283        s.video_session = Some(window(99, 10, chrono::Duration::hours(24)));
284        s.video_weekly = Some(window(99, 10, chrono::Duration::days(7)));
285        assert_eq!(severity(&s), severity_for(10));
286    }
287
288    #[test]
289    fn video_placeholders_degrade_to_a_dash_without_the_pool() {
290        let mut s = snap();
291        s.video_session = None;
292        s.video_weekly = None;
293        let v = build_placeholders(&s, now());
294        assert_eq!(v.get("minimax_video_pct").map(String::as_str), Some("—"));
295        assert_eq!(
296            v.get("minimax_video_weekly_pct").map(String::as_str),
297            Some("—")
298        );
299    }
300
301    #[test]
302    fn tooltip_lists_both_pools_when_present() {
303        let s = snap();
304        let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), now());
305        assert!(tip.contains(POOL_GENERAL));
306        assert!(tip.contains(POOL_VIDEO));
307    }
308
309    #[test]
310    fn tooltip_omits_the_video_pool_when_absent() {
311        let mut s = snap();
312        s.video_session = None;
313        s.video_weekly = None;
314        let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), now());
315        assert!(tip.contains(POOL_GENERAL));
316        assert!(!tip.contains(POOL_VIDEO));
317    }
318
319    /// Plan text reaches Pango exactly once escaped, from both paths.
320    #[test]
321    fn escapes_the_plan_name_exactly_once() {
322        let mut s = snap();
323        s.plan = "Plan <b>&</b>".to_string();
324        let out = render(
325            &outcome(&s),
326            &s,
327            &Theme::default(),
328            &RenderOpts {
329                format: Some("{minimax_plan}".to_string()),
330                ..opts()
331            },
332            now(),
333        );
334        assert!(
335            out.text.contains("&lt;b&gt;&amp;&lt;/b&gt;"),
336            "{:?}",
337            out.text
338        );
339        assert!(
340            !out.text.contains("&amp;lt;"),
341            "double-escaped: {:?}",
342            out.text
343        );
344    }
345
346    #[test]
347    fn stale_marks_the_bar() {
348        let s = snap();
349        let mut o = outcome(&s);
350        o.stale = true;
351        let out = render(&o, &s, &Theme::default(), &opts(), now());
352        assert!(out.text.contains('⏸'));
353    }
354}