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