Skip to main content

ai_usagebar/moonshot/
vendor.rs

1//! Moonshot / Kimi renderer — bar text + bordered Pango tooltip. Balance-only,
2//! currency-aware (USD on `.ai`, CNY on `.cn`).
3
4use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{money, placeholders, substitute, updated_at_hm};
9use crate::pacing::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::MoonshotSnapshot;
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{km_balance}";
20
21pub fn build_placeholders(snap: &MoonshotSnapshot) -> HashMap<&'static str, String> {
22    placeholders(vec![
23        ("icon", "󰚩".to_string()),
24        ("vendor_short", VendorId::Moonshot.short_name().to_string()),
25        // Cross-vendor aliases — Kimi has no rate-limit windows here.
26        ("session_pct", "0".to_string()),
27        ("session_reset", "—".to_string()),
28        ("weekly_pct", "0".to_string()),
29        ("weekly_reset", "—".to_string()),
30        ("plan", "Kimi".to_string()),
31        ("km_balance", money(snap.available, &snap.currency)),
32        ("km_voucher", money(snap.voucher, &snap.currency)),
33        ("km_cash", money(snap.cash, &snap.currency)),
34        ("currency", snap.currency.clone()),
35    ])
36}
37
38/// `available_balance <= 0` blocks the inference API, so that's critical.
39/// Otherwise scale the low/high/mid thresholds by currency (CNY ≈ 7× USD),
40/// mirroring DeepSeek.
41pub fn severity(snap: &MoonshotSnapshot) -> PaceSeverity {
42    if snap.available <= 0.0 {
43        return PaceSeverity::Critical;
44    }
45    crate::pango::balance_severity(snap.available, &snap.currency)
46}
47
48pub fn render(
49    outcome: &VendorOutcome,
50    snap: &MoonshotSnapshot,
51    theme: &Theme,
52    opts: &RenderOpts,
53    now: DateTime<Utc>,
54) -> WaybarOutput {
55    let class = Class::from(severity(snap));
56    let format = opts
57        .format
58        .clone()
59        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
60    let values = build_placeholders(snap);
61
62    let mut text = substitute(&format, &values);
63    if outcome.stale {
64        text.push_str(" ⏸");
65    }
66
67    let wrapper_color = severity_color(severity(snap), theme).to_string();
68    let icon_prefix = match opts.icon.as_deref() {
69        Some(ic) if !ic.is_empty() => format!("{ic} "),
70        _ => String::new(),
71    };
72    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
73
74    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
75        substitute(fmt, &values)
76    } else {
77        render_tooltip(outcome, snap, theme, now)
78    };
79
80    WaybarOutput {
81        text: bar_text,
82        tooltip,
83        class,
84    }
85}
86
87fn render_tooltip(
88    outcome: &VendorOutcome,
89    snap: &MoonshotSnapshot,
90    theme: &Theme,
91    now: DateTime<Utc>,
92) -> String {
93    let blue = &theme.blue;
94    let dim = &theme.dim;
95    let fg = &theme.fg;
96    let color = severity_color(severity(snap), theme);
97
98    let mut lines: Vec<TooltipLine> = Vec::new();
99    lines.push(TooltipLine::Center(format!(
100        "<span font_weight='bold' foreground='{blue}'>Kimi (Moonshot)</span>"
101    )));
102    lines.push(TooltipLine::Sep);
103    lines.push(TooltipLine::Body("".into()));
104
105    lines.push(TooltipLine::Body(format!(
106        " <span foreground='{fg}'>  󰢗  Balance</span>"
107    )));
108    lines.push(TooltipLine::Body(format!(
109        "   <span font_weight='bold' foreground='{color}'>{bal}</span>",
110        bal = escape(&money(snap.available, &snap.currency))
111    )));
112    lines.push(TooltipLine::Body(format!(
113        " <span foreground='{dim}'>     cash {cash} · voucher {voucher}</span>",
114        cash = escape(&money(snap.cash, &snap.currency)),
115        voucher = escape(&money(snap.voucher, &snap.currency))
116    )));
117
118    if snap.available <= 0.0 {
119        lines.push(TooltipLine::Body("".into()));
120        lines.push(TooltipLine::Body(format!(
121            " <span foreground='{}'>  󰀪  out of credit — inference blocked</span>",
122            theme.orange.as_str()
123        )));
124    }
125
126    if let Some((code, msg)) = outcome.last_error.as_ref()
127        && *code != 0
128    {
129        let (icon, ecolor) = if *code >= 500 {
130            ("󰅚", theme.red.as_str())
131        } else {
132            ("󰀪", theme.orange.as_str())
133        };
134        lines.push(TooltipLine::Body("".into()));
135        lines.push(TooltipLine::Sep);
136        lines.push(TooltipLine::Body(format!(
137            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
138        )));
139        lines.push(TooltipLine::Body(format!(
140            "     <span foreground='{dim}'>{}</span>",
141            escape(msg)
142        )));
143    }
144
145    let updated = updated_at_hm(now, outcome.cache_age);
146    lines.push(TooltipLine::Body("".into()));
147    lines.push(TooltipLine::Sep);
148    lines.push(TooltipLine::Body(format!(
149        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
150    )));
151
152    render_bordered(&lines, theme)
153}
154
155impl From<FetchOutcome> for VendorOutcome {
156    fn from(o: FetchOutcome) -> Self {
157        o.map(crate::usage::VendorSnapshot::Moonshot)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::usage::MoonshotSnapshot;
165
166    fn sample_snap() -> MoonshotSnapshot {
167        MoonshotSnapshot {
168            available: 49.58,
169            voucher: 46.58,
170            cash: 3.0,
171            currency: "USD".into(),
172        }
173    }
174
175    fn sample_outcome(snap: MoonshotSnapshot) -> VendorOutcome {
176        VendorOutcome {
177            snapshot: crate::usage::VendorSnapshot::Moonshot(snap),
178            stale: false,
179            last_error: None,
180            cache_age: Some(std::time::Duration::from_secs(10)),
181        }
182    }
183
184    fn opts() -> RenderOpts {
185        RenderOpts {
186            format: None,
187            tooltip_format: None,
188            icon: None,
189            pace_tolerance: 5,
190            format_pace_color: false,
191            tooltip_pace_pts: false,
192        }
193    }
194
195    #[test]
196    fn default_render_shows_balance() {
197        let snap = sample_snap();
198        let out = render(
199            &sample_outcome(snap.clone()),
200            &snap,
201            &Theme::default(),
202            &opts(),
203            Utc::now(),
204        );
205        assert!(out.text.contains("$49.58"));
206    }
207
208    /// `cash_balance` is documented as negative (debt). It used to render as
209    /// "$-5.71" here while OpenRouter rendered the same debt as "-$5.71";
210    /// both now go through `format::money`.
211    #[test]
212    fn a_cash_debt_carries_its_sign_ahead_of_the_symbol() {
213        let mut snap = sample_snap();
214        snap.cash = -5.71;
215        let out = render(
216            &sample_outcome(snap.clone()),
217            &snap,
218            &Theme::default(),
219            &opts(),
220            Utc::now(),
221        );
222        assert!(out.tooltip.contains("cash -$5.71"), "{}", out.tooltip);
223        assert!(!out.tooltip.contains("$-5.71"), "{}", out.tooltip);
224
225        snap.currency = "CNY".into();
226        let out = render(
227            &sample_outcome(snap.clone()),
228            &snap,
229            &Theme::default(),
230            &opts(),
231            Utc::now(),
232        );
233        assert!(out.tooltip.contains("cash -¥5.71"), "{}", out.tooltip);
234    }
235
236    #[test]
237    fn tooltip_includes_cash_and_voucher() {
238        let snap = sample_snap();
239        let out = render(
240            &sample_outcome(snap.clone()),
241            &snap,
242            &Theme::default(),
243            &opts(),
244            Utc::now(),
245        );
246        assert!(out.tooltip.contains("cash $3.00"));
247        assert!(out.tooltip.contains("voucher $46.58"));
248    }
249
250    #[test]
251    fn cny_uses_yuan_symbol() {
252        let mut snap = sample_snap();
253        snap.currency = "CNY".into();
254        let out = render(
255            &sample_outcome(snap.clone()),
256            &snap,
257            &Theme::default(),
258            &opts(),
259            Utc::now(),
260        );
261        assert!(out.text.contains("¥49.58"));
262    }
263
264    #[test]
265    fn zero_balance_is_critical() {
266        let mut snap = sample_snap();
267        snap.available = 0.0;
268        assert_eq!(severity(&snap), PaceSeverity::Critical);
269    }
270}