Skip to main content

ai_usagebar/zai/
vendor.rs

1//! Z.AI renderer — bar text + bordered Pango tooltip.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{placeholders, substitute, updated_at_hm};
9use crate::pacing::{self, PaceSeverity};
10use crate::pango::{color_span, escape, severity_color, severity_for};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
13use crate::usage::{UsageWindow, ZaiSnapshot};
14use crate::vendor::{RenderOpts, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{zai_session_pct}% · {zai_session_reset}";
20
21/// Build placeholders with the historical default pacing tolerance.
22///
23/// Keep this signature stable for library callers. Rendering uses the private
24/// tolerance-aware helper so `--pace-tolerance` still applies.
25pub fn build_placeholders(snap: &ZaiSnapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
26    build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
27}
28
29fn build_placeholders_with_tolerance(
30    snap: &ZaiSnapshot,
31    pace_tolerance: u32,
32    now: DateTime<Utc>,
33) -> HashMap<&'static str, String> {
34    let session_pct = snap
35        .session
36        .as_ref()
37        .map(|w| w.utilization_pct)
38        .unwrap_or(0);
39    let weekly_pct = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
40    let mcp_pct = snap.mcp.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
41    // A present window normally carries a reset, but the upstream response may
42    // omit it. `pacing::calc` deliberately returns neutral 0/arrow values in
43    // that case, matching the established OpenAI placeholder contract.
44    let session = window_pacing(snap.session.as_ref(), pace_tolerance, now);
45    let weekly = window_pacing(snap.weekly.as_ref(), pace_tolerance, now);
46    let mcp = window_pacing(snap.mcp.as_ref(), pace_tolerance, now);
47    placeholders(vec![
48        ("icon", "󰚩".to_string()),
49        ("vendor_short", "zai".to_string()),
50        // Cross-vendor aliases for scroll-cycle friendly formats.
51        ("session_pct", session_pct.to_string()),
52        (
53            "session_reset",
54            countdown::format(window_reset(&snap.session), now),
55        ),
56        ("weekly_pct", weekly_pct.to_string()),
57        (
58            "weekly_reset",
59            countdown::format(window_reset(&snap.weekly), now),
60        ),
61        ("session_elapsed", session.elapsed.clone()),
62        ("weekly_elapsed", weekly.elapsed.clone()),
63        ("plan", snap.plan.clone()),
64        ("zai_plan", snap.plan.clone()),
65        ("zai_session_pct", session_pct.to_string()),
66        (
67            "zai_session_reset",
68            countdown::format(window_reset(&snap.session), now),
69        ),
70        ("zai_weekly_pct", weekly_pct.to_string()),
71        (
72            "zai_weekly_reset",
73            countdown::format(window_reset(&snap.weekly), now),
74        ),
75        ("zai_session_elapsed", session.elapsed),
76        ("zai_session_pace", session.ratio_pace),
77        ("zai_session_pace_indicator", session.point_pace),
78        ("zai_weekly_elapsed", weekly.elapsed),
79        ("zai_weekly_pace", weekly.ratio_pace),
80        ("zai_weekly_pace_indicator", weekly.point_pace),
81        ("zai_mcp_pct", mcp_pct.to_string()),
82        (
83            "zai_mcp_reset",
84            countdown::format(window_reset(&snap.mcp), now),
85        ),
86        ("zai_mcp_elapsed", mcp.elapsed),
87        ("zai_mcp_pace", mcp.ratio_pace),
88        ("zai_mcp_pace_indicator", mcp.point_pace),
89    ])
90}
91
92fn window_reset(w: &Option<UsageWindow>) -> Option<DateTime<Utc>> {
93    w.as_ref().and_then(|w| w.resets_at)
94}
95
96/// Elapsed fraction and pace glyphs for one window, as ready placeholder
97/// values. Empty strings when the window is absent, mirroring the OpenAI
98/// renderer's convention; `pacing::calc` degrades to neutral 0/glyphs when
99/// the reset is unreported.
100#[derive(Default)]
101struct WindowPacing {
102    elapsed: String,
103    ratio_pace: String,
104    point_pace: String,
105}
106
107fn window_pacing(w: Option<&UsageWindow>, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
108    let Some(w) = w else {
109        return WindowPacing::default();
110    };
111    let p = pacing::calc(
112        w.utilization_pct,
113        w.resets_at,
114        now,
115        w.window_duration,
116        pace_tolerance,
117    );
118    WindowPacing {
119        elapsed: p.elapsed_pct.to_string(),
120        ratio_pace: p.ratio_pace.glyph().to_string(),
121        point_pace: p.point_pace.glyph().to_string(),
122    }
123}
124
125pub fn severity(snap: &ZaiSnapshot) -> PaceSeverity {
126    let session = snap
127        .session
128        .as_ref()
129        .map(|w| w.utilization_pct)
130        .unwrap_or(0);
131    let weekly = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
132    let mcp = snap.mcp.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
133    severity_for([session, weekly, mcp].into_iter().max().unwrap_or(0))
134}
135
136pub fn render(
137    outcome: &VendorOutcome,
138    snap: &ZaiSnapshot,
139    theme: &Theme,
140    opts: &RenderOpts,
141    now: DateTime<Utc>,
142) -> WaybarOutput {
143    let class = Class::from(severity(snap));
144    let format = opts
145        .format
146        .clone()
147        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
148    let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
149
150    let mut text = substitute(&format, &values);
151    if outcome.stale {
152        text.push_str(" ⏸");
153    }
154    let wrapper_color = severity_color(severity(snap), theme).to_string();
155    let icon_prefix = match opts.icon.as_deref() {
156        Some(ic) if !ic.is_empty() => format!("{ic} "),
157        _ => String::new(),
158    };
159    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
160
161    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
162        substitute(fmt, &values)
163    } else {
164        render_tooltip(outcome, snap, theme, opts, now)
165    };
166
167    WaybarOutput {
168        text: bar_text,
169        tooltip,
170        class,
171    }
172}
173
174fn render_tooltip(
175    outcome: &VendorOutcome,
176    snap: &ZaiSnapshot,
177    theme: &Theme,
178    opts: &RenderOpts,
179    now: DateTime<Utc>,
180) -> String {
181    let blue = &theme.blue;
182    let dim = &theme.dim;
183    // The `{zai_*_pace}` placeholders already carry this; the default tooltip
184    // never consulted them, so the arrow the macOS bar draws was missing here.
185    let row =
186        |w: &UsageWindow| WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts);
187    let mut lines: Vec<TooltipLine> = Vec::new();
188    lines.push(TooltipLine::Center(format!(
189        "<span font_weight='bold' foreground='{blue}'>{plan}</span>",
190        plan = escape(&snap.plan)
191    )));
192    lines.push(TooltipLine::Sep);
193    lines.push(TooltipLine::Body("".into()));
194
195    if let Some(w) = snap.session.as_ref() {
196        push_window_with_row(&mut lines, "  󰔟  Session (5h)", w, theme, now, row(w));
197    }
198    if let Some(w) = snap.weekly.as_ref() {
199        if snap.session.is_some() {
200            lines.push(TooltipLine::Body("".into()));
201        }
202        push_window_with_row(&mut lines, "  󰃰  Weekly", w, theme, now, row(w));
203    }
204    if let Some(w) = snap.mcp.as_ref() {
205        lines.push(TooltipLine::Body("".into()));
206        lines.push(TooltipLine::Sep);
207        push_window_with_row(
208            &mut lines,
209            "  󰓹  MCP tools (monthly)",
210            w,
211            theme,
212            now,
213            row(w),
214        );
215    }
216    if snap.session.is_none() && snap.weekly.is_none() && snap.mcp.is_none() {
217        lines.push(TooltipLine::Body(format!(
218            " <span foreground='{dim}'>no usage windows reported</span>"
219        )));
220    }
221
222    if let Some((code, msg)) = outcome.last_error.as_ref()
223        && *code != 0
224    {
225        let (icon, ecolor) = if *code >= 500 {
226            ("󰅚", theme.red.as_str())
227        } else {
228            ("󰀪", theme.orange.as_str())
229        };
230        lines.push(TooltipLine::Body("".into()));
231        lines.push(TooltipLine::Sep);
232        lines.push(TooltipLine::Body(format!(
233            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
234        )));
235        lines.push(TooltipLine::Body(format!(
236            "     <span foreground='{dim}'>{}</span>",
237            escape(msg)
238        )));
239    }
240
241    let updated = updated_at_hm(now, outcome.cache_age);
242    lines.push(TooltipLine::Body("".into()));
243    lines.push(TooltipLine::Sep);
244    lines.push(TooltipLine::Body(format!(
245        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
246    )));
247
248    render_bordered(&lines, theme)
249}
250
251impl From<FetchOutcome> for VendorOutcome {
252    fn from(o: FetchOutcome) -> Self {
253        Self {
254            snapshot: crate::usage::VendorSnapshot::Zai(o.snapshot),
255            stale: o.stale,
256            last_error: o.last_error,
257            cache_age: o.cache_age,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::usage::{UsageWindow, ZaiSnapshot};
266
267    fn sample_snap() -> ZaiSnapshot {
268        let now = Utc::now();
269        ZaiSnapshot {
270            plan: "GLM Coding Pro".into(),
271            session: Some(UsageWindow {
272                utilization_pct: 42,
273                resets_at: Some(now + chrono::Duration::hours(2)),
274                window_duration: chrono::Duration::hours(5),
275            }),
276            weekly: Some(UsageWindow {
277                utilization_pct: 15,
278                resets_at: Some(now + chrono::Duration::days(3)),
279                window_duration: chrono::Duration::days(7),
280            }),
281            mcp: None,
282        }
283    }
284
285    fn outcome(s: ZaiSnapshot) -> VendorOutcome {
286        VendorOutcome {
287            snapshot: crate::usage::VendorSnapshot::Zai(s),
288            stale: false,
289            last_error: None,
290            cache_age: Some(std::time::Duration::from_secs(10)),
291        }
292    }
293
294    fn opts() -> RenderOpts {
295        RenderOpts {
296            format: None,
297            tooltip_format: None,
298            icon: None,
299            pace_tolerance: 5,
300            format_pace_color: false,
301            tooltip_pace_pts: false,
302        }
303    }
304
305    fn placeholders_at(snap: &ZaiSnapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
306        build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
307    }
308
309    #[test]
310    fn default_format_renders_session_pct() {
311        let snap = sample_snap();
312        let oc = outcome(snap.clone());
313        let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
314        assert!(out.text.contains("42%"));
315    }
316
317    #[test]
318    fn elapsed_placeholders_follow_window_progress() {
319        // Fixed `now` on both sides so the fraction is exact: 2h left of a 5h
320        // window → 60% elapsed; 3d left of a 7d window → 57%.
321        let now = Utc::now();
322        let snap = ZaiSnapshot {
323            plan: "GLM Coding Pro".into(),
324            session: Some(UsageWindow {
325                utilization_pct: 42,
326                resets_at: Some(now + chrono::Duration::hours(2)),
327                window_duration: chrono::Duration::hours(5),
328            }),
329            weekly: Some(UsageWindow {
330                utilization_pct: 15,
331                resets_at: Some(now + chrono::Duration::days(3)),
332                window_duration: chrono::Duration::days(7),
333            }),
334            mcp: None,
335        };
336        let values = placeholders_at(&snap, now);
337        assert_eq!(values["session_elapsed"], "60");
338        assert_eq!(values["weekly_elapsed"], "57");
339    }
340
341    #[test]
342    fn pace_placeholders_follow_usage_vs_elapsed() {
343        // Session: 80% used vs 60% elapsed → ahead of pace. Weekly: 15% used
344        // vs 57% elapsed → under pace.
345        let now = Utc::now();
346        let snap = ZaiSnapshot {
347            plan: "GLM Coding Pro".into(),
348            session: Some(UsageWindow {
349                utilization_pct: 80,
350                resets_at: Some(now + chrono::Duration::hours(2)),
351                window_duration: chrono::Duration::hours(5),
352            }),
353            weekly: Some(UsageWindow {
354                utilization_pct: 15,
355                resets_at: Some(now + chrono::Duration::days(3)),
356                window_duration: chrono::Duration::days(7),
357            }),
358            mcp: None,
359        };
360        let values = placeholders_at(&snap, now);
361        assert_eq!(values["zai_session_pace"], "↑");
362        assert_eq!(values["zai_session_pace_indicator"], "↑");
363        assert_eq!(values["zai_weekly_pace"], "↓");
364        assert_eq!(values["zai_weekly_pace_indicator"], "↓");
365        assert_eq!(values["zai_session_elapsed"], "60");
366        assert_eq!(values["zai_weekly_elapsed"], "57");
367    }
368
369    #[test]
370    fn public_builder_keeps_the_default_tolerance_api() {
371        let now = Utc::now();
372        let snap = sample_snap();
373        assert_eq!(
374            build_placeholders(&snap, now),
375            build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now)
376        );
377    }
378
379    #[test]
380    fn custom_tolerance_changes_the_ratio_pace() {
381        let now = Utc::now();
382        let snap = ZaiSnapshot {
383            plan: "GLM Coding Pro".into(),
384            session: Some(UsageWindow {
385                utilization_pct: 53,
386                resets_at: Some(now + chrono::Duration::minutes(150)),
387                window_duration: chrono::Duration::hours(5),
388            }),
389            weekly: None,
390            mcp: None,
391        };
392        assert_eq!(
393            build_placeholders_with_tolerance(&snap, 5, now)["zai_session_pace"],
394            "↑"
395        );
396        assert_eq!(
397            build_placeholders_with_tolerance(&snap, 10, now)["zai_session_pace"],
398            "→"
399        );
400    }
401
402    #[test]
403    fn present_window_without_reset_uses_documented_neutral_pacing() {
404        let now = Utc::now();
405        let mut snap = sample_snap();
406        snap.session.as_mut().unwrap().resets_at = None;
407        let values = placeholders_at(&snap, now);
408        assert_eq!(values["zai_session_reset"], "—");
409        assert_eq!(values["zai_session_elapsed"], "0");
410        assert_eq!(values["zai_session_pace"], "→");
411        assert_eq!(values["zai_session_pace_indicator"], "→");
412    }
413
414    #[test]
415    fn mcp_pace_placeholders_follow_usage_vs_elapsed() {
416        // 70% used vs 15d/30d = 50% elapsed → ahead of pace.
417        let now = Utc::now();
418        let snap = ZaiSnapshot {
419            plan: "GLM Coding Pro".into(),
420            session: None,
421            weekly: None,
422            mcp: Some(UsageWindow {
423                utilization_pct: 70,
424                resets_at: Some(now + chrono::Duration::days(15)),
425                window_duration: chrono::Duration::days(30),
426            }),
427        };
428        let values = placeholders_at(&snap, now);
429        assert_eq!(values["zai_mcp_elapsed"], "50");
430        assert_eq!(values["zai_mcp_pace"], "↑");
431        assert_eq!(values["zai_mcp_pace_indicator"], "↑");
432    }
433
434    #[test]
435    fn elapsed_placeholders_empty_without_window() {
436        let snap = ZaiSnapshot {
437            plan: "GLM Coding Unknown".into(),
438            session: None,
439            weekly: None,
440            mcp: None,
441        };
442        let values = placeholders_at(&snap, Utc::now());
443        assert_eq!(values["session_elapsed"], "");
444        assert_eq!(values["weekly_elapsed"], "");
445        assert_eq!(values["zai_session_pace"], "");
446        assert_eq!(values["zai_mcp_elapsed"], "");
447        assert_eq!(values["zai_mcp_pace"], "");
448    }
449
450    #[test]
451    fn tooltip_contains_all_windows_present() {
452        let snap = sample_snap();
453        let oc = outcome(snap.clone());
454        let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
455        assert!(out.tooltip.contains("Session"));
456        assert!(out.tooltip.contains("Weekly"));
457        assert!(!out.tooltip.contains("MCP"));
458    }
459
460    #[test]
461    fn empty_snapshot_renders_no_windows_message() {
462        let snap = ZaiSnapshot {
463            plan: "GLM Coding Unknown".into(),
464            session: None,
465            weekly: None,
466            mcp: None,
467        };
468        let oc = outcome(snap.clone());
469        let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
470        assert!(out.tooltip.contains("no usage windows reported"));
471    }
472
473    #[test]
474    fn severity_picks_worst_window() {
475        let mut snap = sample_snap();
476        snap.weekly.as_mut().unwrap().utilization_pct = 95;
477        assert_eq!(severity(&snap), PaceSeverity::Critical);
478    }
479
480    #[test]
481    fn custom_tooltip_uses_placeholders() {
482        let snap = sample_snap();
483        let oc = outcome(snap.clone());
484        let mut o = opts();
485        o.tooltip_format = Some("S:{zai_session_pct} W:{zai_weekly_pct}".into());
486        let out = render(&oc, &snap, &Theme::default(), &o, Utc::now());
487        assert_eq!(out.tooltip, "S:42 W:15");
488    }
489
490    fn fixed_now() -> DateTime<Utc> {
491        use chrono::TimeZone;
492        Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap()
493    }
494
495    fn paced_snap() -> ZaiSnapshot {
496        let now = fixed_now();
497        ZaiSnapshot {
498            plan: "GLM Coding Pro".into(),
499            // 3h left of 5h → 40% elapsed against 80% used: clearly ahead.
500            session: Some(UsageWindow {
501                utilization_pct: 80,
502                resets_at: Some(now + chrono::Duration::hours(3)),
503                window_duration: chrono::Duration::hours(5),
504            }),
505            // Reported without a reset — the case the user's own account hits.
506            weekly: Some(UsageWindow {
507                utilization_pct: 3,
508                resets_at: None,
509                window_duration: chrono::Duration::days(7),
510            }),
511            mcp: None,
512        }
513    }
514
515    /// The `{zai_*_pace}` placeholders have carried this since the pace PR; the
516    /// default tooltip never read them, so the CLI showed no arrow where the
517    /// macOS bar did.
518    #[test]
519    fn tooltip_shows_the_pace_arrow_next_to_each_percentage() {
520        let snap = paced_snap();
521        let out = render(
522            &outcome(snap.clone()),
523            &snap,
524            &Theme::default(),
525            &opts(),
526            fixed_now(),
527        );
528        assert!(out.tooltip.contains("80% ↑"), "{}", out.tooltip);
529        // No reset reported → neutral pacing rather than a blank column.
530        assert!(out.tooltip.contains("3% →"), "{}", out.tooltip);
531    }
532
533    /// The elapsed marker stays opt-in, exactly as on the Anthropic tooltip.
534    #[test]
535    fn the_elapsed_marker_stays_behind_tooltip_pace_pts() {
536        let snap = paced_snap();
537        let plain = render(
538            &outcome(snap.clone()),
539            &snap,
540            &Theme::default(),
541            &opts(),
542            fixed_now(),
543        );
544        let paced = render(
545            &outcome(snap.clone()),
546            &snap,
547            &Theme::default(),
548            &RenderOpts {
549                tooltip_pace_pts: true,
550                ..opts()
551            },
552            fixed_now(),
553        );
554        assert_ne!(
555            plain.tooltip, paced.tooltip,
556            "the marker should redraw the bars"
557        );
558        assert!(paced.tooltip.contains("80% ↑"), "{}", paced.tooltip);
559    }
560}