Skip to main content

ai_usagebar/widget/
render.rs

1//! Pango-markup rendering for the Anthropic widget — both the bar text and
2//! the bordered tooltip.
3//!
4//! Closely mirrors claudebar:625-860. Pure functions over an immutable
5//! [`RenderInput`] so all of the visual logic is unit-testable without I/O.
6
7use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10
11use crate::anthropic::fetch::FetchOutcome;
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing;
15use crate::pango::{self, color_span, escape, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{self, Line};
18use crate::usage::{ExtraUsage, anthropic_severity};
19use crate::vendor::VendorId;
20use crate::waybar::{Class, WaybarOutput};
21
22/// Default format string when `--format` is omitted (claudebar:55).
23pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
24
25/// All inputs needed to render the widget — packaged so tests can construct
26/// it without any I/O.
27pub struct RenderInput<'a> {
28    pub outcome: &'a FetchOutcome,
29    pub theme: &'a Theme,
30    pub format: &'a str,
31    pub tooltip_format: Option<&'a str>,
32    pub icon: Option<&'a str>,
33    pub pace_tolerance: u32,
34    pub format_pace_color: bool,
35    pub tooltip_pace_pts: bool,
36    pub now: DateTime<Utc>,
37    /// What this account's live Claude Code sessions are doing, for the
38    /// tooltip (#356). `None`, or nothing working or waiting, = no line.
39    pub claude_sessions: Option<&'a crate::context::activity::SessionActivity>,
40}
41
42/// Compose the full Waybar output for an Anthropic snapshot.
43pub fn render_anthropic(input: &RenderInput) -> WaybarOutput {
44    let snap = &input.outcome.snapshot;
45    let class = Class::from(anthropic_severity(snap));
46    let bar_text = render_bar_text(input, class);
47    let tooltip = if let Some(fmt) = input.tooltip_format {
48        // Custom tooltip uses the same placeholder set as the bar.
49        let values = build_placeholders(input);
50        substitute(fmt, &values)
51    } else {
52        render_default_tooltip(input)
53    };
54
55    WaybarOutput {
56        text: bar_text,
57        tooltip,
58        class,
59    }
60}
61
62/// Build the bar-text string with all placeholders substituted and the
63/// surrounding `<span foreground='…'>` wrapper applied.
64fn render_bar_text(input: &RenderInput, class: Class) -> String {
65    let values = build_placeholders(input);
66    let mut text = substitute(input.format, &values);
67
68    // Append stale indicator (claudebar:687-690).
69    if input.outcome.stale {
70        text.push_str(" ⏸");
71    }
72
73    // Wrap in the global color or the neutral foreground (when individual
74    // pace placeholders supply their own color via --format-pace-color).
75    let wrapper_color = if input.format_pace_color && input.format.contains("_pace") {
76        input.theme.fg.clone()
77    } else {
78        bar_color_for(class, input.theme).to_string()
79    };
80    let icon_prefix = match input.icon {
81        Some(ic) if !ic.is_empty() => format!("{ic} "),
82        _ => String::new(),
83    };
84    color_span(&wrapper_color, &format!("{icon_prefix}{text}"))
85}
86
87fn bar_color_for(class: Class, theme: &Theme) -> &str {
88    match class {
89        Class::Low => &theme.green,
90        Class::Mid => &theme.yellow,
91        Class::High => &theme.orange,
92        Class::Critical => &theme.red,
93    }
94}
95
96/// Build the full placeholder map for an Anthropic snapshot.
97///
98/// Mirrors claudebar's "{...}" surface (claudebar:625-667). Per-window pacing
99/// is pre-computed once; bars are rendered both raw (for `{*_bar}`) and with
100/// elapsed-position markers in the tooltip when `--tooltip-pace-pts` is set.
101fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
102    let snap = &input.outcome.snapshot;
103    let theme = input.theme;
104
105    let session = pacing::calc(
106        snap.session.utilization_pct,
107        snap.session.resets_at,
108        input.now,
109        snap.session.window_duration,
110        input.pace_tolerance,
111    );
112    let weekly = pacing::calc(
113        snap.weekly.utilization_pct,
114        snap.weekly.resets_at,
115        input.now,
116        snap.weekly.window_duration,
117        input.pace_tolerance,
118    );
119    let sonnet_window = snap.sonnet.as_ref();
120    let sonnet = sonnet_window.map(|w| {
121        pacing::calc(
122            w.utilization_pct,
123            w.resets_at,
124            input.now,
125            w.window_duration,
126            input.pace_tolerance,
127        )
128    });
129
130    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
131    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
132    let sonnet_color =
133        sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
134    let extra_color = snap
135        .extra
136        .as_ref()
137        .map(|e| pango::severity_color(severity_for(e.percent()), theme));
138
139    let session_bar = pango::progress_bar(snap.session.utilization_pct, session_color, theme, None);
140    let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
141    let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
142        pango::progress_bar(w.utilization_pct, c, theme, None)
143    } else {
144        String::new()
145    };
146    let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
147        pango::progress_bar(e.percent(), c, theme, None)
148    } else {
149        String::new()
150    };
151
152    // Primary model-scoped weekly window (the common case is exactly one, e.g.
153    // "Fable"). The tooltip renders every entry of `snap.scoped`, but the
154    // desktop surfaces (macOS menu bar, GNOME, Windows tray) redraw from
155    // `--format` and have a single per-model row — so expose the first scoped
156    // window through flat `{scoped_*}` placeholders they can read. Empty /
157    // neutral when the account has no scoped window.
158    let scoped0 = snap.scoped.first();
159    let scoped0_pacing = scoped0.map(|s| {
160        pacing::calc(
161            s.window.utilization_pct,
162            s.window.resets_at,
163            input.now,
164            s.window.window_duration,
165            input.pace_tolerance,
166        )
167    });
168    let scoped0_bar = if let Some(s) = scoped0 {
169        let c = pango::severity_color(severity_for(s.window.utilization_pct), theme);
170        pango::progress_bar(s.window.utilization_pct, c, theme, None)
171    } else {
172        String::new()
173    };
174
175    let mut v = placeholders(vec![
176        ("icon", "󰚩".to_string()),
177        ("vendor_short", VendorId::Anthropic.short_name().to_string()),
178        ("plan", snap.plan.clone()),
179        ("session_pct", snap.session.utilization_pct.to_string()),
180        (
181            "session_reset",
182            countdown::format(snap.session.resets_at, input.now),
183        ),
184        ("session_elapsed", session.elapsed_pct.to_string()),
185        ("session_bar", session_bar.clone()),
186        ("weekly_pct", snap.weekly.utilization_pct.to_string()),
187        (
188            "weekly_reset",
189            countdown::format(snap.weekly.resets_at, input.now),
190        ),
191        ("weekly_elapsed", weekly.elapsed_pct.to_string()),
192        ("weekly_bar", weekly_bar.clone()),
193        (
194            "sonnet_pct",
195            sonnet_window
196                .map(|w| w.utilization_pct.to_string())
197                .unwrap_or_else(|| "0".into()),
198        ),
199        (
200            "sonnet_reset",
201            sonnet_window
202                .map(|w| countdown::format(w.resets_at, input.now))
203                .unwrap_or_else(|| "—".into()),
204        ),
205        (
206            "sonnet_elapsed",
207            sonnet
208                .as_ref()
209                .map(|s| s.elapsed_pct.to_string())
210                .unwrap_or_else(|| "0".into()),
211        ),
212        ("sonnet_bar", sonnet_bar.clone()),
213        // Model-scoped weekly window (first entry of `snap.scoped`, e.g. Fable).
214        (
215            "scoped_model",
216            scoped0.map(|s| s.label.clone()).unwrap_or_default(),
217        ),
218        (
219            "scoped_pct",
220            scoped0
221                .map(|s| s.window.utilization_pct.to_string())
222                .unwrap_or_else(|| "0".into()),
223        ),
224        (
225            "scoped_reset",
226            scoped0
227                .map(|s| countdown::format(s.window.resets_at, input.now))
228                .unwrap_or_else(|| "—".into()),
229        ),
230        (
231            "scoped_elapsed",
232            scoped0_pacing
233                .as_ref()
234                .map(|p| p.elapsed_pct.to_string())
235                .unwrap_or_else(|| "0".into()),
236        ),
237        ("scoped_bar", scoped0_bar.clone()),
238        (
239            "extra_spent",
240            snap.extra
241                .as_ref()
242                .map(ExtraUsage::fmt_spent)
243                .unwrap_or_default(),
244        ),
245        (
246            "extra_limit",
247            // "—" (not empty) for an uncapped plan: GNOME and the macOS menu
248            // bar hide the whole extra row when this field is empty, which
249            // would hide real spend — the exact symptom of #30.
250            snap.extra
251                .as_ref()
252                .map(|e| e.fmt_limit().unwrap_or_else(|| "—".into()))
253                .unwrap_or_default(),
254        ),
255        (
256            "extra_pct",
257            snap.extra
258                .as_ref()
259                .map(|e| e.percent().to_string())
260                .unwrap_or_else(|| "0".into()),
261        ),
262        ("extra_bar", extra_bar),
263        ("resets_available", snap.reset_credits.available.to_string()),
264        ("resets", crate::format::reset_credits(&snap.reset_credits)),
265    ]);
266
267    insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
268    insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
269    if let Some(sp) = sonnet.as_ref() {
270        insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
271    } else {
272        // Empty placeholders so `{sonnet_pace}` etc. don't render the literal
273        // brace text when sonnet is absent.
274        insert_pace(
275            &mut v,
276            "sonnet",
277            &pacing::Pacing::neutral(),
278            input.format_pace_color,
279            theme,
280        );
281    }
282    // Both sinks fed by this map (bar text and --tooltip-format) are Pango
283    // markup. The plan label comes from the credentials file and the scoped
284    // model name from the API, so escape them at the projection boundary.
285    // The default tooltip escapes the raw snapshot.
286    for key in ["plan", "scoped_model"] {
287        if let Some(value) = v.get_mut(key) {
288            *value = escape(value);
289        }
290    }
291    v
292}
293
294fn insert_pace(
295    map: &mut HashMap<&'static str, String>,
296    prefix: &'static str,
297    p: &pacing::Pacing,
298    pace_color: bool,
299    theme: &Theme,
300) {
301    let pace_glyph = p.ratio_pace.glyph();
302    let indicator_glyph = p.point_pace.glyph();
303    let delta = p.delta.to_string();
304    let abs_delta = p.delta.unsigned_abs().to_string();
305    let pct = &p.ratio_label;
306    let pts = &p.point_label;
307
308    let wrap = |s: &str| -> String {
309        if pace_color {
310            let sev = pacing::pace_severity(p.delta);
311            let color = pango::severity_color(sev, theme);
312            color_span(color, s)
313        } else {
314            s.to_string()
315        }
316    };
317
318    let keys: [(&'static str, String); 6] = match prefix {
319        "session" => [
320            ("session_pace", wrap(pace_glyph)),
321            ("session_pace_indicator", wrap(indicator_glyph)),
322            ("session_pace_pct", wrap(pct)),
323            ("session_pace_pts", wrap(pts)),
324            ("session_pace_delta", wrap(&delta)),
325            ("session_pace_abs_delta", wrap(&abs_delta)),
326        ],
327        "weekly" => [
328            ("weekly_pace", wrap(pace_glyph)),
329            ("weekly_pace_indicator", wrap(indicator_glyph)),
330            ("weekly_pace_pct", wrap(pct)),
331            ("weekly_pace_pts", wrap(pts)),
332            ("weekly_pace_delta", wrap(&delta)),
333            ("weekly_pace_abs_delta", wrap(&abs_delta)),
334        ],
335        "sonnet" => [
336            ("sonnet_pace", wrap(pace_glyph)),
337            ("sonnet_pace_indicator", wrap(indicator_glyph)),
338            ("sonnet_pace_pct", wrap(pct)),
339            ("sonnet_pace_pts", wrap(pts)),
340            ("sonnet_pace_delta", wrap(&delta)),
341            ("sonnet_pace_abs_delta", wrap(&abs_delta)),
342        ],
343        _ => return,
344    };
345    for (k, v) in keys {
346        map.insert(k, v);
347    }
348}
349
350/// The bordered Pango tooltip (claudebar:707-860).
351fn render_default_tooltip(input: &RenderInput) -> String {
352    let snap = &input.outcome.snapshot;
353    let theme = input.theme;
354    let blue = &theme.blue;
355    let dim = &theme.dim;
356    let fg = &theme.fg;
357
358    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
359    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
360
361    let session_pacing = pacing::calc(
362        snap.session.utilization_pct,
363        snap.session.resets_at,
364        input.now,
365        snap.session.window_duration,
366        input.pace_tolerance,
367    );
368    let weekly_pacing = pacing::calc(
369        snap.weekly.utilization_pct,
370        snap.weekly.resets_at,
371        input.now,
372        snap.weekly.window_duration,
373        input.pace_tolerance,
374    );
375
376    let session_bar = if input.tooltip_pace_pts {
377        pango::progress_bar(
378            snap.session.utilization_pct,
379            session_color,
380            theme,
381            Some(session_pacing.elapsed_pct),
382        )
383    } else {
384        pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
385    };
386    let weekly_bar = if input.tooltip_pace_pts {
387        pango::progress_bar(
388            snap.weekly.utilization_pct,
389            weekly_color,
390            theme,
391            Some(weekly_pacing.elapsed_pct),
392        )
393    } else {
394        pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
395    };
396
397    let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
398    let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
399
400    let mut lines: Vec<Line> = Vec::new();
401    let _ = pango::severity_color; // silence unused-import warning if any
402    lines.push(Line::Center(format!(
403        "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
404        plan = escape(&snap.plan)
405    )));
406    lines.push(Line::Sep);
407    lines.push(Line::Body("".into()));
408
409    lines.push(Line::Body(format!(
410        " <span foreground='{fg}'>  󰔟  Session</span>"
411    )));
412    lines.push(Line::Body(format!(
413        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
414        bar = session_bar,
415        color = session_color,
416        pct = snap.session.utilization_pct,
417        glyph = session_pace_glyph
418    )));
419    lines.push(Line::Body(format!(
420        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
421        cd = escape(&countdown::format(snap.session.resets_at, input.now))
422    )));
423    lines.push(Line::Body("".into()));
424
425    lines.push(Line::Body(format!(
426        " <span foreground='{fg}'>  󰃰  Weekly</span>"
427    )));
428    lines.push(Line::Body(format!(
429        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
430        bar = weekly_bar,
431        color = weekly_color,
432        pct = snap.weekly.utilization_pct,
433        glyph = weekly_pace_glyph
434    )));
435    lines.push(Line::Body(format!(
436        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
437        cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
438    )));
439
440    if let Some(sw) = snap.sonnet.as_ref() {
441        let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
442        let sonnet_pacing = pacing::calc(
443            sw.utilization_pct,
444            sw.resets_at,
445            input.now,
446            sw.window_duration,
447            input.pace_tolerance,
448        );
449        let sonnet_bar = if input.tooltip_pace_pts {
450            pango::progress_bar(
451                sw.utilization_pct,
452                sonnet_color,
453                theme,
454                Some(sonnet_pacing.elapsed_pct),
455            )
456        } else {
457            pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
458        };
459        lines.push(Line::Body("".into()));
460        lines.push(Line::Body(format!(
461            " <span foreground='{fg}'>  󱤔  Sonnet only</span>"
462        )));
463        lines.push(Line::Body(format!(
464            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
465            bar = sonnet_bar,
466            color = sonnet_color,
467            pct = sw.utilization_pct
468        )));
469        lines.push(Line::Body(format!(
470            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
471            cd = escape(&countdown::format(sw.resets_at, input.now))
472        )));
473    }
474
475    for sw in &snap.scoped {
476        let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
477        let scoped_pacing = pacing::calc(
478            sw.window.utilization_pct,
479            sw.window.resets_at,
480            input.now,
481            sw.window.window_duration,
482            input.pace_tolerance,
483        );
484        let scoped_bar = if input.tooltip_pace_pts {
485            pango::progress_bar(
486                sw.window.utilization_pct,
487                scoped_color,
488                theme,
489                Some(scoped_pacing.elapsed_pct),
490            )
491        } else {
492            pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
493        };
494        lines.push(Line::Body("".into()));
495        lines.push(Line::Body(format!(
496            " <span foreground='{fg}'>  󰆧  {label} weekly</span>",
497            label = escape(&sw.label)
498        )));
499        lines.push(Line::Body(format!(
500            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
501            bar = scoped_bar,
502            color = scoped_color,
503            pct = sw.window.utilization_pct
504        )));
505        lines.push(Line::Body(format!(
506            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
507            cd = escape(&countdown::format(sw.window.resets_at, input.now))
508        )));
509    }
510
511    if let Some(extra) = snap.extra.as_ref() {
512        let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
513        let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
514        lines.push(Line::Body("".into()));
515        lines.push(Line::Sep);
516        lines.push(Line::Body(format!(
517            " <span foreground='{fg}'>  󰄑  Extra usage</span>"
518        )));
519        lines.push(Line::Body(format!(
520            "   {bar}  <span font_weight='bold' foreground='{color}'>{spent}</span>",
521            bar = extra_bar,
522            color = extra_color,
523            spent = escape(&extra.fmt_spent())
524        )));
525        let lim = match extra.fmt_limit() {
526            Some(l) => l,
527            // No usable `monthly_limit` in the payload (null — observed for
528            // uncapped plans — or absent). "none reported" states exactly
529            // that; inferring a plan tier from it would overclaim, and a
530            // $0.00 ceiling would be invented.
531            None => "none reported".into(),
532        };
533        lines.push(Line::Body(format!(
534            " <span foreground='{dim}'>  󰀓  Limit: {lim}</span>",
535            lim = escape(&lim)
536        )));
537        if let Some(note) = extra.status_note() {
538            lines.push(Line::Body(format!(
539                " <span foreground='{dim}'>  Status: {note}</span>",
540                note = escape(note)
541            )));
542        }
543    }
544
545    if let Some(cloud) = snap.cloud_credits.as_ref() {
546        // The same figures as the panel's "Cloud session credits" metric,
547        // in the tooltip's one-line style: what is left of what, when it
548        // expires, and whether the grant is locked. Everything here came
549        // off the wire (or is a constant), and the whole line still goes
550        // through `escape` at the sink.
551        let mut detail = format!(
552            "{} left of {}",
553            crate::format::usd(cloud.remaining.0 as f64 / 100.0),
554            crate::format::usd(cloud.limit.0 as f64 / 100.0)
555        );
556        if let Some(when) = cloud.expires_at {
557            detail.push_str(" · expires ");
558            detail.push_str(&crate::format::local_date_hm(when));
559        }
560        if cloud.locked {
561            detail.push_str(" · locked");
562        }
563        lines.push(Line::Body("".into()));
564        lines.push(Line::Sep);
565        lines.push(Line::Body(format!(
566            " <span foreground='{fg}'>  󰀓  Cloud session credits</span>"
567        )));
568        lines.push(Line::Body(format!(
569            " <span foreground='{dim}'>  {detail}</span>",
570            detail = escape(&detail)
571        )));
572    }
573
574    if let Some(usage) = snap.usage_credits.as_ref() {
575        lines.push(Line::Body("".into()));
576        lines.push(Line::Sep);
577        lines.push(Line::Body(format!(
578            " <span foreground='{fg}'>  󰀓  Usage credits</span>"
579        )));
580        // The currency code is response text, so the line is escaped at the
581        // sink like every other line here.
582        lines.push(Line::Body(format!(
583            " <span foreground='{dim}'>  balance: {balance}</span>",
584            balance = escape(&crate::usage::fmt_minor(
585                usage.balance.0,
586                usage.decimal_places,
587                usage.currency.as_deref(),
588            ))
589        )));
590        // `None` means "not reported": the row is absent, never an invented
591        // "off".
592        if let Some(auto_reload) = usage.auto_reload {
593            lines.push(Line::Body(format!(
594                " <span foreground='{dim}'>  auto-reload: {state}</span>",
595                state = if auto_reload { "on" } else { "off" }
596            )));
597        }
598    }
599
600    if snap.reset_credits.available > 0 {
601        lines.push(Line::Body("".into()));
602        lines.push(Line::Sep);
603        lines.push(Line::Body(format!(
604            " <span foreground='{fg}'>  󰁯  Reset credits</span>"
605        )));
606        for line in crate::format::reset_credit_lines(&snap.reset_credits, input.now) {
607            lines.push(Line::Body(format!(
608                " <span foreground='{dim}'>     {}</span>",
609                escape(&line)
610            )));
611        }
612    }
613
614    if let Some((code, msg)) = input.outcome.last_error.as_ref()
615        && *code != 0
616    {
617        let (icon, color) = if *code >= 500 {
618            ("󰅚", theme.red.as_str())
619        } else {
620            ("󰀪", theme.orange.as_str())
621        };
622        lines.push(Line::Body("".into()));
623        lines.push(Line::Sep);
624        lines.push(Line::Body(format!(
625            " <span foreground='{color}'>  {icon}  HTTP {code}</span>"
626        )));
627        for wrapped in wrap_words(&escape(msg), 35) {
628            lines.push(Line::Body(format!(
629                "     <span foreground='{dim}'>{wrapped}</span>"
630            )));
631        }
632    }
633
634    if let Some(text) = input.claude_sessions.and_then(|s| s.summary()) {
635        lines.push(Line::Body("".into()));
636        lines.push(Line::Body(format!(
637            " <span foreground='{dim}'>  {text}</span>"
638        )));
639    }
640
641    let updated = updated_at_hm(input.now, input.outcome.cache_age);
642    lines.push(Line::Body("".into()));
643    lines.push(Line::Sep);
644    lines.push(Line::Body(format!(
645        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
646    )));
647
648    tooltip::render_bordered(&lines, theme)
649}
650
651fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
652    if point_mode {
653        p.point_pace.glyph()
654    } else {
655        p.ratio_pace.glyph()
656    }
657}
658
659/// Greedy word-wrap to a target column. Used for the API-error message
660/// in the tooltip (claudebar:779-790).
661fn wrap_words(s: &str, width: usize) -> Vec<String> {
662    let mut out = Vec::new();
663    let mut buf = String::new();
664    for word in s.split_whitespace() {
665        if buf.is_empty() {
666            buf = word.into();
667        } else if buf.len() + 1 + word.len() <= width {
668            buf.push(' ');
669            buf.push_str(word);
670        } else {
671            out.push(std::mem::take(&mut buf));
672            buf = word.into();
673        }
674    }
675    if !buf.is_empty() {
676        out.push(buf);
677    }
678    out
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::anthropic::fetch::FetchOutcome;
685    use crate::usage::{
686        AnthropicSnapshot, Cents, CloudCredits, ExtraUsage, UsageCredits, UsageWindow,
687    };
688    use chrono::TimeZone;
689
690    fn now() -> DateTime<Utc> {
691        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
692    }
693
694    fn sample_outcome() -> FetchOutcome {
695        let session = UsageWindow {
696            utilization_pct: 62,
697            resets_at: Some(now() + chrono::Duration::minutes(90)),
698            window_duration: chrono::Duration::hours(5),
699        };
700        let weekly = UsageWindow {
701            utilization_pct: 27,
702            resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
703            window_duration: chrono::Duration::days(7),
704        };
705        let sonnet = UsageWindow {
706            utilization_pct: 4,
707            resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
708            window_duration: chrono::Duration::days(7),
709        };
710        let snap = AnthropicSnapshot {
711            plan: "Max 5x".into(),
712            session,
713            weekly,
714            sonnet: Some(sonnet),
715            scoped: vec![],
716            extra: Some(ExtraUsage {
717                limit: Some(Cents(5000)),
718                spent: Cents(250),
719                currency: None,
720                decimal_places: Some(2),
721                status: crate::usage::ExtraUsageStatus::Active,
722            }),
723            reset_credits: Default::default(),
724            cloud_credits: None,
725            usage_credits: None,
726        };
727        FetchOutcome {
728            snapshot: snap,
729            stale: false,
730            last_error: None,
731            cache_age: Some(std::time::Duration::from_secs(30)),
732        }
733    }
734
735    fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
736        RenderInput {
737            claude_sessions: None,
738            outcome,
739            theme,
740            format: DEFAULT_FORMAT,
741            tooltip_format: None,
742            icon: None,
743            pace_tolerance: 5,
744            format_pace_color: false,
745            tooltip_pace_pts: false,
746            now: now(),
747        }
748    }
749
750    #[test]
751    fn uncapped_extra_usage_renders_spend_with_dash_limit() {
752        // The #30 shape: real spend, `monthly_limit: null`. The spend must
753        // stay visible and `{extra_limit}` must be "—", NOT empty — GNOME and
754        // the macOS menu bar hide the whole extra row on an empty limit,
755        // which would re-hide the spend the fix is recovering.
756        let mut oc = sample_outcome();
757        if let Some(e) = oc.snapshot.extra.as_mut() {
758            e.limit = None;
759            e.spent = Cents(14157);
760        }
761        let theme = Theme::default();
762        let mut inp = input(&oc, &theme);
763        inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
764        let out = render_anthropic(&inp);
765        assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
766
767        // Default tooltip: spend shown, the missing limit stated as exactly
768        // that, and no fabricated "$0.00" anywhere near the extra block.
769        let inp2 = input(&oc, &theme);
770        let out2 = render_anthropic(&inp2);
771        assert!(out2.tooltip.contains("$141.57"));
772        assert!(out2.tooltip.contains("none reported"));
773        assert!(!out2.tooltip.contains("Limit: $0.00"));
774    }
775
776    #[test]
777    fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
778        // Non-vacuous currency pin: with BRL in the snapshot, formatting
779        // with a bare `$` again ("$141.57") must fail this test — that is
780        // the wrong-currency claim the wiring exists to prevent.
781        let mut oc = sample_outcome();
782        if let Some(e) = oc.snapshot.extra.as_mut() {
783            e.limit = None;
784            e.spent = Cents(14157);
785            e.currency = Some("BRL".into());
786        }
787        let theme = Theme::default();
788        let mut inp = input(&oc, &theme);
789        inp.format = "{extra_spent}";
790        let out = render_anthropic(&inp);
791        assert!(out.text.contains("R$141.57"), "got: {}", out.text);
792        assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
793
794        let inp2 = input(&oc, &theme);
795        let out2 = render_anthropic(&inp2);
796        assert!(
797            out2.tooltip.contains("R$141.57"),
798            "tooltip must carry the block's currency"
799        );
800    }
801
802    #[test]
803    fn provider_disabled_extra_usage_explains_why_it_stopped() {
804        let mut oc = sample_outcome();
805        oc.snapshot.extra.as_mut().unwrap().status = crate::usage::ExtraUsageStatus::OutOfCredits;
806        let theme = Theme::default();
807        let out = render_anthropic(&input(&oc, &theme));
808        assert!(
809            out.tooltip.contains("Status: out of credits"),
810            "{}",
811            out.tooltip
812        );
813        assert!(out.tooltip.contains("$2.50"), "{}", out.tooltip);
814    }
815
816    #[test]
817    fn default_format_renders_pct_and_reset() {
818        let oc = sample_outcome();
819        let theme = Theme::default();
820        let out = render_anthropic(&input(&oc, &theme));
821        // Bar text wraps in a span; content should include "62%" and the
822        // session countdown "1h 30m".
823        assert!(out.text.contains("62%"));
824        assert!(out.text.contains("1h 30m"));
825        assert_eq!(out.class, Class::Mid); // session=62 → mid
826    }
827
828    #[test]
829    fn stale_appends_pause_indicator() {
830        let mut oc = sample_outcome();
831        oc.stale = true;
832        let theme = Theme::default();
833        let out = render_anthropic(&input(&oc, &theme));
834        assert!(out.text.contains("⏸"));
835    }
836
837    #[test]
838    fn icon_prepends() {
839        let oc = sample_outcome();
840        let theme = Theme::default();
841        let mut inp = input(&oc, &theme);
842        inp.icon = Some("󰚩");
843        let out = render_anthropic(&inp);
844        assert!(out.text.contains("󰚩 "));
845    }
846
847    #[test]
848    fn custom_tooltip_format_uses_placeholders() {
849        let oc = sample_outcome();
850        let theme = Theme::default();
851        let mut inp = input(&oc, &theme);
852        inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
853        let out = render_anthropic(&inp);
854        assert_eq!(out.tooltip, "S:62 W:27");
855    }
856
857    #[test]
858    fn scoped_placeholders_expose_model_scoped_window() {
859        // The desktop surfaces read these `{scoped_*}` fields to show the
860        // model-scoped weekly bar (e.g. Fable) that only lives in `limits[]`.
861        let mut oc = sample_outcome();
862        oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
863            label: "Fable".into(),
864            window: UsageWindow {
865                utilization_pct: 84,
866                resets_at: Some(now() + chrono::Duration::days(5)),
867                window_duration: chrono::Duration::days(7),
868            },
869        }];
870        let theme = Theme::default();
871        let mut inp = input(&oc, &theme);
872        inp.tooltip_format = Some(
873            "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
874        );
875        let out = render_anthropic(&inp);
876        assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
877        for placeholder in [
878            "{scoped_model}",
879            "{scoped_pct}",
880            "{scoped_reset}",
881            "{scoped_elapsed}",
882            "{scoped_bar}",
883        ] {
884            assert!(!out.tooltip.contains(placeholder));
885        }
886    }
887
888    #[test]
889    fn scoped_placeholders_are_neutral_when_absent() {
890        let oc = sample_outcome(); // scoped: vec![]
891        let theme = Theme::default();
892        let mut inp = input(&oc, &theme);
893        inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
894        let out = render_anthropic(&inp);
895        assert_eq!(out.tooltip, "[] 0 —");
896    }
897
898    #[test]
899    fn plan_and_scoped_model_are_pango_escaped_in_custom_formats() {
900        // The plan label comes from the credentials file and the scoped model
901        // name from the API's `limits[].scope.model.display_name`; both feed
902        // the bar text and a custom --tooltip-format, which are Pango markup.
903        let mut oc = sample_outcome();
904        oc.snapshot.plan = "Max <5x> & Team".into();
905        oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
906            label: "Fable & <Opus>".into(),
907            window: UsageWindow {
908                utilization_pct: 84,
909                resets_at: Some(now() + chrono::Duration::days(5)),
910                window_duration: chrono::Duration::days(7),
911            },
912        }];
913        let theme = Theme::default();
914        let mut inp = input(&oc, &theme);
915        inp.format = "{plan} · {scoped_model}";
916        inp.tooltip_format = Some("{plan} · {scoped_model}");
917        let out = render_anthropic(&inp);
918        let escaped = "Max &lt;5x&gt; &amp; Team · Fable &amp; &lt;Opus&gt;";
919        assert!(out.text.contains(escaped), "{}", out.text);
920        assert!(!out.text.contains("<5x>"), "{}", out.text);
921        assert_eq!(out.tooltip, escaped);
922    }
923
924    #[test]
925    fn tooltip_lists_cloud_session_and_usage_credits() {
926        let mut oc = sample_outcome();
927        oc.snapshot.cloud_credits = Some(CloudCredits {
928            limit: Cents(25000),
929            remaining: Cents(25000),
930            expires_at: Some(now() + chrono::Duration::days(10)),
931            locked: false,
932        });
933        oc.snapshot.usage_credits = Some(UsageCredits {
934            balance: Cents(1500),
935            decimal_places: 2,
936            currency: Some("USD".into()),
937            auto_reload: Some(true),
938        });
939        let theme = Theme::default();
940        let out = render_anthropic(&input(&oc, &theme));
941        let t = &out.tooltip;
942        assert!(t.contains("Cloud session credits"), "{}", t);
943        assert!(
944            t.contains("$250.00 left of $250.00 · expires "),
945            "cloud line: {}",
946            t
947        );
948        assert!(t.contains("Usage credits"), "{}", t);
949        assert!(t.contains("balance: $15.00"), "{}", t);
950        assert!(t.contains("auto-reload: on"), "{}", t);
951        // The blocks sit after the Extra usage section and before the
952        // footer — the tooltip lists them under the account, in order.
953        let extra_at = t.find("Extra usage").unwrap();
954        let cloud_at = t.find("Cloud session credits").unwrap();
955        let usage_at = t.find("Usage credits").unwrap();
956        let updated_at = t.find("Updated").unwrap();
957        assert!(
958            extra_at < cloud_at && cloud_at < usage_at && usage_at < updated_at,
959            "{}",
960            t
961        );
962
963        // And a balance the response does not report stays out of the
964        // tooltip: no row, never a $0.00.
965        let quiet = sample_outcome();
966        let out = render_anthropic(&input(&quiet, &theme));
967        assert!(
968            !out.tooltip.contains("Cloud session credits"),
969            "{}",
970            out.tooltip
971        );
972        assert!(!out.tooltip.contains("Usage credits"), "{}", out.tooltip);
973        assert!(!out.tooltip.contains("balance: $0.00"), "{}", out.tooltip);
974    }
975
976    #[test]
977    fn tooltip_lock_line_escapes_a_hostile_locked_reason() {
978        // The `locked_reason` text is untrusted: it never leaves the parser
979        // as text — only the boolean lock does — so the tooltip shows the
980        // lock and not a single raw `<` from the reason.
981        let raw = r#"{
982            "five_hour": {"utilization": 12},
983            "seven_day": {"utilization": 34},
984            "iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
985                               "limit_dollars": 250, "remaining_dollars": 250,
986                               "locked_reason": "<b>org_migrating</b>"}
987        }"#;
988        let resp: crate::anthropic::types::UsageResponse = serde_json::from_str(raw).unwrap();
989        let mut oc = sample_outcome();
990        oc.snapshot = resp.into_snapshot("Max 5x".into());
991        let theme = Theme::default();
992        let out = render_anthropic(&input(&oc, &theme));
993        assert!(
994            out.tooltip.contains("Cloud session credits"),
995            "{}",
996            out.tooltip
997        );
998        assert!(out.tooltip.contains("locked"), "{}", out.tooltip);
999        assert!(!out.tooltip.contains("<b>"), "{}", out.tooltip);
1000        assert!(!out.tooltip.contains("org_migrating"), "{}", out.tooltip);
1001    }
1002
1003    #[test]
1004    fn default_tooltip_contains_all_sections() {
1005        let oc = sample_outcome();
1006        let theme = Theme::default();
1007        let out = render_anthropic(&input(&oc, &theme));
1008        assert!(out.tooltip.contains("Claude Max 5x"));
1009        assert!(out.tooltip.contains("Session"));
1010        assert!(out.tooltip.contains("Weekly"));
1011        assert!(out.tooltip.contains("Sonnet only"));
1012        assert!(out.tooltip.contains("Extra usage"));
1013        assert!(out.tooltip.contains("Updated"));
1014        assert!(out.tooltip.contains("62%"));
1015        assert!(out.tooltip.contains("27%"));
1016        assert!(out.tooltip.contains("$2.50"));
1017        assert!(out.tooltip.contains("$50.00"));
1018    }
1019
1020    #[test]
1021    fn tooltip_omits_sonnet_and_extra_when_absent() {
1022        let mut oc = sample_outcome();
1023        oc.snapshot.sonnet = None;
1024        oc.snapshot.extra = None;
1025        let theme = Theme::default();
1026        let out = render_anthropic(&input(&oc, &theme));
1027        assert!(!out.tooltip.contains("Sonnet only"));
1028        assert!(!out.tooltip.contains("Extra usage"));
1029        // Still contains the basics.
1030        assert!(out.tooltip.contains("Session"));
1031        assert!(out.tooltip.contains("Weekly"));
1032    }
1033
1034    #[test]
1035    fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
1036        let theme = Theme::default();
1037        let mut oc = sample_outcome();
1038
1039        // The overwhelmingly common case: no grant, and therefore no row.
1040        let out = render_anthropic(&input(&oc, &theme));
1041        assert!(!out.tooltip.contains("Reset credits"), "{}", out.tooltip);
1042
1043        oc.snapshot.reset_credits = crate::usage::ResetCredits {
1044            available: 1,
1045            credits: vec![crate::usage::ResetCredit {
1046                title: Some("Opus 5.5 launch reset".into()),
1047                expires_at: Some(now() + chrono::Duration::days(28)),
1048            }],
1049        };
1050        let out = render_anthropic(&input(&oc, &theme));
1051        assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
1052        assert!(
1053            out.tooltip.contains("Opus 5.5 launch reset"),
1054            "{}",
1055            out.tooltip
1056        );
1057        // The deadline is what the user acts on, so it travels with the row.
1058        // Asserted through the countdown rather than the rendered date: the
1059        // date is formatted in local time and this suite must not depend on
1060        // the machine's zone.
1061        assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
1062        assert!(out.tooltip.contains("28d"), "{}", out.tooltip);
1063
1064        let values = build_placeholders(&input(&oc, &theme));
1065        assert_eq!(values["resets_available"], "1");
1066        assert_eq!(values["resets"], "1 reset available");
1067    }
1068
1069    #[test]
1070    fn tooltip_includes_http_error_when_last_error_present() {
1071        let mut oc = sample_outcome();
1072        oc.last_error = Some((429, "rate limited".into()));
1073        let theme = Theme::default();
1074        let out = render_anthropic(&input(&oc, &theme));
1075        assert!(out.tooltip.contains("HTTP 429"));
1076        assert!(out.tooltip.contains("rate limited"));
1077    }
1078
1079    #[test]
1080    fn tooltip_omits_http_zero() {
1081        // claudebar treats code 0 (no HTTP response) as "don't render"
1082        // because it would be misleading.
1083        let mut oc = sample_outcome();
1084        oc.last_error = Some((0, "n/a".into()));
1085        let theme = Theme::default();
1086        let out = render_anthropic(&input(&oc, &theme));
1087        assert!(!out.tooltip.contains("HTTP 0"));
1088    }
1089
1090    #[test]
1091    fn worst_window_promotes_class_to_critical() {
1092        let mut oc = sample_outcome();
1093        oc.snapshot.weekly.utilization_pct = 95;
1094        let theme = Theme::default();
1095        let out = render_anthropic(&input(&oc, &theme));
1096        assert_eq!(out.class, Class::Critical);
1097    }
1098
1099    #[test]
1100    fn pace_color_mode_uses_neutral_wrapper() {
1101        let oc = sample_outcome();
1102        let theme = Theme::default();
1103        let mut inp = input(&oc, &theme);
1104        inp.format = "{session_pct}% {session_pace}";
1105        inp.format_pace_color = true;
1106        let out = render_anthropic(&inp);
1107        // Wrapper color should be the foreground (neutral), not severity.
1108        assert!(out.text.contains(&theme.fg));
1109    }
1110
1111    #[test]
1112    fn wrap_words_breaks_on_width_boundary() {
1113        let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
1114        // "aaa bbb" (7) fits; "ccc ddd" (7) fits next; "eee fff" (7) next.
1115        assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
1116    }
1117
1118    /// #356: the tooltip says what the account's live sessions are doing, and
1119    /// says nothing when none are working or waiting.
1120    #[test]
1121    fn tooltip_names_working_and_waiting_sessions_only_when_there_are_some() {
1122        use crate::context::activity::SessionActivity;
1123        let oc = sample_outcome();
1124        let theme = Theme::default();
1125
1126        let active = SessionActivity {
1127            working: 2,
1128            waiting: 1,
1129        };
1130        let mut inp = input(&oc, &theme);
1131        inp.claude_sessions = Some(&active);
1132        let out = render_anthropic(&inp);
1133        assert!(
1134            out.tooltip.contains("2 working · 1 waiting"),
1135            "{}",
1136            out.tooltip
1137        );
1138
1139        let idle = SessionActivity::default();
1140        let mut inp = input(&oc, &theme);
1141        inp.claude_sessions = Some(&idle);
1142        let quiet = render_anthropic(&inp);
1143        assert!(!quiet.tooltip.contains("working"), "{}", quiet.tooltip);
1144        assert!(!quiet.tooltip.contains("waiting"), "{}", quiet.tooltip);
1145    }
1146}