Skip to main content

ai_usagebar/kimi/
vendor.rs

1//! Kimi 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::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color, severity_for};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::KimiSnapshot;
14use crate::vendor::{RenderOpts, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::{FetchOutcome, SCHEMA_DRIFT_MESSAGE};
18
19/// Presentation classification for Kimi's legacy `(u16, String)` cached
20/// diagnostic. Code zero has never meant HTTP; the stable schema marker lets
21/// renderers distinguish an upstream response-shape change from other local
22/// failures without changing the on-disk cache format.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum WarningKind {
25    Http(u16),
26    SchemaDrift,
27    Other,
28}
29
30pub fn warning_kind(code: u16, message: &str) -> WarningKind {
31    if code != 0 {
32        WarningKind::Http(code)
33    } else if message == SCHEMA_DRIFT_MESSAGE {
34        WarningKind::SchemaDrift
35    } else {
36        WarningKind::Other
37    }
38}
39
40pub const DEFAULT_FORMAT: &str = "{kimi_weekly_pct}%";
41
42pub fn build_placeholders(
43    snap: &KimiSnapshot,
44    now: DateTime<Utc>,
45) -> HashMap<&'static str, String> {
46    let plan = snap.plan.as_deref().unwrap_or("Kimi");
47    let weekly_pct = snap.weekly_pct();
48    let window_pct = snap.window_pct();
49    placeholders(vec![
50        ("icon", "󰚩".to_string()),
51        ("vendor_short", "kmi".to_string()),
52        // Cross-vendor aliases.
53        ("plan", plan.to_string()),
54        ("weekly_pct", weekly_pct.to_string()),
55        ("weekly_reset", countdown::format(snap.weekly_reset_at, now)),
56        ("session_pct", window_pct.to_string()),
57        (
58            "session_reset",
59            countdown::format(snap.window_reset_at, now),
60        ),
61        // Kimi-specific placeholders.
62        ("kimi_plan", plan.to_string()),
63        ("kimi_weekly_pct", weekly_pct.to_string()),
64        ("kimi_weekly_used", snap.weekly_used.to_string()),
65        ("kimi_weekly_limit", snap.weekly_limit.to_string()),
66        ("kimi_weekly_remaining", snap.weekly_remaining.to_string()),
67        (
68            "kimi_weekly_reset",
69            countdown::format(snap.weekly_reset_at, now),
70        ),
71        ("kimi_window_pct", window_pct.to_string()),
72        ("kimi_window_used", snap.window_used.to_string()),
73        ("kimi_window_limit", snap.window_limit.to_string()),
74        ("kimi_window_remaining", snap.window_remaining.to_string()),
75        (
76            "kimi_window_reset",
77            countdown::format(snap.window_reset_at, now),
78        ),
79    ])
80}
81
82pub fn severity(snap: &KimiSnapshot) -> PaceSeverity {
83    severity_for(snap.weekly_pct().max(snap.window_pct()))
84}
85
86pub fn render(
87    outcome: &VendorOutcome,
88    snap: &KimiSnapshot,
89    theme: &Theme,
90    opts: &RenderOpts,
91    now: DateTime<Utc>,
92) -> WaybarOutput {
93    let class = Class::from(severity(snap));
94    let format = opts
95        .format
96        .clone()
97        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
98    let values = build_placeholders(snap, now);
99    // User formats are Pango markup after Waybar renders them. Escape API
100    // strings there, while retaining raw values for the default tooltip (which
101    // escapes exactly once at its markup insertion point).
102    let mut pango_values = values.clone();
103    for key in ["plan", "kimi_plan"] {
104        if let Some(value) = pango_values.get_mut(key) {
105            *value = escape(value);
106        }
107    }
108
109    let mut text = substitute(&format, &pango_values);
110    if outcome.stale {
111        text.push_str(" ⏸");
112    }
113
114    let wrapper_color = severity_color(severity(snap), theme).to_string();
115    let icon_prefix = match opts.icon.as_deref() {
116        Some(ic) if !ic.is_empty() => format!("{ic} "),
117        _ => String::new(),
118    };
119    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
120
121    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
122        substitute(fmt, &pango_values)
123    } else {
124        render_tooltip(outcome, snap, theme, now)
125    };
126
127    WaybarOutput {
128        text: bar_text,
129        tooltip,
130        class,
131    }
132}
133
134fn render_tooltip(
135    outcome: &VendorOutcome,
136    snap: &KimiSnapshot,
137    theme: &Theme,
138    now: DateTime<Utc>,
139) -> String {
140    let blue = &theme.blue;
141    let dim = &theme.dim;
142    let fg = &theme.fg;
143
144    let weekly_pct = snap.weekly_pct();
145    let weekly_color = severity_color(severity_for(weekly_pct), theme);
146    let window_pct = snap.window_pct();
147    let window_color = severity_color(severity_for(window_pct), theme);
148
149    let mut lines: Vec<TooltipLine> = Vec::new();
150    lines.push(TooltipLine::Center(format!(
151        "<span font_weight='bold' foreground='{blue}'>Kimi</span>"
152    )));
153    lines.push(TooltipLine::Sep);
154    lines.push(TooltipLine::Body("".into()));
155
156    let plan = snap.plan.as_deref().unwrap_or("Kimi");
157    lines.push(TooltipLine::Body(format!(
158        " <span foreground='{fg}'>  󰣖  Plan</span>"
159    )));
160    lines.push(TooltipLine::Body(format!(
161        "   <span font_weight='bold' foreground='{weekly_color}'>{}</span>",
162        escape(plan)
163    )));
164
165    lines.push(TooltipLine::Body("".into()));
166    lines.push(TooltipLine::Body(format!(
167        " <span foreground='{fg}'>  󰅄  Weekly quota</span>"
168    )));
169    lines.push(TooltipLine::Body(format!(
170        "   <span font_weight='bold' foreground='{weekly_color}'>{used} / {limit}</span>  ({pct}%)",
171        used = snap.weekly_used,
172        limit = snap.weekly_limit,
173        pct = weekly_pct
174    )));
175    lines.push(TooltipLine::Body(format!(
176        " <span foreground='{dim}'>     {remaining} remaining · reset {reset}</span>",
177        remaining = snap.weekly_remaining,
178        reset = escape(&countdown::format(snap.weekly_reset_at, now))
179    )));
180
181    if snap.window_limit > 0 {
182        lines.push(TooltipLine::Body("".into()));
183        lines.push(TooltipLine::Body(format!(
184            " <span foreground='{fg}'>  󰅁  Rolling window</span>"
185        )));
186        lines.push(TooltipLine::Body(format!(
187            "   <span font_weight='bold' foreground='{window_color}'>{used} / {limit}</span>  ({pct}%)",
188            used = snap.window_used,
189            limit = snap.window_limit,
190            pct = window_pct
191        )));
192        lines.push(TooltipLine::Body(format!(
193            " <span foreground='{dim}'>     {remaining} remaining · reset {reset}</span>",
194            remaining = snap.window_remaining,
195            reset = escape(&countdown::format(snap.window_reset_at, now))
196        )));
197    }
198
199    if let Some((code, msg)) = outcome.last_error.as_ref() {
200        let (label, icon, ecolor) = match warning_kind(*code, msg) {
201            WarningKind::SchemaDrift => {
202                ("Kimi API schema drift".to_string(), "󰅚", theme.red.as_str())
203            }
204            WarningKind::Other => ("Kimi error".to_string(), "󰅚", theme.red.as_str()),
205            WarningKind::Http(code) if code >= 500 => {
206                (format!("HTTP {code}"), "󰅚", theme.red.as_str())
207            }
208            WarningKind::Http(code) => (format!("HTTP {code}"), "󰀪", theme.orange.as_str()),
209        };
210        lines.push(TooltipLine::Body("".into()));
211        lines.push(TooltipLine::Sep);
212        lines.push(TooltipLine::Body(format!(
213            " <span foreground='{ecolor}'>  {icon}  {label}</span>"
214        )));
215        if msg != &label {
216            lines.push(TooltipLine::Body(format!(
217                "     <span foreground='{dim}'>{}</span>",
218                escape(msg)
219            )));
220        }
221    }
222
223    let updated = updated_at_hm(now, outcome.cache_age);
224    lines.push(TooltipLine::Body("".into()));
225    lines.push(TooltipLine::Sep);
226    lines.push(TooltipLine::Body(format!(
227        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
228    )));
229
230    render_bordered(&lines, theme)
231}
232
233impl From<FetchOutcome> for VendorOutcome {
234    fn from(o: FetchOutcome) -> Self {
235        Self {
236            snapshot: crate::usage::VendorSnapshot::Kimi(o.snapshot),
237            stale: o.stale,
238            last_error: o.last_error,
239            cache_age: o.cache_age,
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use chrono::TimeZone;
248
249    fn now() -> DateTime<Utc> {
250        Utc.with_ymd_and_hms(2026, 2, 7, 12, 0, 0).unwrap()
251    }
252
253    fn sample_snap() -> KimiSnapshot {
254        KimiSnapshot {
255            plan: Some("LEVEL_INTERMEDIATE".into()),
256            weekly_limit: 100,
257            weekly_used: 26,
258            weekly_remaining: 74,
259            weekly_reset_at: Some(now() + chrono::Duration::days(4)),
260            window_limit: 100,
261            window_used: 15,
262            window_remaining: 85,
263            window_reset_at: Some(now() + chrono::Duration::hours(2)),
264        }
265    }
266
267    fn sample_outcome(snap: KimiSnapshot) -> VendorOutcome {
268        VendorOutcome {
269            snapshot: crate::usage::VendorSnapshot::Kimi(snap),
270            stale: false,
271            last_error: None,
272            cache_age: Some(std::time::Duration::from_secs(10)),
273        }
274    }
275
276    fn opts() -> RenderOpts {
277        RenderOpts {
278            format: None,
279            tooltip_format: None,
280            icon: None,
281            pace_tolerance: 5,
282            format_pace_color: false,
283            tooltip_pace_pts: false,
284        }
285    }
286
287    #[test]
288    fn default_render_has_exactly_one_percent() {
289        let snap = sample_snap();
290        let outcome = sample_outcome(snap.clone());
291        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
292        // "26%" should appear exactly once and there must be no double percent.
293        assert!(out.text.contains("26%"), "text: {}", out.text);
294        assert!(
295            !out.text.contains("%%"),
296            "double percent in text: {}",
297            out.text
298        );
299        assert_eq!(out.text.matches('%').count(), 1, "text: {}", out.text);
300    }
301
302    #[test]
303    fn pct_placeholders_are_bare_integers() {
304        let snap = sample_snap();
305        let values = build_placeholders(&snap, now());
306        assert_eq!(values["kimi_weekly_pct"], "26");
307        assert_eq!(values["weekly_pct"], "26");
308        assert_eq!(values["kimi_window_pct"], "15");
309        assert_eq!(values["session_pct"], "15");
310    }
311
312    #[test]
313    fn severity_worst_of_windows() {
314        let mut snap = sample_snap();
315        snap.weekly_used = 10;
316        snap.weekly_remaining = 90;
317        snap.window_used = 95;
318        snap.window_remaining = 5;
319        // 95% window should drive severity to Critical even though weekly is Low.
320        assert_eq!(severity(&snap), PaceSeverity::Critical);
321    }
322
323    #[test]
324    fn zero_limits_are_low() {
325        let snap = KimiSnapshot {
326            weekly_limit: 0,
327            weekly_used: 0,
328            weekly_remaining: 0,
329            window_limit: 0,
330            window_used: 0,
331            window_remaining: 0,
332            ..sample_snap()
333        };
334        assert_eq!(severity(&snap), PaceSeverity::Low);
335    }
336
337    #[test]
338    fn missing_window_omitted_from_tooltip() {
339        let mut snap = sample_snap();
340        snap.window_limit = 0;
341        let outcome = sample_outcome(snap.clone());
342        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
343        assert!(out.tooltip.contains("Weekly quota"));
344        assert!(!out.tooltip.contains("Rolling window"));
345    }
346
347    #[test]
348    fn custom_tooltip_format_substitutes_exactly() {
349        let snap = sample_snap();
350        let outcome = sample_outcome(snap.clone());
351        let mut o = opts();
352        o.tooltip_format = Some("W:{kimi_weekly_pct} R:{kimi_window_pct}".into());
353        let out = render(&outcome, &snap, &Theme::default(), &o, now());
354        assert_eq!(out.tooltip, "W:26 R:15");
355    }
356
357    #[test]
358    fn plan_is_pango_escaped() {
359        let mut snap = sample_snap();
360        snap.plan = Some("A&B <beta>".into());
361        let outcome = sample_outcome(snap.clone());
362        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
363        assert!(
364            out.tooltip.contains("A&amp;B &lt;beta&gt;"),
365            "tooltip: {}",
366            out.tooltip
367        );
368    }
369
370    #[test]
371    fn custom_plan_placeholder_is_pango_escaped_once() {
372        let mut snap = sample_snap();
373        snap.plan = Some("A&B <beta>".into());
374        let outcome = sample_outcome(snap.clone());
375        let mut o = opts();
376        o.tooltip_format = Some("{kimi_plan}".into());
377        let out = render(&outcome, &snap, &Theme::default(), &o, now());
378        assert_eq!(out.tooltip, "A&amp;B &lt;beta&gt;");
379    }
380
381    #[test]
382    fn schema_error_has_schema_label_not_http_422() {
383        let snap = sample_snap();
384        let mut outcome = sample_outcome(snap.clone());
385        outcome.stale = true;
386        outcome.last_error = Some((0, SCHEMA_DRIFT_MESSAGE.into()));
387        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
388        assert!(out.tooltip.contains("Kimi API schema drift"));
389        assert!(!out.tooltip.contains("HTTP 422"));
390        assert_eq!(out.tooltip.matches("Kimi API schema drift").count(), 1);
391    }
392
393    #[test]
394    fn generic_code_zero_error_is_not_labeled_schema_drift() {
395        let snap = sample_snap();
396        let mut outcome = sample_outcome(snap.clone());
397        outcome.stale = true;
398        outcome.last_error = Some((0, "cache lock unavailable".into()));
399        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
400        assert!(out.tooltip.contains("Kimi error"));
401        assert!(out.tooltip.contains("cache lock unavailable"));
402        assert!(!out.tooltip.contains("Kimi API schema drift"));
403    }
404
405    #[test]
406    fn warning_kind_uses_schema_marker_without_treating_code_zero_as_http() {
407        assert_eq!(
408            warning_kind(0, SCHEMA_DRIFT_MESSAGE),
409            WarningKind::SchemaDrift
410        );
411        assert_eq!(
412            warning_kind(0, "cache lock unavailable"),
413            WarningKind::Other
414        );
415        assert_eq!(warning_kind(503, "unavailable"), WarningKind::Http(503));
416    }
417
418    #[test]
419    fn fetch_outcome_conversion_preserves_metadata() {
420        let snap = sample_snap();
421        let fetch = FetchOutcome {
422            snapshot: snap.clone(),
423            stale: true,
424            last_error: Some((401, "bad".into())),
425            cache_age: Some(std::time::Duration::from_secs(42)),
426        };
427        let vendor: VendorOutcome = fetch.into();
428        assert!(matches!(
429            vendor.snapshot,
430            crate::usage::VendorSnapshot::Kimi(_)
431        ));
432        assert!(vendor.stale);
433        assert_eq!(vendor.last_error, Some((401, "bad".into())));
434        assert_eq!(vendor.cache_age, Some(std::time::Duration::from_secs(42)));
435    }
436
437    #[test]
438    fn tooltip_includes_plan_and_usage_and_countdowns() {
439        let snap = sample_snap();
440        let outcome = sample_outcome(snap.clone());
441        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
442        assert!(out.tooltip.contains("Kimi"));
443        assert!(out.tooltip.contains("LEVEL_INTERMEDIATE"));
444        assert!(out.tooltip.contains("Weekly quota"));
445        assert!(out.tooltip.contains("26 / 100"));
446        assert!(out.tooltip.contains("Rolling window"));
447        assert!(out.tooltip.contains("15 / 100"));
448        // Reset should be a countdown, not raw RFC3339.
449        assert!(!out.tooltip.contains("2026-02-11T17:32:50"));
450        assert!(!out.tooltip.contains("2026-02-07T12:32:50"));
451    }
452
453    #[test]
454    fn stale_appends_pause() {
455        let snap = sample_snap();
456        let mut outcome = sample_outcome(snap.clone());
457        outcome.stale = true;
458        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
459        assert!(out.text.contains("⏸"));
460    }
461
462    #[test]
463    fn placeholder_set_contains_all_keys() {
464        let snap = sample_snap();
465        let values = build_placeholders(&snap, now());
466        for key in [
467            "kimi_plan",
468            "kimi_weekly_pct",
469            "kimi_weekly_used",
470            "kimi_weekly_limit",
471            "kimi_weekly_remaining",
472            "kimi_weekly_reset",
473            "kimi_window_pct",
474            "kimi_window_used",
475            "kimi_window_limit",
476            "kimi_window_remaining",
477            "kimi_window_reset",
478            "plan",
479            "weekly_pct",
480            "session_pct",
481        ] {
482            assert!(values.contains_key(key), "missing placeholder {key}");
483        }
484    }
485}