Skip to main content

ai_usagebar/ollama/
vendor.rs

1//! Ollama Cloud 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::{self, 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::{OllamaSnapshot, UsageWindow};
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{oll_session_pct}% · {oll_weekly_pct}%w";
20
21/// Build placeholders with the historical default pacing tolerance.
22pub fn build_placeholders(
23    snap: &OllamaSnapshot,
24    now: DateTime<Utc>,
25) -> HashMap<&'static str, String> {
26    build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
27}
28
29fn build_placeholders_with_tolerance(
30    snap: &OllamaSnapshot,
31    pace_tolerance: u32,
32    now: DateTime<Utc>,
33) -> HashMap<&'static str, String> {
34    let session_pct = snap
35        .session
36        .as_ref()
37        .map(|w| w.utilization_pct)
38        .unwrap_or(0);
39    let weekly_pct = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
40    let monthly_pct = snap
41        .monthly
42        .as_ref()
43        .map(|w| w.utilization_pct)
44        .unwrap_or(0);
45    let session = window_pacing(snap.session.as_ref(), pace_tolerance, now);
46    let weekly = window_pacing(snap.weekly.as_ref(), pace_tolerance, now);
47    let monthly = window_pacing(snap.monthly.as_ref(), pace_tolerance, now);
48    let cost = snap.activity_cost.clone().unwrap_or_else(|| "—".into());
49
50    placeholders(vec![
51        ("icon", "🦙".to_string()),
52        ("vendor_short", VendorId::Ollama.short_name().to_string()),
53        // Cross-vendor aliases for scroll-cycle friendly formats.
54        ("session_pct", session_pct.to_string()),
55        (
56            "session_reset",
57            countdown::format(window_reset(&snap.session), now),
58        ),
59        ("weekly_pct", weekly_pct.to_string()),
60        (
61            "weekly_reset",
62            countdown::format(window_reset(&snap.weekly), now),
63        ),
64        ("session_elapsed", session.elapsed.clone()),
65        ("weekly_elapsed", weekly.elapsed.clone()),
66        ("plan", snap.plan.clone()),
67        ("oll_plan", snap.plan.clone()),
68        ("oll_session_pct", session_pct.to_string()),
69        (
70            "oll_session_reset",
71            countdown::format(window_reset(&snap.session), now),
72        ),
73        ("oll_weekly_pct", weekly_pct.to_string()),
74        (
75            "oll_weekly_reset",
76            countdown::format(window_reset(&snap.weekly), now),
77        ),
78        ("oll_monthly_pct", monthly_pct.to_string()),
79        (
80            "oll_monthly_reset",
81            countdown::format(window_reset(&snap.monthly), now),
82        ),
83        ("oll_session_elapsed", session.elapsed),
84        ("oll_session_pace", session.ratio_pace),
85        ("oll_session_pace_indicator", session.point_pace),
86        ("oll_weekly_elapsed", weekly.elapsed),
87        ("oll_weekly_pace", weekly.ratio_pace),
88        ("oll_weekly_pace_indicator", weekly.point_pace),
89        ("oll_monthly_elapsed", monthly.elapsed),
90        ("oll_monthly_pace", monthly.ratio_pace),
91        ("oll_monthly_pace_indicator", monthly.point_pace),
92        ("oll_cost", cost),
93    ])
94}
95
96fn window_reset(w: &Option<UsageWindow>) -> Option<DateTime<Utc>> {
97    w.as_ref().and_then(|w| w.resets_at)
98}
99
100#[derive(Default)]
101struct WindowPacing {
102    elapsed: String,
103    ratio_pace: String,
104    point_pace: String,
105}
106
107fn window_pacing(w: Option<&UsageWindow>, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
108    let Some(w) = w else {
109        return WindowPacing::default();
110    };
111    let p = pacing::calc(
112        w.utilization_pct,
113        w.resets_at,
114        now,
115        w.window_duration,
116        pace_tolerance,
117    );
118    WindowPacing {
119        elapsed: p.elapsed_pct.to_string(),
120        ratio_pace: p.ratio_pace.glyph().to_string(),
121        point_pace: p.point_pace.glyph().to_string(),
122    }
123}
124
125pub fn severity(snap: &OllamaSnapshot) -> PaceSeverity {
126    // Worst of the three windows — session fills fastest and is the one
127    // that actually interrupts a chat mid-stream.
128    let session = snap
129        .session
130        .as_ref()
131        .map(|w| w.utilization_pct)
132        .unwrap_or(0);
133    let weekly = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
134    let monthly = snap
135        .monthly
136        .as_ref()
137        .map(|w| w.utilization_pct)
138        .unwrap_or(0);
139    severity_for(session.max(weekly).max(monthly))
140}
141
142pub fn render(
143    outcome: &VendorOutcome,
144    snap: &OllamaSnapshot,
145    theme: &Theme,
146    opts: &RenderOpts,
147    now: DateTime<Utc>,
148) -> WaybarOutput {
149    let class = Class::from(severity(snap));
150    let format = opts
151        .format
152        .clone()
153        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
154    let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
155
156    let mut text = substitute(&format, &values);
157    if outcome.stale {
158        text.push('…');
159    }
160
161    let wrapper_color = severity_color(severity(snap), theme).to_string();
162    let icon_prefix = match opts.icon.as_deref() {
163        Some(ic) if !ic.is_empty() => format!("{ic} "),
164        _ => String::new(),
165    };
166    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
167
168    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
169        substitute(fmt, &values)
170    } else {
171        render_tooltip(outcome, snap, theme, opts.pace_tolerance, now)
172    };
173
174    WaybarOutput {
175        text: bar_text,
176        tooltip,
177        class,
178    }
179}
180
181fn row(w: &UsageWindow) -> WindowRow {
182    // No reset timestamp on the wire, so no elapsed marker / pace glyph.
183    let _ = w;
184    WindowRow::default()
185}
186
187fn render_tooltip(
188    outcome: &VendorOutcome,
189    snap: &OllamaSnapshot,
190    theme: &Theme,
191    _pace_tolerance: u32,
192    now: DateTime<Utc>,
193) -> String {
194    let blue = &theme.blue;
195    let dim = &theme.dim;
196
197    let mut lines: Vec<TooltipLine> = Vec::new();
198    lines.push(TooltipLine::Center(format!(
199        "<span font_weight='bold' foreground='{blue}'>Ollama Cloud</span>"
200    )));
201    if !snap.plan.is_empty() {
202        lines.push(TooltipLine::Center(format!(
203            "<span foreground='{dim}'>{}</span>",
204            escape(&snap.plan)
205        )));
206    }
207    lines.push(TooltipLine::Sep);
208    lines.push(TooltipLine::Body("".into()));
209
210    if let Some(w) = snap.session.as_ref() {
211        push_window_with_row(&mut lines, "  Session (5h)", w, theme, now, row(w));
212        if !snap.session_models.is_empty() {
213            push_model_rows(&mut lines, &snap.session_models, dim);
214        }
215        lines.push(TooltipLine::Body("".into()));
216    }
217
218    if let Some(w) = snap.weekly.as_ref() {
219        push_window_with_row(&mut lines, "  Weekly", w, theme, now, row(w));
220        if !snap.weekly_models.is_empty() {
221            push_model_rows(&mut lines, &snap.weekly_models, dim);
222        }
223        lines.push(TooltipLine::Body("".into()));
224    }
225
226    if let Some(w) = snap.monthly.as_ref() {
227        push_window_with_row(&mut lines, "  Monthly", w, theme, now, row(w));
228        if !snap.monthly_models.is_empty() {
229            push_model_rows(&mut lines, &snap.monthly_models, dim);
230        }
231        lines.push(TooltipLine::Body("".into()));
232    }
233
234    if let Some(cost) = snap.activity_cost.as_deref() {
235        let period = snap.activity_period.as_deref().unwrap_or("activity");
236        lines.push(TooltipLine::Body(format!(
237            " <span foreground='{dim}'>  $  {period}</span>"
238        )));
239        lines.push(TooltipLine::Body(format!(
240            "   <span foreground='{dim}'>${}</span>",
241            escape(cost)
242        )));
243    }
244
245    if let Some((code, msg)) = outcome.last_error.as_ref()
246        && *code != 0
247    {
248        let (icon, ecolor) = if *code >= 500 {
249            ("!", theme.red.as_str())
250        } else {
251            ("!", theme.orange.as_str())
252        };
253        lines.push(TooltipLine::Body("".into()));
254        lines.push(TooltipLine::Sep);
255        lines.push(TooltipLine::Body(format!(
256            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
257        )));
258        lines.push(TooltipLine::Body(format!(
259            "     <span foreground='{dim}'>{}</span>",
260            escape(msg)
261        )));
262    }
263
264    let updated = updated_at_hm(now, outcome.cache_age);
265    lines.push(TooltipLine::Body("".into()));
266    lines.push(TooltipLine::Sep);
267    lines.push(TooltipLine::Body(format!(
268        " <span foreground='{dim}'>  Updated {updated}</span>"
269    )));
270
271    render_bordered(&lines, theme)
272}
273
274fn push_model_rows(
275    lines: &mut Vec<TooltipLine>,
276    models: &[crate::usage::OllamaModelUsage],
277    dim: &str,
278) {
279    // Cap the tooltip — a long week can have many models; show the top few.
280    for m in models.iter().take(6) {
281        lines.push(TooltipLine::Body(format!(
282            "     <span foreground='{dim}'>{} · {} req</span>",
283            escape(&m.name),
284            m.request_count
285        )));
286    }
287    if models.len() > 6 {
288        lines.push(TooltipLine::Body(format!(
289            "     <span foreground='{dim}'>… +{} more</span>",
290            models.len() - 6
291        )));
292    }
293}
294
295impl From<FetchOutcome> for VendorOutcome {
296    fn from(o: FetchOutcome) -> Self {
297        o.map(crate::usage::VendorSnapshot::Ollama)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::usage::{OllamaModelUsage, OllamaSnapshot, UsageWindow};
305
306    fn sample_snap() -> OllamaSnapshot {
307        OllamaSnapshot {
308            plan: "pro".into(),
309            session: Some(UsageWindow {
310                utilization_pct: 82,
311                resets_at: None,
312                window_duration: chrono::Duration::hours(5),
313            }),
314            weekly: Some(UsageWindow {
315                utilization_pct: 23,
316                resets_at: None,
317                window_duration: chrono::Duration::days(7),
318            }),
319            monthly: None,
320            session_models: vec![OllamaModelUsage {
321                name: "kimi-k3".into(),
322                request_count: 180,
323            }],
324            weekly_models: vec![
325                OllamaModelUsage {
326                    name: "kimi-k3".into(),
327                    request_count: 180,
328                },
329                OllamaModelUsage {
330                    name: "minimax-m3".into(),
331                    request_count: 554,
332                },
333            ],
334            monthly_models: vec![],
335            activity_cost: Some("0.00000".into()),
336            activity_period: Some("last_4_weeks".into()),
337        }
338    }
339
340    fn sample_outcome(snap: OllamaSnapshot) -> VendorOutcome {
341        VendorOutcome {
342            snapshot: crate::usage::VendorSnapshot::Ollama(snap),
343            stale: false,
344            last_error: None,
345            cache_age: Some(std::time::Duration::from_secs(10)),
346        }
347    }
348
349    fn opts() -> RenderOpts {
350        RenderOpts {
351            format: None,
352            tooltip_format: None,
353            icon: None,
354            pace_tolerance: pacing::DEFAULT_TOLERANCE,
355            format_pace_color: false,
356            tooltip_pace_pts: false,
357        }
358    }
359
360    #[test]
361    fn placeholders_expose_session_and_weekly() {
362        let now = Utc::now();
363        let values = build_placeholders(&sample_snap(), now);
364        assert_eq!(
365            values.get("oll_session_pct").map(String::as_str),
366            Some("82")
367        );
368        assert_eq!(values.get("oll_weekly_pct").map(String::as_str), Some("23"));
369        assert_eq!(values.get("oll_cost").map(String::as_str), Some("0.00000"));
370        assert_eq!(values.get("vendor_short").map(String::as_str), Some("oll"));
371        assert_eq!(values.get("plan").map(String::as_str), Some("pro"));
372    }
373
374    #[test]
375    fn severity_tracks_worst_window() {
376        assert_eq!(severity(&sample_snap()), severity_for(82));
377    }
378
379    #[test]
380    fn render_produces_nonempty_bar_and_tooltip() {
381        let snap = sample_snap();
382        let outcome = sample_outcome(snap.clone());
383        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
384        assert!(!out.text.is_empty());
385        assert!(out.tooltip.contains("Ollama Cloud"));
386        assert!(out.tooltip.contains("kimi-k3"));
387        assert!(out.tooltip.contains("Session") || out.tooltip.contains("82"));
388    }
389}