1use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{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, 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", "kmi".to_string()),
25 ("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", format_money(snap.available, &snap.currency)),
32 ("km_voucher", format_money(snap.voucher, &snap.currency)),
33 ("km_cash", format_money(snap.cash, &snap.currency)),
34 ("currency", snap.currency.clone()),
35 ])
36}
37
38fn format_money(v: f64, currency: &str) -> String {
39 match currency {
40 "USD" => format!("${v:.2}"),
41 "CNY" => format!("¥{v:.2}"),
42 _ => format!("{v:.2} {currency}"),
43 }
44}
45
46pub fn severity(snap: &MoonshotSnapshot) -> PaceSeverity {
50 if snap.available <= 0.0 {
51 return PaceSeverity::Critical;
52 }
53 let (t_critical, t_high, t_mid) = match snap.currency.as_str() {
54 "CNY" => (7.0_f64, 35.0, 140.0),
55 _ => (1.0_f64, 5.0, 20.0),
56 };
57 if snap.available < t_critical {
58 PaceSeverity::Critical
59 } else if snap.available < t_high {
60 PaceSeverity::High
61 } else if snap.available < t_mid {
62 PaceSeverity::Mid
63 } else {
64 PaceSeverity::Low
65 }
66}
67
68pub fn render(
69 outcome: &VendorOutcome,
70 snap: &MoonshotSnapshot,
71 theme: &Theme,
72 opts: &RenderOpts,
73 now: DateTime<Utc>,
74) -> WaybarOutput {
75 let class = Class::from(severity(snap));
76 let format = opts
77 .format
78 .clone()
79 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
80 let values = build_placeholders(snap);
81
82 let mut text = substitute(&format, &values);
83 if outcome.stale {
84 text.push_str(" ⏸");
85 }
86
87 let wrapper_color = severity_color(severity(snap), theme).to_string();
88 let icon_prefix = match opts.icon.as_deref() {
89 Some(ic) if !ic.is_empty() => format!("{ic} "),
90 _ => String::new(),
91 };
92 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
93
94 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
95 substitute(fmt, &values)
96 } else {
97 render_tooltip(outcome, snap, theme, now)
98 };
99
100 WaybarOutput {
101 text: bar_text,
102 tooltip,
103 class,
104 }
105}
106
107fn render_tooltip(
108 outcome: &VendorOutcome,
109 snap: &MoonshotSnapshot,
110 theme: &Theme,
111 now: DateTime<Utc>,
112) -> String {
113 let blue = &theme.blue;
114 let dim = &theme.dim;
115 let fg = &theme.fg;
116 let color = severity_color(severity(snap), theme);
117
118 let mut lines: Vec<TooltipLine> = Vec::new();
119 lines.push(TooltipLine::Center(format!(
120 "<span font_weight='bold' foreground='{blue}'>Kimi (Moonshot)</span>"
121 )));
122 lines.push(TooltipLine::Sep);
123 lines.push(TooltipLine::Body("".into()));
124
125 lines.push(TooltipLine::Body(format!(
126 " <span foreground='{fg}'> Balance</span>"
127 )));
128 lines.push(TooltipLine::Body(format!(
129 " <span font_weight='bold' foreground='{color}'>{bal}</span>",
130 bal = escape(&format_money(snap.available, &snap.currency))
131 )));
132 lines.push(TooltipLine::Body(format!(
133 " <span foreground='{dim}'> cash {cash} · voucher {voucher}</span>",
134 cash = escape(&format_money(snap.cash, &snap.currency)),
135 voucher = escape(&format_money(snap.voucher, &snap.currency))
136 )));
137
138 if snap.available <= 0.0 {
139 lines.push(TooltipLine::Body("".into()));
140 lines.push(TooltipLine::Body(format!(
141 " <span foreground='{}'> out of credit — inference blocked</span>",
142 theme.orange.as_str()
143 )));
144 }
145
146 if let Some((code, msg)) = outcome.last_error.as_ref()
147 && *code != 0
148 {
149 let (icon, ecolor) = if *code >= 500 {
150 ("", theme.red.as_str())
151 } else {
152 ("", theme.orange.as_str())
153 };
154 lines.push(TooltipLine::Body("".into()));
155 lines.push(TooltipLine::Sep);
156 lines.push(TooltipLine::Body(format!(
157 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
158 )));
159 lines.push(TooltipLine::Body(format!(
160 " <span foreground='{dim}'>{}</span>",
161 escape(msg)
162 )));
163 }
164
165 let updated = updated_at_hm(now, outcome.cache_age);
166 lines.push(TooltipLine::Body("".into()));
167 lines.push(TooltipLine::Sep);
168 lines.push(TooltipLine::Body(format!(
169 " <span foreground='{dim}'> Updated {updated}</span>"
170 )));
171
172 render_bordered(&lines, theme)
173}
174
175impl From<FetchOutcome> for VendorOutcome {
176 fn from(o: FetchOutcome) -> Self {
177 Self {
178 snapshot: crate::usage::VendorSnapshot::Moonshot(o.snapshot),
179 stale: o.stale,
180 last_error: o.last_error,
181 cache_age: o.cache_age,
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::usage::MoonshotSnapshot;
190
191 fn sample_snap() -> MoonshotSnapshot {
192 MoonshotSnapshot {
193 available: 49.58,
194 voucher: 46.58,
195 cash: 3.0,
196 currency: "USD".into(),
197 }
198 }
199
200 fn sample_outcome(snap: MoonshotSnapshot) -> VendorOutcome {
201 VendorOutcome {
202 snapshot: crate::usage::VendorSnapshot::Moonshot(snap),
203 stale: false,
204 last_error: None,
205 cache_age: Some(std::time::Duration::from_secs(10)),
206 }
207 }
208
209 fn opts() -> RenderOpts {
210 RenderOpts {
211 format: None,
212 tooltip_format: None,
213 icon: None,
214 pace_tolerance: 5,
215 format_pace_color: false,
216 tooltip_pace_pts: false,
217 }
218 }
219
220 #[test]
221 fn default_render_shows_balance() {
222 let snap = sample_snap();
223 let out = render(
224 &sample_outcome(snap.clone()),
225 &snap,
226 &Theme::default(),
227 &opts(),
228 Utc::now(),
229 );
230 assert!(out.text.contains("$49.58"));
231 }
232
233 #[test]
234 fn tooltip_includes_cash_and_voucher() {
235 let snap = sample_snap();
236 let out = render(
237 &sample_outcome(snap.clone()),
238 &snap,
239 &Theme::default(),
240 &opts(),
241 Utc::now(),
242 );
243 assert!(out.tooltip.contains("cash $3.00"));
244 assert!(out.tooltip.contains("voucher $46.58"));
245 }
246
247 #[test]
248 fn cny_uses_yuan_symbol() {
249 let mut snap = sample_snap();
250 snap.currency = "CNY".into();
251 let out = render(
252 &sample_outcome(snap.clone()),
253 &snap,
254 &Theme::default(),
255 &opts(),
256 Utc::now(),
257 );
258 assert!(out.text.contains("¥49.58"));
259 }
260
261 #[test]
262 fn zero_balance_is_critical() {
263 let mut snap = sample_snap();
264 snap.available = 0.0;
265 assert_eq!(severity(&snap), PaceSeverity::Critical);
266 }
267}