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::{self, PaceSeverity};
15use crate::pango::{color_span, escape, severity_color, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
18use crate::usage::{MinimaxSnapshot, UsageWindow};
19use crate::vendor::{RenderOpts, VendorId, 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
31/// Build placeholders with the historical default pacing tolerance.
32///
33/// Keep this signature stable for library callers. Rendering uses the private
34/// tolerance-aware helper so `--pace-tolerance` still applies.
35pub fn build_placeholders(
36    snap: &MinimaxSnapshot,
37    now: DateTime<Utc>,
38) -> HashMap<&'static str, String> {
39    build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
40}
41
42fn build_placeholders_with_tolerance(
43    snap: &MinimaxSnapshot,
44    pace_tolerance: u32,
45    now: DateTime<Utc>,
46) -> HashMap<&'static str, String> {
47    let session_pct = snap.session.utilization_pct;
48    let weekly_pct = snap.weekly.utilization_pct;
49    let session_reset = countdown::format(snap.session.resets_at, now);
50    let weekly_reset = countdown::format(snap.weekly.resets_at, now);
51    // The video pool is optional; its placeholders resolve to an em dash rather
52    // than vanishing, so a user format referencing them never leaves a gap.
53    let video = |w: &Option<UsageWindow>, pct: bool| -> String {
54        match w {
55            Some(w) if pct => w.utilization_pct.to_string(),
56            Some(w) => countdown::format(w.resets_at, now),
57            None => "—".to_string(),
58        }
59    };
60    // Present windows normally carry resets, but degenerate upstream bounds
61    // are represented without one. `pacing::calc` deliberately returns
62    // neutral 0/arrow values then, matching the existing provider contract.
63    let session = window_pacing(&snap.session, pace_tolerance, now);
64    let weekly = window_pacing(&snap.weekly, pace_tolerance, now);
65    let video_pacing = optional_window_pacing(snap.video_session.as_ref(), pace_tolerance, now);
66    let video_weekly_pacing =
67        optional_window_pacing(snap.video_weekly.as_ref(), pace_tolerance, now);
68
69    placeholders(vec![
70        ("icon", "󰚩".to_string()),
71        ("vendor_short", VendorId::Minimax.short_name().to_string()),
72        // Cross-vendor aliases — what the desktop surfaces read.
73        ("plan", snap.plan.clone()),
74        ("session_pct", session_pct.to_string()),
75        ("session_reset", session_reset.clone()),
76        ("weekly_pct", weekly_pct.to_string()),
77        ("weekly_reset", weekly_reset.clone()),
78        ("session_elapsed", session.elapsed.clone()),
79        ("weekly_elapsed", weekly.elapsed.clone()),
80        // MiniMax-specific placeholders.
81        ("minimax_plan", snap.plan.clone()),
82        ("minimax_session_pct", session_pct.to_string()),
83        ("minimax_session_reset", session_reset),
84        ("minimax_weekly_pct", weekly_pct.to_string()),
85        ("minimax_weekly_reset", weekly_reset),
86        ("minimax_session_elapsed", session.elapsed),
87        ("minimax_session_pace", session.ratio_pace),
88        ("minimax_session_pace_indicator", session.point_pace),
89        ("minimax_weekly_elapsed", weekly.elapsed),
90        ("minimax_weekly_pace", weekly.ratio_pace),
91        ("minimax_weekly_pace_indicator", weekly.point_pace),
92        ("minimax_video_pct", video(&snap.video_session, true)),
93        ("minimax_video_reset", video(&snap.video_session, false)),
94        ("minimax_video_elapsed", video_pacing.elapsed),
95        ("minimax_video_pace", video_pacing.ratio_pace),
96        ("minimax_video_pace_indicator", video_pacing.point_pace),
97        ("minimax_video_weekly_pct", video(&snap.video_weekly, true)),
98        (
99            "minimax_video_weekly_reset",
100            video(&snap.video_weekly, false),
101        ),
102        ("minimax_video_weekly_elapsed", video_weekly_pacing.elapsed),
103        ("minimax_video_weekly_pace", video_weekly_pacing.ratio_pace),
104        (
105            "minimax_video_weekly_pace_indicator",
106            video_weekly_pacing.point_pace,
107        ),
108    ])
109}
110
111/// Elapsed fraction and pace glyphs for one window, as ready placeholder
112/// values.
113struct WindowPacing {
114    elapsed: String,
115    ratio_pace: String,
116    point_pace: String,
117}
118
119impl WindowPacing {
120    fn unavailable() -> Self {
121        Self {
122            elapsed: "—".to_string(),
123            ratio_pace: "—".to_string(),
124            point_pace: "—".to_string(),
125        }
126    }
127}
128
129fn optional_window_pacing(
130    window: Option<&UsageWindow>,
131    pace_tolerance: u32,
132    now: DateTime<Utc>,
133) -> WindowPacing {
134    window.map_or_else(WindowPacing::unavailable, |window| {
135        window_pacing(window, pace_tolerance, now)
136    })
137}
138
139fn window_pacing(w: &UsageWindow, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
140    let p = pacing::calc(
141        w.utilization_pct,
142        w.resets_at,
143        now,
144        w.window_duration,
145        pace_tolerance,
146    );
147    WindowPacing {
148        elapsed: p.elapsed_pct.to_string(),
149        ratio_pace: p.ratio_pace.glyph().to_string(),
150        point_pace: p.point_pace.glyph().to_string(),
151    }
152}
153
154/// Worst of the two text-pool windows. The video pool deliberately does not
155/// drive the bar color: running out of video quota should not paint the coding
156/// bar red.
157pub fn severity(snap: &MinimaxSnapshot) -> PaceSeverity {
158    severity_for(
159        snap.session
160            .utilization_pct
161            .max(snap.weekly.utilization_pct),
162    )
163}
164
165pub fn render(
166    outcome: &VendorOutcome,
167    snap: &MinimaxSnapshot,
168    theme: &Theme,
169    opts: &RenderOpts,
170    now: DateTime<Utc>,
171) -> WaybarOutput {
172    let class = Class::from(severity(snap));
173    let format = opts
174        .format
175        .clone()
176        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
177    let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
178    // User formats are Pango markup after Waybar renders them. Escape API
179    // strings there, while retaining raw values for the default tooltip (which
180    // escapes exactly once at its markup insertion point).
181    let mut pango_values = values.clone();
182    for key in ["plan", "minimax_plan"] {
183        if let Some(value) = pango_values.get_mut(key) {
184            *value = escape(value);
185        }
186    }
187
188    let mut text = substitute(&format, &pango_values);
189    if outcome.stale {
190        text.push_str(" ⏸");
191    }
192
193    let wrapper_color = severity_color(severity(snap), theme).to_string();
194    let icon_prefix = match opts.icon.as_deref() {
195        Some(ic) if !ic.is_empty() => format!("{ic} "),
196        _ => String::new(),
197    };
198    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
199
200    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
201        substitute(fmt, &pango_values)
202    } else {
203        render_tooltip(outcome, snap, theme, opts, now)
204    };
205
206    WaybarOutput {
207        text: bar_text,
208        tooltip,
209        class,
210    }
211}
212
213fn render_tooltip(
214    outcome: &VendorOutcome,
215    snap: &MinimaxSnapshot,
216    theme: &Theme,
217    opts: &RenderOpts,
218    now: DateTime<Utc>,
219) -> String {
220    let blue = &theme.blue;
221    let dim = &theme.dim;
222    let fg = &theme.fg;
223
224    let mut lines: Vec<TooltipLine> = Vec::new();
225    lines.push(TooltipLine::Center(format!(
226        "<span font_weight='bold' foreground='{blue}'>{}</span>",
227        escape(&snap.plan)
228    )));
229    lines.push(TooltipLine::Sep);
230
231    // Rows go through the shared window helper so a pool reads like every
232    // other vendor's block — bar, percentage, pace arrow — instead of the bare
233    // `Session 20%` pair MiniMax printed before. The pool keeps its heading,
234    // the way Antigravity groups its Gemini / third-party budgets.
235    let mut pool = |label: &str, icon: &str, session: &UsageWindow, weekly: &UsageWindow| {
236        lines.push(TooltipLine::Body("".into()));
237        lines.push(TooltipLine::Body(format!(
238            " <span font_weight='bold' foreground='{fg}'>  {icon}  {label}</span>"
239        )));
240        for (slot, (what, w)) in [("  󰔟  Session", session), ("  󰃰  Weekly", weekly)]
241            .into_iter()
242            .enumerate()
243        {
244            if slot > 0 {
245                lines.push(TooltipLine::Body("".into()));
246            }
247            push_window_with_row(
248                &mut lines,
249                what,
250                w,
251                theme,
252                now,
253                WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts),
254            );
255        }
256    };
257
258    pool(POOL_GENERAL, "󰅄", &snap.session, &snap.weekly);
259    if let (Some(vs), Some(vw)) = (&snap.video_session, &snap.video_weekly) {
260        pool(POOL_VIDEO, "󰕧", vs, vw);
261    }
262
263    if let Some((code, msg)) = outcome.last_error.as_ref() {
264        let (label, icon, ecolor) = if *code == 0 {
265            ("MiniMax error".to_string(), "󰅚", theme.red.as_str())
266        } else if *code >= 500 {
267            (format!("HTTP {code}"), "󰅚", theme.red.as_str())
268        } else {
269            (format!("HTTP {code}"), "󰀪", theme.orange.as_str())
270        };
271        lines.push(TooltipLine::Body("".into()));
272        lines.push(TooltipLine::Sep);
273        lines.push(TooltipLine::Body(format!(
274            " <span foreground='{ecolor}'>  {icon}  {label}</span>"
275        )));
276        if msg != &label {
277            lines.push(TooltipLine::Body(format!(
278                "     <span foreground='{dim}'>{}</span>",
279                escape(msg)
280            )));
281        }
282    }
283
284    let updated = updated_at_hm(now, outcome.cache_age);
285    lines.push(TooltipLine::Body("".into()));
286    lines.push(TooltipLine::Sep);
287    lines.push(TooltipLine::Body(format!(
288        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
289    )));
290
291    render_bordered(&lines, theme)
292}
293
294impl From<FetchOutcome> for VendorOutcome {
295    fn from(o: FetchOutcome) -> Self {
296        o.map(crate::usage::VendorSnapshot::Minimax)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use chrono::TimeZone;
304
305    fn now() -> DateTime<Utc> {
306        Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap()
307    }
308
309    fn window(pct: i32, mins_ahead: i64, dur: chrono::Duration) -> UsageWindow {
310        UsageWindow {
311            utilization_pct: pct,
312            resets_at: Some(now() + chrono::Duration::minutes(mins_ahead)),
313            window_duration: dur,
314        }
315    }
316
317    fn snap() -> MinimaxSnapshot {
318        MinimaxSnapshot {
319            plan: "MiniMax Token Plan".to_string(),
320            session: window(31, 45, chrono::Duration::hours(5)),
321            weekly: window(62, 3000, chrono::Duration::days(7)),
322            video_session: Some(window(5, 200, chrono::Duration::hours(24))),
323            video_weekly: Some(window(9, 3000, chrono::Duration::days(7))),
324        }
325    }
326
327    fn opts() -> RenderOpts {
328        RenderOpts {
329            format: None,
330            tooltip_format: None,
331            icon: None,
332            pace_tolerance: 5,
333            format_pace_color: false,
334            tooltip_pace_pts: false,
335        }
336    }
337
338    fn placeholders_at(
339        snap: &MinimaxSnapshot,
340        now: DateTime<Utc>,
341    ) -> HashMap<&'static str, String> {
342        build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
343    }
344
345    fn outcome(s: &MinimaxSnapshot) -> VendorOutcome {
346        VendorOutcome {
347            snapshot: crate::usage::VendorSnapshot::Minimax(s.clone()),
348            stale: false,
349            last_error: None,
350            cache_age: Some(std::time::Duration::ZERO),
351        }
352    }
353
354    #[test]
355    fn default_format_shows_session_percent_and_reset() {
356        let s = snap();
357        let out = render(&outcome(&s), &s, &Theme::default(), &opts(), now());
358        assert!(out.text.contains("31%"), "bar text was {:?}", out.text);
359    }
360
361    /// Cross-vendor aliases are what the GNOME/macOS surfaces read; without
362    /// them MiniMax would render blank rows on the desktop.
363    #[test]
364    fn exposes_cross_vendor_aliases() {
365        let v = placeholders_at(&snap(), now());
366        assert_eq!(v.get("session_pct").map(String::as_str), Some("31"));
367        assert_eq!(v.get("weekly_pct").map(String::as_str), Some("62"));
368        assert_eq!(v.get("vendor_short").map(String::as_str), Some("mmx"));
369        assert!(v.contains_key("session_reset"));
370    }
371
372    /// The video pool must not drag the coding bar into red.
373    #[test]
374    fn severity_ignores_the_video_pool() {
375        let mut s = snap();
376        s.session.utilization_pct = 10;
377        s.weekly.utilization_pct = 10;
378        s.video_session = Some(window(99, 10, chrono::Duration::hours(24)));
379        s.video_weekly = Some(window(99, 10, chrono::Duration::days(7)));
380        assert_eq!(severity(&s), severity_for(10));
381    }
382
383    #[test]
384    fn video_placeholders_degrade_to_a_dash_without_the_pool() {
385        let mut s = snap();
386        s.video_session = None;
387        s.video_weekly = None;
388        let v = placeholders_at(&s, now());
389        assert_eq!(v.get("minimax_video_pct").map(String::as_str), Some("—"));
390        assert_eq!(
391            v.get("minimax_video_weekly_pct").map(String::as_str),
392            Some("—")
393        );
394        assert_eq!(v.get("minimax_video_pace").map(String::as_str), Some("—"));
395        assert_eq!(
396            v.get("minimax_video_weekly_reset").map(String::as_str),
397            Some("—")
398        );
399        assert_eq!(
400            v.get("minimax_video_weekly_pace").map(String::as_str),
401            Some("—")
402        );
403    }
404
405    #[test]
406    fn public_builder_keeps_the_default_tolerance_api() {
407        let snap = snap();
408        assert_eq!(
409            build_placeholders(&snap, now()),
410            build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now())
411        );
412    }
413
414    #[test]
415    fn present_window_without_reset_uses_documented_neutral_pacing() {
416        let mut snap = snap();
417        snap.session.resets_at = None;
418        let values = placeholders_at(&snap, now());
419        assert_eq!(values["minimax_session_reset"], "—");
420        assert_eq!(values["minimax_session_elapsed"], "0");
421        assert_eq!(values["minimax_session_pace"], "→");
422        assert_eq!(values["minimax_session_pace_indicator"], "→");
423    }
424
425    #[test]
426    fn pace_placeholders_follow_usage_vs_elapsed() {
427        // Session: 80% used vs 2h/5h = 60% elapsed → ahead of pace. Weekly:
428        // 15% used vs 3d/7d = 57% elapsed → under pace.
429        let n = now();
430        let mut s = snap();
431        s.session = window(80, 120, chrono::Duration::hours(5));
432        s.weekly = window(15, 3 * 24 * 60, chrono::Duration::days(7));
433        let v = placeholders_at(&s, n);
434        assert_eq!(v["minimax_session_elapsed"], "60");
435        assert_eq!(v["minimax_session_pace"], "↑");
436        assert_eq!(v["minimax_session_pace_indicator"], "↑");
437        assert_eq!(v["minimax_weekly_elapsed"], "57");
438        assert_eq!(v["minimax_weekly_pace"], "↓");
439        assert_eq!(v["minimax_weekly_pace_indicator"], "↓");
440    }
441
442    #[test]
443    fn video_pace_follows_usage_vs_elapsed() {
444        // 90% used vs 6h/24h = 75% elapsed → ahead of pace.
445        let n = now();
446        let mut s = snap();
447        s.video_session = Some(window(90, 6 * 60, chrono::Duration::hours(24)));
448        let v = placeholders_at(&s, n);
449        assert_eq!(v["minimax_video_elapsed"], "75");
450        assert_eq!(v["minimax_video_pace"], "↑");
451        assert_eq!(v["minimax_video_pace_indicator"], "↑");
452    }
453
454    #[test]
455    fn video_weekly_exposes_the_complete_pacing_family() {
456        let n = now();
457        let mut s = snap();
458        s.video_weekly = Some(window(90, 3 * 24 * 60, chrono::Duration::days(7)));
459        let v = placeholders_at(&s, n);
460        assert_ne!(v["minimax_video_weekly_reset"], "—");
461        assert_eq!(v["minimax_video_weekly_elapsed"], "57");
462        assert_eq!(v["minimax_video_weekly_pace"], "↑");
463        assert_eq!(v["minimax_video_weekly_pace_indicator"], "↑");
464    }
465
466    #[test]
467    fn tooltip_lists_both_pools_when_present() {
468        let s = snap();
469        let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
470        assert!(tip.contains(POOL_GENERAL));
471        assert!(tip.contains(POOL_VIDEO));
472    }
473
474    #[test]
475    fn tooltip_omits_the_video_pool_when_absent() {
476        let mut s = snap();
477        s.video_session = None;
478        s.video_weekly = None;
479        let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
480        assert!(tip.contains(POOL_GENERAL));
481        assert!(!tip.contains(POOL_VIDEO));
482    }
483
484    /// Plan text reaches Pango exactly once escaped, from both paths.
485    #[test]
486    fn escapes_the_plan_name_exactly_once() {
487        let mut s = snap();
488        s.plan = "Plan <b>&</b>".to_string();
489        let out = render(
490            &outcome(&s),
491            &s,
492            &Theme::default(),
493            &RenderOpts {
494                format: Some("{minimax_plan}".to_string()),
495                ..opts()
496            },
497            now(),
498        );
499        assert!(
500            out.text.contains("&lt;b&gt;&amp;&lt;/b&gt;"),
501            "{:?}",
502            out.text
503        );
504        assert!(
505            !out.text.contains("&amp;lt;"),
506            "double-escaped: {:?}",
507            out.text
508        );
509    }
510
511    #[test]
512    fn stale_marks_the_bar() {
513        let s = snap();
514        let mut o = outcome(&s);
515        o.stale = true;
516        let out = render(&o, &s, &Theme::default(), &opts(), now());
517        assert!(out.text.contains('⏸'));
518    }
519
520    /// MiniMax printed a bare `Session 20%` pair per pool; both rows now draw
521    /// the shared bar and carry the pace arrow the placeholders already had.
522    #[test]
523    fn tooltip_draws_a_bar_and_pace_arrow_for_every_pool_row() {
524        let s = snap();
525        let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
526        let cells = tip.matches('░').count() + tip.matches('█').count();
527        assert_eq!(
528            cells,
529            4 * crate::pango::BAR_LEN as usize,
530            "expected a bar for each of the four windows: {tip}"
531        );
532        let arrows = tip.matches('↑').count() + tip.matches('→').count() + tip.matches('↓').count();
533        assert_eq!(arrows, 4, "expected one arrow per window: {tip}");
534    }
535
536    #[test]
537    fn the_elapsed_marker_stays_behind_tooltip_pace_pts() {
538        let s = snap();
539        let plain = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
540        let paced = render_tooltip(
541            &outcome(&s),
542            &s,
543            &Theme::default(),
544            &RenderOpts {
545                tooltip_pace_pts: true,
546                ..opts()
547            },
548            now(),
549        );
550        assert_ne!(plain, paced, "the marker should redraw the bars");
551    }
552}