Skip to main content

ai_usagebar/kiro/
vendor.rs

1//! Kiro CLI renderer — a single credit pool (used/limit + reset), closer in
2//! shape to `anthropic_api::vendor` (one headline %) than to Cursor's two
3//! pools, plus the reset countdown/tooltip layout `cursor::vendor` uses.
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::KiroSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{kiro_pct}%";
22
23/// No confirmed Kiro glyph in the common Nerd Font sets — a plain diamond,
24/// matching Cursor's "no dedicated icon" treatment. Override with `--icon`.
25const DEFAULT_ICON: &str = "◆";
26
27fn credits(v: f64) -> String {
28    // Two decimals only when the wire value actually carries a fraction
29    // (kiro-cli's own `/usage` shows "9943.38 of 10000", not "9943.00").
30    if (v.fract()).abs() < f64::EPSILON {
31        format!("{v:.0}")
32    } else {
33        format!("{v:.2}")
34    }
35}
36
37pub fn build_placeholders(
38    snap: &KiroSnapshot,
39    now: DateTime<Utc>,
40) -> HashMap<&'static str, String> {
41    let pct = snap.pct();
42    let reset = countdown::format(snap.reset_at, now);
43    placeholders(vec![
44        ("icon", DEFAULT_ICON.to_string()),
45        ("vendor_short", VendorId::Kiro.short_name().to_string()),
46        // Cross-vendor aliases: one pool, so it fills both generic slots.
47        ("plan", snap.plan.clone()),
48        ("session_pct", pct.to_string()),
49        ("session_reset", reset.clone()),
50        ("weekly_pct", pct.to_string()),
51        ("weekly_reset", reset.clone()),
52        // Kiro-specific placeholders.
53        ("kiro_plan", snap.plan.clone()),
54        ("kiro_pct", pct.to_string()),
55        ("kiro_used", credits(snap.used)),
56        ("kiro_limit", credits(snap.limit)),
57        ("kiro_reset", reset),
58    ])
59}
60
61pub fn severity(snap: &KiroSnapshot) -> PaceSeverity {
62    severity_for(snap.pct())
63}
64
65pub fn render(
66    outcome: &VendorOutcome,
67    snap: &KiroSnapshot,
68    theme: &Theme,
69    opts: &RenderOpts,
70    now: DateTime<Utc>,
71) -> WaybarOutput {
72    let class = Class::from(severity(snap));
73    let format = opts
74        .format
75        .clone()
76        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
77    let mut values = build_placeholders(snap, now);
78    // Both sinks fed by this map (bar text and --tooltip-format) are Pango
79    // markup. The plan label is API-controlled, so escape its aliases at the
80    // projection boundary. The default tooltip escapes the raw snapshot.
81    for key in ["plan", "kiro_plan"] {
82        if let Some(value) = values.get_mut(key) {
83            *value = escape(value);
84        }
85    }
86
87    let mut text = substitute(&format, &values);
88    if outcome.stale {
89        text.push_str(" ⏸");
90    }
91
92    let wrapper_color = severity_color(severity(snap), theme).to_string();
93    let icon_prefix = match opts.icon.as_deref() {
94        Some(ic) if !ic.is_empty() => format!("{ic} "),
95        _ => String::new(),
96    };
97    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
98
99    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
100        substitute(fmt, &values)
101    } else {
102        render_tooltip(outcome, snap, theme, now)
103    };
104
105    WaybarOutput {
106        text: bar_text,
107        tooltip,
108        class,
109    }
110}
111
112fn render_tooltip(
113    outcome: &VendorOutcome,
114    snap: &KiroSnapshot,
115    theme: &Theme,
116    now: DateTime<Utc>,
117) -> String {
118    let blue = &theme.blue;
119    let dim = &theme.dim;
120    let fg = &theme.fg;
121    let pct = snap.pct();
122    let color = severity_color(severity(snap), theme);
123
124    let mut lines: Vec<TooltipLine> = Vec::new();
125    lines.push(TooltipLine::Center(format!(
126        "<span font_weight='bold' foreground='{blue}'>Kiro {}</span>",
127        escape(&snap.plan)
128    )));
129    lines.push(TooltipLine::Sep);
130    lines.push(TooltipLine::Body("".into()));
131    lines.push(TooltipLine::Body(format!(
132        " <span foreground='{fg}'>  󰠮  Credits</span>"
133    )));
134    lines.push(TooltipLine::Body(format!(
135        "   <span font_weight='bold' foreground='{color}'>{pct}%</span> used \
136         <span foreground='{dim}'>({} of {})</span>",
137        credits(snap.used),
138        credits(snap.limit)
139    )));
140
141    lines.push(TooltipLine::Body("".into()));
142    lines.push(TooltipLine::Body(format!(
143        " <span foreground='{dim}'>  󰃰  Resets {}</span>",
144        escape(&countdown::format(snap.reset_at, now))
145    )));
146
147    if let Some((code, msg)) = outcome.last_error.as_ref()
148        && *code != 0
149    {
150        let (icon, ecolor) = if *code >= 500 {
151            ("󰅚", theme.red.as_str())
152        } else {
153            ("󰀪", theme.orange.as_str())
154        };
155        lines.push(TooltipLine::Body("".into()));
156        lines.push(TooltipLine::Sep);
157        lines.push(TooltipLine::Body(format!(
158            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
159        )));
160        lines.push(TooltipLine::Body(format!(
161            "     <span foreground='{dim}'>{}</span>",
162            escape(msg)
163        )));
164    }
165
166    let updated = updated_at_hm(now, outcome.cache_age);
167    lines.push(TooltipLine::Body("".into()));
168    lines.push(TooltipLine::Sep);
169    lines.push(TooltipLine::Body(format!(
170        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
171    )));
172
173    render_bordered(&lines, theme)
174}
175
176impl From<FetchOutcome> for VendorOutcome {
177    fn from(o: FetchOutcome) -> Self {
178        Self {
179            snapshot: crate::usage::VendorSnapshot::Kiro(o.snapshot),
180            stale: o.stale,
181            last_error: o.last_error,
182            cache_age: o.cache_age,
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use chrono::TimeZone;
191
192    fn now() -> DateTime<Utc> {
193        Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, 0).unwrap()
194    }
195
196    fn sample_snap() -> KiroSnapshot {
197        KiroSnapshot {
198            plan: "KIRO POWER".into(),
199            used: 9943.38,
200            limit: 10000.0,
201            reset_at: Some(now() + chrono::Duration::days(1)),
202        }
203    }
204
205    fn sample_outcome(snap: KiroSnapshot) -> VendorOutcome {
206        VendorOutcome {
207            snapshot: crate::usage::VendorSnapshot::Kiro(snap),
208            stale: false,
209            last_error: None,
210            cache_age: Some(std::time::Duration::from_secs(10)),
211        }
212    }
213
214    fn opts() -> RenderOpts {
215        RenderOpts {
216            format: None,
217            tooltip_format: None,
218            icon: None,
219            pace_tolerance: 5,
220            format_pace_color: false,
221            tooltip_pace_pts: false,
222        }
223    }
224
225    #[test]
226    fn default_bar_shows_the_percentage() {
227        let snap = sample_snap();
228        let out = render(
229            &sample_outcome(snap.clone()),
230            &snap,
231            &Theme::default(),
232            &opts(),
233            now(),
234        );
235        assert!(out.text.contains("99%"), "text: {}", out.text);
236    }
237
238    #[test]
239    fn tooltip_shows_plan_credits_and_reset() {
240        let snap = sample_snap();
241        let out = render(
242            &sample_outcome(snap.clone()),
243            &snap,
244            &Theme::default(),
245            &opts(),
246            now(),
247        );
248        assert!(out.tooltip.contains("Kiro KIRO POWER"));
249        assert!(out.tooltip.contains("99%"));
250        assert!(out.tooltip.contains("9943.38"));
251        assert!(out.tooltip.contains("10000"));
252        assert!(out.tooltip.contains("1d"));
253    }
254
255    #[test]
256    fn whole_number_credits_have_no_decimals() {
257        assert_eq!(credits(10000.0), "10000");
258        assert_eq!(credits(9943.38), "9943.38");
259    }
260
261    #[test]
262    fn severity_tracks_the_credit_percentage() {
263        let mut snap = sample_snap();
264        snap.used = 99.0;
265        snap.limit = 100.0;
266        assert_eq!(severity(&snap), PaceSeverity::Critical);
267    }
268
269    #[test]
270    fn stale_appends_pause() {
271        let snap = sample_snap();
272        let mut outcome = sample_outcome(snap.clone());
273        outcome.stale = true;
274        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
275        assert!(out.text.contains("⏸"));
276    }
277
278    #[test]
279    fn custom_tooltip_uses_placeholders() {
280        let snap = sample_snap();
281        let mut o = opts();
282        o.tooltip_format = Some("{kiro_pct}% of {kiro_limit} · {kiro_plan}".into());
283        let out = render(
284            &sample_outcome(snap.clone()),
285            &snap,
286            &Theme::default(),
287            &o,
288            now(),
289        );
290        assert_eq!(out.tooltip, "99% of 10000 · KIRO POWER");
291    }
292
293    #[test]
294    fn api_plan_is_inert_in_custom_pango_formats() {
295        let mut snap = sample_snap();
296        snap.plan = "<b>not markup</b> & control\u{1b}".into();
297        let mut o = opts();
298        o.format = Some("{kiro_plan}".into());
299        o.tooltip_format = Some("{plan}".into());
300
301        let out = render(
302            &sample_outcome(snap.clone()),
303            &snap,
304            &Theme::default(),
305            &o,
306            now(),
307        );
308
309        assert!(!out.text.contains("<b>"));
310        assert!(!out.tooltip.contains("<b>"));
311        assert!(
312            out.text
313                .contains("&lt;b&gt;not markup&lt;/b&gt; &amp; control")
314        );
315        assert_eq!(out.tooltip, "&lt;b&gt;not markup&lt;/b&gt; &amp; control");
316        assert!(!out.text.contains('\u{1b}'));
317    }
318
319    #[test]
320    fn generic_windows_map_to_the_single_pool() {
321        let values = build_placeholders(&sample_snap(), now());
322        assert_eq!(values["session_pct"], "99");
323        assert_eq!(values["weekly_pct"], "99");
324        assert_eq!(values["plan"], "KIRO POWER");
325    }
326
327    #[test]
328    fn placeholder_set_contains_all_keys() {
329        let values = build_placeholders(&sample_snap(), now());
330        for key in [
331            "icon",
332            "vendor_short",
333            "plan",
334            "session_pct",
335            "session_reset",
336            "weekly_pct",
337            "weekly_reset",
338            "kiro_plan",
339            "kiro_pct",
340            "kiro_used",
341            "kiro_limit",
342            "kiro_reset",
343        ] {
344            assert!(values.contains_key(key), "missing placeholder {key}");
345        }
346    }
347
348    #[test]
349    fn fetch_outcome_conversion_preserves_metadata() {
350        let fetch = FetchOutcome {
351            snapshot: sample_snap(),
352            stale: true,
353            last_error: Some((401, "bad".into())),
354            cache_age: Some(std::time::Duration::from_secs(42)),
355        };
356        let vendor: VendorOutcome = fetch.into();
357        assert!(matches!(
358            vendor.snapshot,
359            crate::usage::VendorSnapshot::Kiro(_)
360        ));
361        assert!(vendor.stale);
362    }
363}