Skip to main content

ai_usagebar/cursor/
vendor.rs

1//! Cursor renderer — bar text + bordered Pango tooltip. Two included-usage
2//! pools (Cursor Models / Other Models), like Kimi's two windows but keyed on
3//! model category rather than a time window.
4
5use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8
9use crate::countdown;
10use crate::format::{placeholders, substitute, updated_at_hm};
11use crate::pacing::PaceSeverity;
12use crate::pango::{color_span, escape, severity_color, severity_for};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::CursorSnapshot;
16use crate::vendor::{RenderOpts, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{cursor_auto_pct}·{cursor_api_pct}%";
22
23/// No confirmed Cursor glyph in the common Nerd Font sets, so a plain caret.
24/// Override with `--icon`.
25const DEFAULT_ICON: &str = "❯";
26
27pub fn build_placeholders(
28    snap: &CursorSnapshot,
29    now: DateTime<Utc>,
30) -> HashMap<&'static str, String> {
31    let reset = countdown::format(snap.reset_at, now);
32    placeholders(vec![
33        ("icon", DEFAULT_ICON.to_string()),
34        ("vendor_short", "cur".to_string()),
35        // Cross-vendor aliases: the two pools map onto the two generic windows
36        // (session = Cursor Models, weekly = Other Models) so a shared format
37        // and the macOS menu bar show both. Severity still keys on the worst.
38        ("plan", format!("Cursor {}", snap.plan)),
39        ("session_pct", snap.auto_pct.to_string()),
40        ("session_reset", reset.clone()),
41        ("weekly_pct", snap.api_pct.to_string()),
42        ("weekly_reset", reset.clone()),
43        // Cursor-specific placeholders.
44        ("cursor_plan", snap.plan.clone()),
45        ("cursor_auto_pct", snap.auto_pct.to_string()),
46        ("cursor_api_pct", snap.api_pct.to_string()),
47        ("cursor_total_pct", snap.total_pct.to_string()),
48        ("cursor_reset", reset),
49        (
50            "cursor_on_demand",
51            if snap.on_demand_enabled { "on" } else { "off" }.to_string(),
52        ),
53        (
54            "cursor_unlimited",
55            if snap.unlimited { "yes" } else { "no" }.to_string(),
56        ),
57    ])
58}
59
60/// Severity keys on the binding pool. An unlimited plan has no cap, so it stays
61/// calm regardless of the (meaningless) percentages.
62pub fn severity(snap: &CursorSnapshot) -> PaceSeverity {
63    if snap.unlimited {
64        PaceSeverity::Low
65    } else {
66        severity_for(snap.worst_pct())
67    }
68}
69
70pub fn render(
71    outcome: &VendorOutcome,
72    snap: &CursorSnapshot,
73    theme: &Theme,
74    opts: &RenderOpts,
75    now: DateTime<Utc>,
76) -> WaybarOutput {
77    let class = Class::from(severity(snap));
78    let format = opts
79        .format
80        .clone()
81        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
82    let values = build_placeholders(snap, now);
83
84    let mut text = if snap.unlimited && opts.format.is_none() {
85        "unlimited".to_string()
86    } else {
87        substitute(&format, &values)
88    };
89    if outcome.stale {
90        text.push_str(" ⏸");
91    }
92
93    let wrapper_color = severity_color(severity(snap), theme).to_string();
94    let icon_prefix = match opts.icon.as_deref() {
95        Some(ic) if !ic.is_empty() => format!("{ic} "),
96        _ => String::new(),
97    };
98    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
99
100    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
101        substitute(fmt, &values)
102    } else {
103        render_tooltip(outcome, snap, theme, now)
104    };
105
106    WaybarOutput {
107        text: bar_text,
108        tooltip,
109        class,
110    }
111}
112
113fn pool_line(lines: &mut Vec<TooltipLine>, theme: &Theme, label: &str, pct: i32) {
114    let fg = &theme.fg;
115    let color = severity_color(severity_for(pct), theme);
116    lines.push(TooltipLine::Body(format!(
117        " <span foreground='{fg}'>  󰢻  {label}</span>"
118    )));
119    lines.push(TooltipLine::Body(format!(
120        "   <span font_weight='bold' foreground='{color}'>{pct}%</span> used"
121    )));
122}
123
124fn render_tooltip(
125    outcome: &VendorOutcome,
126    snap: &CursorSnapshot,
127    theme: &Theme,
128    now: DateTime<Utc>,
129) -> String {
130    let blue = &theme.blue;
131    let dim = &theme.dim;
132    let fg = &theme.fg;
133
134    let mut lines: Vec<TooltipLine> = Vec::new();
135    lines.push(TooltipLine::Center(format!(
136        "<span font_weight='bold' foreground='{blue}'>Cursor {}</span>",
137        escape(&snap.plan)
138    )));
139    lines.push(TooltipLine::Sep);
140    lines.push(TooltipLine::Body("".into()));
141
142    if snap.unlimited {
143        lines.push(TooltipLine::Body(format!(
144            " <span foreground='{fg}'>  󰐾  Unlimited plan</span>"
145        )));
146    } else {
147        pool_line(&mut lines, theme, "Cursor Models", snap.auto_pct);
148        lines.push(TooltipLine::Body(format!(
149            " <span foreground='{dim}'>     Auto + Composer</span>"
150        )));
151        lines.push(TooltipLine::Body("".into()));
152        pool_line(&mut lines, theme, "Other Models", snap.api_pct);
153        lines.push(TooltipLine::Body(format!(
154            " <span foreground='{dim}'>     Named / API models · on-demand {}</span>",
155            if snap.on_demand_enabled { "on" } else { "off" }
156        )));
157    }
158
159    lines.push(TooltipLine::Body("".into()));
160    lines.push(TooltipLine::Body(format!(
161        " <span foreground='{dim}'>  󰃰  Resets {}</span>",
162        escape(&countdown::format(snap.reset_at, now))
163    )));
164
165    if let Some((code, msg)) = outcome.last_error.as_ref()
166        && *code != 0
167    {
168        let (icon, ecolor) = if *code >= 500 {
169            ("󰅚", theme.red.as_str())
170        } else {
171            ("󰀪", theme.orange.as_str())
172        };
173        lines.push(TooltipLine::Body("".into()));
174        lines.push(TooltipLine::Sep);
175        lines.push(TooltipLine::Body(format!(
176            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
177        )));
178        lines.push(TooltipLine::Body(format!(
179            "     <span foreground='{dim}'>{}</span>",
180            escape(msg)
181        )));
182    }
183
184    let updated = updated_at_hm(now, outcome.cache_age);
185    lines.push(TooltipLine::Body("".into()));
186    lines.push(TooltipLine::Sep);
187    lines.push(TooltipLine::Body(format!(
188        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
189    )));
190
191    render_bordered(&lines, theme)
192}
193
194impl From<FetchOutcome> for VendorOutcome {
195    fn from(o: FetchOutcome) -> Self {
196        Self {
197            snapshot: crate::usage::VendorSnapshot::Cursor(o.snapshot),
198            stale: o.stale,
199            last_error: o.last_error,
200            cache_age: o.cache_age,
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use chrono::TimeZone;
209
210    fn now() -> DateTime<Utc> {
211        Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, 0).unwrap()
212    }
213
214    fn sample_snap() -> CursorSnapshot {
215        CursorSnapshot {
216            plan: "Ultra".into(),
217            auto_pct: 98,
218            api_pct: 100,
219            total_pct: 99,
220            unlimited: false,
221            on_demand_enabled: false,
222            reset_at: Some(now() + chrono::Duration::days(9)),
223        }
224    }
225
226    fn sample_outcome(snap: CursorSnapshot) -> VendorOutcome {
227        VendorOutcome {
228            snapshot: crate::usage::VendorSnapshot::Cursor(snap),
229            stale: false,
230            last_error: None,
231            cache_age: Some(std::time::Duration::from_secs(10)),
232        }
233    }
234
235    fn opts() -> RenderOpts {
236        RenderOpts {
237            format: None,
238            tooltip_format: None,
239            icon: None,
240            pace_tolerance: 5,
241            format_pace_color: false,
242            tooltip_pace_pts: false,
243        }
244    }
245
246    #[test]
247    fn default_bar_shows_both_pools() {
248        let snap = sample_snap();
249        let out = render(
250            &sample_outcome(snap.clone()),
251            &snap,
252            &Theme::default(),
253            &opts(),
254            now(),
255        );
256        assert!(out.text.contains("98·100%"), "text: {}", out.text);
257    }
258
259    #[test]
260    fn tooltip_breaks_out_both_pools_and_reset() {
261        let snap = sample_snap();
262        let out = render(
263            &sample_outcome(snap.clone()),
264            &snap,
265            &Theme::default(),
266            &opts(),
267            now(),
268        );
269        assert!(out.tooltip.contains("Cursor Ultra"));
270        assert!(out.tooltip.contains("Cursor Models"));
271        assert!(out.tooltip.contains("98%"));
272        assert!(out.tooltip.contains("Other Models"));
273        assert!(out.tooltip.contains("100%"));
274        assert!(out.tooltip.contains("9d"));
275    }
276
277    #[test]
278    fn severity_keys_on_the_worst_pool() {
279        let mut snap = sample_snap();
280        snap.auto_pct = 10;
281        snap.api_pct = 95;
282        // Other Models at 95% must drive severity Critical even though Cursor
283        // Models is calm.
284        assert_eq!(severity(&snap), PaceSeverity::Critical);
285    }
286
287    #[test]
288    fn unlimited_plan_is_calm_and_labeled() {
289        let mut snap = sample_snap();
290        snap.unlimited = true;
291        let out = render(
292            &sample_outcome(snap.clone()),
293            &snap,
294            &Theme::default(),
295            &opts(),
296            now(),
297        );
298        assert_eq!(severity(&snap), PaceSeverity::Low);
299        assert!(out.text.contains("unlimited"));
300        assert!(out.tooltip.contains("Unlimited plan"));
301    }
302
303    #[test]
304    fn stale_appends_pause() {
305        let snap = sample_snap();
306        let mut outcome = sample_outcome(snap.clone());
307        outcome.stale = true;
308        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
309        assert!(out.text.contains("⏸"));
310    }
311
312    #[test]
313    fn custom_tooltip_uses_placeholders() {
314        let snap = sample_snap();
315        let mut o = opts();
316        o.tooltip_format = Some("auto {cursor_auto_pct} api {cursor_api_pct} {cursor_plan}".into());
317        let out = render(
318            &sample_outcome(snap.clone()),
319            &snap,
320            &Theme::default(),
321            &o,
322            now(),
323        );
324        assert_eq!(out.tooltip, "auto 98 api 100 Ultra");
325    }
326
327    #[test]
328    fn generic_windows_map_to_the_two_pools() {
329        let values = build_placeholders(&sample_snap(), now());
330        assert_eq!(values["session_pct"], "98"); // Cursor Models (auto)
331        assert_eq!(values["weekly_pct"], "100"); // Other Models (api)
332        assert_eq!(values["plan"], "Cursor Ultra");
333    }
334
335    #[test]
336    fn placeholder_set_contains_all_keys() {
337        let values = build_placeholders(&sample_snap(), now());
338        for key in [
339            "icon",
340            "vendor_short",
341            "plan",
342            "session_pct",
343            "session_reset",
344            "weekly_pct",
345            "weekly_reset",
346            "cursor_plan",
347            "cursor_auto_pct",
348            "cursor_api_pct",
349            "cursor_total_pct",
350            "cursor_reset",
351            "cursor_on_demand",
352            "cursor_unlimited",
353        ] {
354            assert!(values.contains_key(key), "missing placeholder {key}");
355        }
356    }
357
358    #[test]
359    fn fetch_outcome_conversion_preserves_metadata() {
360        let fetch = FetchOutcome {
361            snapshot: sample_snap(),
362            stale: true,
363            last_error: Some((401, "bad".into())),
364            cache_age: Some(std::time::Duration::from_secs(42)),
365        };
366        let vendor: VendorOutcome = fetch.into();
367        assert!(matches!(
368            vendor.snapshot,
369            crate::usage::VendorSnapshot::Cursor(_)
370        ));
371        assert!(vendor.stale);
372    }
373}