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, WindowRow, push_window_with_row, render_bordered};
13use crate::usage::{KimiSnapshot, UsageWindow};
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}% · {kimi_weekly_reset}";
41
42/// Kimi reports the weekly quota's reset instant but never its length; the
43/// subscription bucket rolls every 7 days.
44const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
45/// The rolling bucket's length *is* advertised — 300 minutes — and only that
46/// spelling is accepted on the way in (`types::is_five_hour_window`).
47const ROLLING_WINDOW: chrono::Duration = chrono::Duration::hours(5);
48
49/// Project a quota pair onto the shared window shape the tooltip helper draws.
50fn window(pct: i32, resets_at: Option<DateTime<Utc>>, duration: chrono::Duration) -> UsageWindow {
51    UsageWindow {
52        utilization_pct: pct,
53        resets_at,
54        window_duration: duration,
55    }
56}
57
58pub fn build_placeholders(
59    snap: &KimiSnapshot,
60    now: DateTime<Utc>,
61) -> HashMap<&'static str, String> {
62    let plan = snap.plan.as_deref().unwrap_or("Kimi");
63    let weekly_pct = snap.weekly_pct();
64    let window_pct = snap.window_pct();
65    placeholders(vec![
66        ("icon", "󰚩".to_string()),
67        ("vendor_short", "kmi".to_string()),
68        // Cross-vendor aliases.
69        ("plan", plan.to_string()),
70        ("weekly_pct", weekly_pct.to_string()),
71        ("weekly_reset", countdown::format(snap.weekly_reset_at, now)),
72        ("session_pct", window_pct.to_string()),
73        (
74            "session_reset",
75            countdown::format(snap.window_reset_at, now),
76        ),
77        // Kimi-specific placeholders.
78        ("kimi_plan", plan.to_string()),
79        ("kimi_weekly_pct", weekly_pct.to_string()),
80        ("kimi_weekly_used", snap.weekly_used.to_string()),
81        ("kimi_weekly_limit", snap.weekly_limit.to_string()),
82        ("kimi_weekly_remaining", snap.weekly_remaining.to_string()),
83        (
84            "kimi_weekly_reset",
85            countdown::format(snap.weekly_reset_at, now),
86        ),
87        ("kimi_window_pct", window_pct.to_string()),
88        ("kimi_window_used", snap.window_used.to_string()),
89        ("kimi_window_limit", snap.window_limit.to_string()),
90        ("kimi_window_remaining", snap.window_remaining.to_string()),
91        (
92            "kimi_window_reset",
93            countdown::format(snap.window_reset_at, now),
94        ),
95    ])
96}
97
98pub fn severity(snap: &KimiSnapshot) -> PaceSeverity {
99    severity_for(snap.weekly_pct().max(snap.window_pct()))
100}
101
102pub fn render(
103    outcome: &VendorOutcome,
104    snap: &KimiSnapshot,
105    theme: &Theme,
106    opts: &RenderOpts,
107    now: DateTime<Utc>,
108) -> WaybarOutput {
109    let class = Class::from(severity(snap));
110    let format = opts
111        .format
112        .clone()
113        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
114    let values = build_placeholders(snap, now);
115    // User formats are Pango markup after Waybar renders them. Escape API
116    // strings there, while retaining raw values for the default tooltip (which
117    // escapes exactly once at its markup insertion point).
118    let mut pango_values = values.clone();
119    for key in ["plan", "kimi_plan"] {
120        if let Some(value) = pango_values.get_mut(key) {
121            *value = escape(value);
122        }
123    }
124
125    let mut text = substitute(&format, &pango_values);
126    if outcome.stale {
127        text.push_str(" ⏸");
128    }
129
130    let wrapper_color = severity_color(severity(snap), theme).to_string();
131    let icon_prefix = match opts.icon.as_deref() {
132        Some(ic) if !ic.is_empty() => format!("{ic} "),
133        _ => String::new(),
134    };
135    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
136
137    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
138        substitute(fmt, &pango_values)
139    } else {
140        render_tooltip(outcome, snap, theme, now)
141    };
142
143    WaybarOutput {
144        text: bar_text,
145        tooltip,
146        class,
147    }
148}
149
150fn render_tooltip(
151    outcome: &VendorOutcome,
152    snap: &KimiSnapshot,
153    theme: &Theme,
154    now: DateTime<Utc>,
155) -> String {
156    let blue = &theme.blue;
157    let dim = &theme.dim;
158    let fg = &theme.fg;
159
160    let weekly_pct = snap.weekly_pct();
161    let weekly_color = severity_color(severity_for(weekly_pct), theme);
162
163    let mut lines: Vec<TooltipLine> = Vec::new();
164    lines.push(TooltipLine::Center(format!(
165        "<span font_weight='bold' foreground='{blue}'>Kimi</span>"
166    )));
167    lines.push(TooltipLine::Sep);
168    lines.push(TooltipLine::Body("".into()));
169
170    let plan = snap.plan.as_deref().unwrap_or("Kimi");
171    lines.push(TooltipLine::Body(format!(
172        " <span foreground='{fg}'>  󰣖  Plan</span>"
173    )));
174    lines.push(TooltipLine::Body(format!(
175        "   <span font_weight='bold' foreground='{weekly_color}'>{}</span>",
176        escape(plan)
177    )));
178
179    // Kimi counts requests rather than reporting a percentage, which is why
180    // this block used to print bare `26 / 100  (26%)` pairs. The percentage is
181    // right there — project each quota onto a window and it draws like every
182    // other vendor, with the counts riding along on the reset line.
183    lines.push(TooltipLine::Body("".into()));
184    // `remaining` is the vendor's own number, not `limit - used`: `extract_block`
185    // keeps both when the wire reports both. Dropping it would lose the figure a
186    // request-counting quota is actually read for.
187    let weekly_detail = format!(
188        "{used} / {limit} · {remaining} left",
189        used = snap.weekly_used,
190        limit = snap.weekly_limit,
191        remaining = snap.weekly_remaining
192    );
193    push_window_with_row(
194        &mut lines,
195        "  󰅄  Weekly quota",
196        &window(weekly_pct, snap.weekly_reset_at, WEEKLY_WINDOW),
197        theme,
198        now,
199        WindowRow::default().with_detail(&weekly_detail),
200    );
201
202    if snap.window_limit > 0 {
203        lines.push(TooltipLine::Body("".into()));
204        let window_detail = format!(
205            "{used} / {limit} · {remaining} left",
206            used = snap.window_used,
207            limit = snap.window_limit,
208            remaining = snap.window_remaining
209        );
210        push_window_with_row(
211            &mut lines,
212            "  󰅁  Rolling window (5h)",
213            &window(snap.window_pct(), snap.window_reset_at, ROLLING_WINDOW),
214            theme,
215            now,
216            WindowRow::default().with_detail(&window_detail),
217        );
218    }
219
220    if let Some((code, msg)) = outcome.last_error.as_ref() {
221        let (label, icon, ecolor) = match warning_kind(*code, msg) {
222            WarningKind::SchemaDrift => {
223                ("Kimi API schema drift".to_string(), "󰅚", theme.red.as_str())
224            }
225            WarningKind::Other => ("Kimi error".to_string(), "󰅚", theme.red.as_str()),
226            WarningKind::Http(code) if code >= 500 => {
227                (format!("HTTP {code}"), "󰅚", theme.red.as_str())
228            }
229            WarningKind::Http(code) => (format!("HTTP {code}"), "󰀪", theme.orange.as_str()),
230        };
231        lines.push(TooltipLine::Body("".into()));
232        lines.push(TooltipLine::Sep);
233        lines.push(TooltipLine::Body(format!(
234            " <span foreground='{ecolor}'>  {icon}  {label}</span>"
235        )));
236        if msg != &label {
237            lines.push(TooltipLine::Body(format!(
238                "     <span foreground='{dim}'>{}</span>",
239                escape(msg)
240            )));
241        }
242    }
243
244    let updated = updated_at_hm(now, outcome.cache_age);
245    lines.push(TooltipLine::Body("".into()));
246    lines.push(TooltipLine::Sep);
247    lines.push(TooltipLine::Body(format!(
248        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
249    )));
250
251    render_bordered(&lines, theme)
252}
253
254impl From<FetchOutcome> for VendorOutcome {
255    fn from(o: FetchOutcome) -> Self {
256        Self {
257            snapshot: crate::usage::VendorSnapshot::Kimi(o.snapshot),
258            stale: o.stale,
259            last_error: o.last_error,
260            cache_age: o.cache_age,
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use chrono::TimeZone;
269
270    fn now() -> DateTime<Utc> {
271        Utc.with_ymd_and_hms(2026, 2, 7, 12, 0, 0).unwrap()
272    }
273
274    fn sample_snap() -> KimiSnapshot {
275        KimiSnapshot {
276            plan: Some("LEVEL_INTERMEDIATE".into()),
277            weekly_limit: 100,
278            weekly_used: 26,
279            weekly_remaining: 74,
280            weekly_reset_at: Some(now() + chrono::Duration::days(4)),
281            window_limit: 100,
282            window_used: 15,
283            window_remaining: 85,
284            window_reset_at: Some(now() + chrono::Duration::hours(2)),
285        }
286    }
287
288    fn sample_outcome(snap: KimiSnapshot) -> VendorOutcome {
289        VendorOutcome {
290            snapshot: crate::usage::VendorSnapshot::Kimi(snap),
291            stale: false,
292            last_error: None,
293            cache_age: Some(std::time::Duration::from_secs(10)),
294        }
295    }
296
297    fn opts() -> RenderOpts {
298        RenderOpts {
299            format: None,
300            tooltip_format: None,
301            icon: None,
302            pace_tolerance: 5,
303            format_pace_color: false,
304            tooltip_pace_pts: false,
305        }
306    }
307
308    #[test]
309    fn default_render_has_exactly_one_percent() {
310        let snap = sample_snap();
311        let outcome = sample_outcome(snap.clone());
312        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
313        // "26%" should appear exactly once and there must be no double percent.
314        assert!(out.text.contains("26%"), "text: {}", out.text);
315        assert!(
316            !out.text.contains("%%"),
317            "double percent in text: {}",
318            out.text
319        );
320        assert_eq!(out.text.matches('%').count(), 1, "text: {}", out.text);
321    }
322
323    #[test]
324    fn pct_placeholders_are_bare_integers() {
325        let snap = sample_snap();
326        let values = build_placeholders(&snap, now());
327        assert_eq!(values["kimi_weekly_pct"], "26");
328        assert_eq!(values["weekly_pct"], "26");
329        assert_eq!(values["kimi_window_pct"], "15");
330        assert_eq!(values["session_pct"], "15");
331    }
332
333    #[test]
334    fn severity_worst_of_windows() {
335        let mut snap = sample_snap();
336        snap.weekly_used = 10;
337        snap.weekly_remaining = 90;
338        snap.window_used = 95;
339        snap.window_remaining = 5;
340        // 95% window should drive severity to Critical even though weekly is Low.
341        assert_eq!(severity(&snap), PaceSeverity::Critical);
342    }
343
344    #[test]
345    fn zero_limits_are_low() {
346        let snap = KimiSnapshot {
347            weekly_limit: 0,
348            weekly_used: 0,
349            weekly_remaining: 0,
350            window_limit: 0,
351            window_used: 0,
352            window_remaining: 0,
353            ..sample_snap()
354        };
355        assert_eq!(severity(&snap), PaceSeverity::Low);
356    }
357
358    #[test]
359    fn missing_window_omitted_from_tooltip() {
360        let mut snap = sample_snap();
361        snap.window_limit = 0;
362        let outcome = sample_outcome(snap.clone());
363        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
364        assert!(out.tooltip.contains("Weekly quota"));
365        assert!(!out.tooltip.contains("Rolling window"));
366    }
367
368    #[test]
369    fn custom_tooltip_format_substitutes_exactly() {
370        let snap = sample_snap();
371        let outcome = sample_outcome(snap.clone());
372        let mut o = opts();
373        o.tooltip_format = Some("W:{kimi_weekly_pct} R:{kimi_window_pct}".into());
374        let out = render(&outcome, &snap, &Theme::default(), &o, now());
375        assert_eq!(out.tooltip, "W:26 R:15");
376    }
377
378    #[test]
379    fn plan_is_pango_escaped() {
380        let mut snap = sample_snap();
381        snap.plan = Some("A&B <beta>".into());
382        let outcome = sample_outcome(snap.clone());
383        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
384        assert!(
385            out.tooltip.contains("A&amp;B &lt;beta&gt;"),
386            "tooltip: {}",
387            out.tooltip
388        );
389    }
390
391    #[test]
392    fn custom_plan_placeholder_is_pango_escaped_once() {
393        let mut snap = sample_snap();
394        snap.plan = Some("A&B <beta>".into());
395        let outcome = sample_outcome(snap.clone());
396        let mut o = opts();
397        o.tooltip_format = Some("{kimi_plan}".into());
398        let out = render(&outcome, &snap, &Theme::default(), &o, now());
399        assert_eq!(out.tooltip, "A&amp;B &lt;beta&gt;");
400    }
401
402    #[test]
403    fn schema_error_has_schema_label_not_http_422() {
404        let snap = sample_snap();
405        let mut outcome = sample_outcome(snap.clone());
406        outcome.stale = true;
407        outcome.last_error = Some((0, SCHEMA_DRIFT_MESSAGE.into()));
408        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
409        assert!(out.tooltip.contains("Kimi API schema drift"));
410        assert!(!out.tooltip.contains("HTTP 422"));
411        assert_eq!(out.tooltip.matches("Kimi API schema drift").count(), 1);
412    }
413
414    #[test]
415    fn generic_code_zero_error_is_not_labeled_schema_drift() {
416        let snap = sample_snap();
417        let mut outcome = sample_outcome(snap.clone());
418        outcome.stale = true;
419        outcome.last_error = Some((0, "cache lock unavailable".into()));
420        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
421        assert!(out.tooltip.contains("Kimi error"));
422        assert!(out.tooltip.contains("cache lock unavailable"));
423        assert!(!out.tooltip.contains("Kimi API schema drift"));
424    }
425
426    #[test]
427    fn warning_kind_uses_schema_marker_without_treating_code_zero_as_http() {
428        assert_eq!(
429            warning_kind(0, SCHEMA_DRIFT_MESSAGE),
430            WarningKind::SchemaDrift
431        );
432        assert_eq!(
433            warning_kind(0, "cache lock unavailable"),
434            WarningKind::Other
435        );
436        assert_eq!(warning_kind(503, "unavailable"), WarningKind::Http(503));
437    }
438
439    #[test]
440    fn fetch_outcome_conversion_preserves_metadata() {
441        let snap = sample_snap();
442        let fetch = FetchOutcome {
443            snapshot: snap.clone(),
444            stale: true,
445            last_error: Some((401, "bad".into())),
446            cache_age: Some(std::time::Duration::from_secs(42)),
447        };
448        let vendor: VendorOutcome = fetch.into();
449        assert!(matches!(
450            vendor.snapshot,
451            crate::usage::VendorSnapshot::Kimi(_)
452        ));
453        assert!(vendor.stale);
454        assert_eq!(vendor.last_error, Some((401, "bad".into())));
455        assert_eq!(vendor.cache_age, Some(std::time::Duration::from_secs(42)));
456    }
457
458    #[test]
459    fn tooltip_includes_plan_and_usage_and_countdowns() {
460        let snap = sample_snap();
461        let outcome = sample_outcome(snap.clone());
462        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
463        assert!(out.tooltip.contains("Kimi"));
464        assert!(out.tooltip.contains("LEVEL_INTERMEDIATE"));
465        assert!(out.tooltip.contains("Weekly quota"));
466        assert!(out.tooltip.contains("26 / 100"));
467        assert!(out.tooltip.contains("Rolling window"));
468        assert!(out.tooltip.contains("15 / 100"));
469        // Reset should be a countdown, not raw RFC3339.
470        assert!(!out.tooltip.contains("2026-02-11T17:32:50"));
471        assert!(!out.tooltip.contains("2026-02-07T12:32:50"));
472    }
473
474    #[test]
475    fn stale_appends_pause() {
476        let snap = sample_snap();
477        let mut outcome = sample_outcome(snap.clone());
478        outcome.stale = true;
479        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
480        assert!(out.text.contains("⏸"));
481    }
482
483    #[test]
484    fn placeholder_set_contains_all_keys() {
485        let snap = sample_snap();
486        let values = build_placeholders(&snap, now());
487        for key in [
488            "kimi_plan",
489            "kimi_weekly_pct",
490            "kimi_weekly_used",
491            "kimi_weekly_limit",
492            "kimi_weekly_remaining",
493            "kimi_weekly_reset",
494            "kimi_window_pct",
495            "kimi_window_used",
496            "kimi_window_limit",
497            "kimi_window_remaining",
498            "kimi_window_reset",
499            "plan",
500            "weekly_pct",
501            "session_pct",
502        ] {
503            assert!(values.contains_key(key), "missing placeholder {key}");
504        }
505    }
506
507    /// The whole point of the rework: Kimi's quotas are percentages behind a
508    /// pair of counters, so they draw like every other vendor's window.
509    #[test]
510    fn tooltip_draws_a_progress_bar_for_both_quotas() {
511        let snap = sample_snap();
512        let outcome = sample_outcome(snap.clone());
513        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
514        assert_eq!(
515            out.tooltip.matches('░').count() + out.tooltip.matches('█').count(),
516            2 * crate::pango::BAR_LEN as usize,
517            "expected one full-width bar per quota: {}",
518            out.tooltip
519        );
520        assert!(out.tooltip.contains("Resets in"), "{}", out.tooltip);
521    }
522
523    /// The counters the old hand-rolled rows carried ride the reset line now —
524    /// the bar replaces the `26 / 100  (26%)` pair, it does not drop it.
525    #[test]
526    fn tooltip_keeps_the_raw_counts_on_the_reset_line() {
527        let snap = sample_snap();
528        let outcome = sample_outcome(snap.clone());
529        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
530        assert!(out.tooltip.contains("· 26 / 100"), "{}", out.tooltip);
531        assert!(out.tooltip.contains("· 15 / 100"), "{}", out.tooltip);
532    }
533
534    /// `remaining` is what a request-counting quota is read for, and it is the
535    /// vendor's own figure rather than `limit - used` — `extract_block` keeps
536    /// both when the wire reports both, so it cannot be recovered by
537    /// subtraction. The fixture makes them disagree to prove which one is
538    /// rendered.
539    #[test]
540    fn tooltip_keeps_the_vendors_own_remaining_count() {
541        let mut snap = sample_snap();
542        snap.weekly_remaining = 70; // not 100 - 26
543        snap.window_remaining = 80; // not 100 - 15
544        let outcome = sample_outcome(snap.clone());
545        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
546        assert!(
547            out.tooltip.contains("· 26 / 100 · 70 left"),
548            "{}",
549            out.tooltip
550        );
551        assert!(
552            out.tooltip.contains("· 15 / 100 · 80 left"),
553            "{}",
554            out.tooltip
555        );
556    }
557
558    /// Kimi opts out of pacing, like Codex; the rows must not sprout a glyph
559    /// on their own.
560    #[test]
561    fn tooltip_rows_carry_no_pace_glyph() {
562        let snap = sample_snap();
563        let outcome = sample_outcome(snap.clone());
564        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
565        for glyph in ['↑', '→', '↓'] {
566            assert!(!out.tooltip.contains(glyph), "{}", out.tooltip);
567        }
568    }
569
570    /// `{pct}% · {reset}` is what every other percentage vendor puts on the
571    /// bar; Kimi printed a bare `26%`.
572    #[test]
573    fn default_bar_text_pairs_the_percentage_with_its_reset() {
574        let snap = sample_snap();
575        let outcome = sample_outcome(snap.clone());
576        let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
577        assert!(out.text.contains("26% · 4d 0h"), "{}", out.text);
578    }
579}