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    v
283}
284
285fn insert_pace(
286    map: &mut HashMap<&'static str, String>,
287    prefix: &'static str,
288    p: &pacing::Pacing,
289    pace_color: bool,
290    theme: &Theme,
291) {
292    let pace_glyph = p.ratio_pace.glyph();
293    let indicator_glyph = p.point_pace.glyph();
294    let delta = p.delta.to_string();
295    let abs_delta = p.delta.unsigned_abs().to_string();
296    let pct = &p.ratio_label;
297    let pts = &p.point_label;
298
299    let wrap = |s: &str| -> String {
300        if pace_color {
301            let sev = pacing::pace_severity(p.delta);
302            let color = pango::severity_color(sev, theme);
303            color_span(color, s)
304        } else {
305            s.to_string()
306        }
307    };
308
309    let keys: [(&'static str, String); 6] = match prefix {
310        "session" => [
311            ("session_pace", wrap(pace_glyph)),
312            ("session_pace_indicator", wrap(indicator_glyph)),
313            ("session_pace_pct", wrap(pct)),
314            ("session_pace_pts", wrap(pts)),
315            ("session_pace_delta", wrap(&delta)),
316            ("session_pace_abs_delta", wrap(&abs_delta)),
317        ],
318        "weekly" => [
319            ("weekly_pace", wrap(pace_glyph)),
320            ("weekly_pace_indicator", wrap(indicator_glyph)),
321            ("weekly_pace_pct", wrap(pct)),
322            ("weekly_pace_pts", wrap(pts)),
323            ("weekly_pace_delta", wrap(&delta)),
324            ("weekly_pace_abs_delta", wrap(&abs_delta)),
325        ],
326        "sonnet" => [
327            ("sonnet_pace", wrap(pace_glyph)),
328            ("sonnet_pace_indicator", wrap(indicator_glyph)),
329            ("sonnet_pace_pct", wrap(pct)),
330            ("sonnet_pace_pts", wrap(pts)),
331            ("sonnet_pace_delta", wrap(&delta)),
332            ("sonnet_pace_abs_delta", wrap(&abs_delta)),
333        ],
334        _ => return,
335    };
336    for (k, v) in keys {
337        map.insert(k, v);
338    }
339}
340
341/// The bordered Pango tooltip (claudebar:707-860).
342fn render_default_tooltip(input: &RenderInput) -> String {
343    let snap = &input.outcome.snapshot;
344    let theme = input.theme;
345    let blue = &theme.blue;
346    let dim = &theme.dim;
347    let fg = &theme.fg;
348
349    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
350    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
351
352    let session_pacing = pacing::calc(
353        snap.session.utilization_pct,
354        snap.session.resets_at,
355        input.now,
356        snap.session.window_duration,
357        input.pace_tolerance,
358    );
359    let weekly_pacing = pacing::calc(
360        snap.weekly.utilization_pct,
361        snap.weekly.resets_at,
362        input.now,
363        snap.weekly.window_duration,
364        input.pace_tolerance,
365    );
366
367    let session_bar = if input.tooltip_pace_pts {
368        pango::progress_bar(
369            snap.session.utilization_pct,
370            session_color,
371            theme,
372            Some(session_pacing.elapsed_pct),
373        )
374    } else {
375        pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
376    };
377    let weekly_bar = if input.tooltip_pace_pts {
378        pango::progress_bar(
379            snap.weekly.utilization_pct,
380            weekly_color,
381            theme,
382            Some(weekly_pacing.elapsed_pct),
383        )
384    } else {
385        pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
386    };
387
388    let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
389    let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
390
391    let mut lines: Vec<Line> = Vec::new();
392    let _ = pango::severity_color; // silence unused-import warning if any
393    lines.push(Line::Center(format!(
394        "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
395        plan = escape(&snap.plan)
396    )));
397    lines.push(Line::Sep);
398    lines.push(Line::Body("".into()));
399
400    lines.push(Line::Body(format!(
401        " <span foreground='{fg}'>  󰔟  Session</span>"
402    )));
403    lines.push(Line::Body(format!(
404        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
405        bar = session_bar,
406        color = session_color,
407        pct = snap.session.utilization_pct,
408        glyph = session_pace_glyph
409    )));
410    lines.push(Line::Body(format!(
411        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
412        cd = escape(&countdown::format(snap.session.resets_at, input.now))
413    )));
414    lines.push(Line::Body("".into()));
415
416    lines.push(Line::Body(format!(
417        " <span foreground='{fg}'>  󰃰  Weekly</span>"
418    )));
419    lines.push(Line::Body(format!(
420        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
421        bar = weekly_bar,
422        color = weekly_color,
423        pct = snap.weekly.utilization_pct,
424        glyph = weekly_pace_glyph
425    )));
426    lines.push(Line::Body(format!(
427        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
428        cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
429    )));
430
431    if let Some(sw) = snap.sonnet.as_ref() {
432        let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
433        let sonnet_pacing = pacing::calc(
434            sw.utilization_pct,
435            sw.resets_at,
436            input.now,
437            sw.window_duration,
438            input.pace_tolerance,
439        );
440        let sonnet_bar = if input.tooltip_pace_pts {
441            pango::progress_bar(
442                sw.utilization_pct,
443                sonnet_color,
444                theme,
445                Some(sonnet_pacing.elapsed_pct),
446            )
447        } else {
448            pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
449        };
450        lines.push(Line::Body("".into()));
451        lines.push(Line::Body(format!(
452            " <span foreground='{fg}'>  󱤔  Sonnet only</span>"
453        )));
454        lines.push(Line::Body(format!(
455            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
456            bar = sonnet_bar,
457            color = sonnet_color,
458            pct = sw.utilization_pct
459        )));
460        lines.push(Line::Body(format!(
461            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
462            cd = escape(&countdown::format(sw.resets_at, input.now))
463        )));
464    }
465
466    for sw in &snap.scoped {
467        let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
468        let scoped_pacing = pacing::calc(
469            sw.window.utilization_pct,
470            sw.window.resets_at,
471            input.now,
472            sw.window.window_duration,
473            input.pace_tolerance,
474        );
475        let scoped_bar = if input.tooltip_pace_pts {
476            pango::progress_bar(
477                sw.window.utilization_pct,
478                scoped_color,
479                theme,
480                Some(scoped_pacing.elapsed_pct),
481            )
482        } else {
483            pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
484        };
485        lines.push(Line::Body("".into()));
486        lines.push(Line::Body(format!(
487            " <span foreground='{fg}'>  󰆧  {label} weekly</span>",
488            label = escape(&sw.label)
489        )));
490        lines.push(Line::Body(format!(
491            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
492            bar = scoped_bar,
493            color = scoped_color,
494            pct = sw.window.utilization_pct
495        )));
496        lines.push(Line::Body(format!(
497            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
498            cd = escape(&countdown::format(sw.window.resets_at, input.now))
499        )));
500    }
501
502    if let Some(extra) = snap.extra.as_ref() {
503        let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
504        let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
505        lines.push(Line::Body("".into()));
506        lines.push(Line::Sep);
507        lines.push(Line::Body(format!(
508            " <span foreground='{fg}'>  󰄑  Extra usage</span>"
509        )));
510        lines.push(Line::Body(format!(
511            "   {bar}  <span font_weight='bold' foreground='{color}'>{spent}</span>",
512            bar = extra_bar,
513            color = extra_color,
514            spent = escape(&extra.fmt_spent())
515        )));
516        let lim = match extra.fmt_limit() {
517            Some(l) => l,
518            // No usable `monthly_limit` in the payload (null — observed for
519            // uncapped plans — or absent). "none reported" states exactly
520            // that; inferring a plan tier from it would overclaim, and a
521            // $0.00 ceiling would be invented.
522            None => "none reported".into(),
523        };
524        lines.push(Line::Body(format!(
525            " <span foreground='{dim}'>  󰀓  Limit: {lim}</span>",
526            lim = escape(&lim)
527        )));
528    }
529
530    if snap.reset_credits.available > 0 {
531        lines.push(Line::Body("".into()));
532        lines.push(Line::Sep);
533        lines.push(Line::Body(format!(
534            " <span foreground='{fg}'>  󰁯  Reset credits</span>"
535        )));
536        for line in crate::format::reset_credit_lines(&snap.reset_credits, input.now) {
537            lines.push(Line::Body(format!(
538                " <span foreground='{dim}'>     {}</span>",
539                escape(&line)
540            )));
541        }
542    }
543
544    if let Some((code, msg)) = input.outcome.last_error.as_ref()
545        && *code != 0
546    {
547        let (icon, color) = if *code >= 500 {
548            ("󰅚", theme.red.as_str())
549        } else {
550            ("󰀪", theme.orange.as_str())
551        };
552        lines.push(Line::Body("".into()));
553        lines.push(Line::Sep);
554        lines.push(Line::Body(format!(
555            " <span foreground='{color}'>  {icon}  HTTP {code}</span>"
556        )));
557        for wrapped in wrap_words(&escape(msg), 35) {
558            lines.push(Line::Body(format!(
559                "     <span foreground='{dim}'>{wrapped}</span>"
560            )));
561        }
562    }
563
564    if let Some(text) = input.claude_sessions.and_then(|s| s.summary()) {
565        lines.push(Line::Body("".into()));
566        lines.push(Line::Body(format!(
567            " <span foreground='{dim}'>  {text}</span>"
568        )));
569    }
570
571    let updated = updated_at_hm(input.now, input.outcome.cache_age);
572    lines.push(Line::Body("".into()));
573    lines.push(Line::Sep);
574    lines.push(Line::Body(format!(
575        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
576    )));
577
578    tooltip::render_bordered(&lines, theme)
579}
580
581fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
582    if point_mode {
583        p.point_pace.glyph()
584    } else {
585        p.ratio_pace.glyph()
586    }
587}
588
589/// Greedy word-wrap to a target column. Used for the API-error message
590/// in the tooltip (claudebar:779-790).
591fn wrap_words(s: &str, width: usize) -> Vec<String> {
592    let mut out = Vec::new();
593    let mut buf = String::new();
594    for word in s.split_whitespace() {
595        if buf.is_empty() {
596            buf = word.into();
597        } else if buf.len() + 1 + word.len() <= width {
598            buf.push(' ');
599            buf.push_str(word);
600        } else {
601            out.push(std::mem::take(&mut buf));
602            buf = word.into();
603        }
604    }
605    if !buf.is_empty() {
606        out.push(buf);
607    }
608    out
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::anthropic::fetch::FetchOutcome;
615    use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
616    use chrono::TimeZone;
617
618    fn now() -> DateTime<Utc> {
619        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
620    }
621
622    fn sample_outcome() -> FetchOutcome {
623        let session = UsageWindow {
624            utilization_pct: 62,
625            resets_at: Some(now() + chrono::Duration::minutes(90)),
626            window_duration: chrono::Duration::hours(5),
627        };
628        let weekly = UsageWindow {
629            utilization_pct: 27,
630            resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
631            window_duration: chrono::Duration::days(7),
632        };
633        let sonnet = UsageWindow {
634            utilization_pct: 4,
635            resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
636            window_duration: chrono::Duration::days(7),
637        };
638        let snap = AnthropicSnapshot {
639            plan: "Max 5x".into(),
640            session,
641            weekly,
642            sonnet: Some(sonnet),
643            scoped: vec![],
644            extra: Some(ExtraUsage {
645                limit: Some(Cents(5000)),
646                spent: Cents(250),
647                currency: None,
648                decimal_places: Some(2),
649            }),
650            reset_credits: Default::default(),
651        };
652        FetchOutcome {
653            snapshot: snap,
654            stale: false,
655            last_error: None,
656            cache_age: Some(std::time::Duration::from_secs(30)),
657        }
658    }
659
660    fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
661        RenderInput {
662            claude_sessions: None,
663            outcome,
664            theme,
665            format: DEFAULT_FORMAT,
666            tooltip_format: None,
667            icon: None,
668            pace_tolerance: 5,
669            format_pace_color: false,
670            tooltip_pace_pts: false,
671            now: now(),
672        }
673    }
674
675    #[test]
676    fn uncapped_extra_usage_renders_spend_with_dash_limit() {
677        // The #30 shape: real spend, `monthly_limit: null`. The spend must
678        // stay visible and `{extra_limit}` must be "—", NOT empty — GNOME and
679        // the macOS menu bar hide the whole extra row on an empty limit,
680        // which would re-hide the spend the fix is recovering.
681        let mut oc = sample_outcome();
682        if let Some(e) = oc.snapshot.extra.as_mut() {
683            e.limit = None;
684            e.spent = Cents(14157);
685        }
686        let theme = Theme::default();
687        let mut inp = input(&oc, &theme);
688        inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
689        let out = render_anthropic(&inp);
690        assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
691
692        // Default tooltip: spend shown, the missing limit stated as exactly
693        // that, and no fabricated "$0.00" anywhere near the extra block.
694        let inp2 = input(&oc, &theme);
695        let out2 = render_anthropic(&inp2);
696        assert!(out2.tooltip.contains("$141.57"));
697        assert!(out2.tooltip.contains("none reported"));
698        assert!(!out2.tooltip.contains("Limit: $0.00"));
699    }
700
701    #[test]
702    fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
703        // Non-vacuous currency pin: with BRL in the snapshot, formatting
704        // through fmt_dollars again ("$141.57") must fail this test — that is
705        // the wrong-currency claim the wiring exists to prevent.
706        let mut oc = sample_outcome();
707        if let Some(e) = oc.snapshot.extra.as_mut() {
708            e.limit = None;
709            e.spent = Cents(14157);
710            e.currency = Some("BRL".into());
711        }
712        let theme = Theme::default();
713        let mut inp = input(&oc, &theme);
714        inp.format = "{extra_spent}";
715        let out = render_anthropic(&inp);
716        assert!(out.text.contains("R$141.57"), "got: {}", out.text);
717        assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
718
719        let inp2 = input(&oc, &theme);
720        let out2 = render_anthropic(&inp2);
721        assert!(
722            out2.tooltip.contains("R$141.57"),
723            "tooltip must carry the block's currency"
724        );
725    }
726
727    #[test]
728    fn default_format_renders_pct_and_reset() {
729        let oc = sample_outcome();
730        let theme = Theme::default();
731        let out = render_anthropic(&input(&oc, &theme));
732        // Bar text wraps in a span; content should include "62%" and the
733        // session countdown "1h 30m".
734        assert!(out.text.contains("62%"));
735        assert!(out.text.contains("1h 30m"));
736        assert_eq!(out.class, Class::Mid); // session=62 → mid
737    }
738
739    #[test]
740    fn stale_appends_pause_indicator() {
741        let mut oc = sample_outcome();
742        oc.stale = true;
743        let theme = Theme::default();
744        let out = render_anthropic(&input(&oc, &theme));
745        assert!(out.text.contains("⏸"));
746    }
747
748    #[test]
749    fn icon_prepends() {
750        let oc = sample_outcome();
751        let theme = Theme::default();
752        let mut inp = input(&oc, &theme);
753        inp.icon = Some("󰚩");
754        let out = render_anthropic(&inp);
755        assert!(out.text.contains("󰚩 "));
756    }
757
758    #[test]
759    fn custom_tooltip_format_uses_placeholders() {
760        let oc = sample_outcome();
761        let theme = Theme::default();
762        let mut inp = input(&oc, &theme);
763        inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
764        let out = render_anthropic(&inp);
765        assert_eq!(out.tooltip, "S:62 W:27");
766    }
767
768    #[test]
769    fn scoped_placeholders_expose_model_scoped_window() {
770        // The desktop surfaces read these `{scoped_*}` fields to show the
771        // model-scoped weekly bar (e.g. Fable) that only lives in `limits[]`.
772        let mut oc = sample_outcome();
773        oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
774            label: "Fable".into(),
775            window: UsageWindow {
776                utilization_pct: 84,
777                resets_at: Some(now() + chrono::Duration::days(5)),
778                window_duration: chrono::Duration::days(7),
779            },
780        }];
781        let theme = Theme::default();
782        let mut inp = input(&oc, &theme);
783        inp.tooltip_format = Some(
784            "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
785        );
786        let out = render_anthropic(&inp);
787        assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
788        for placeholder in [
789            "{scoped_model}",
790            "{scoped_pct}",
791            "{scoped_reset}",
792            "{scoped_elapsed}",
793            "{scoped_bar}",
794        ] {
795            assert!(!out.tooltip.contains(placeholder));
796        }
797    }
798
799    #[test]
800    fn scoped_placeholders_are_neutral_when_absent() {
801        let oc = sample_outcome(); // scoped: vec![]
802        let theme = Theme::default();
803        let mut inp = input(&oc, &theme);
804        inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
805        let out = render_anthropic(&inp);
806        assert_eq!(out.tooltip, "[] 0 —");
807    }
808
809    #[test]
810    fn default_tooltip_contains_all_sections() {
811        let oc = sample_outcome();
812        let theme = Theme::default();
813        let out = render_anthropic(&input(&oc, &theme));
814        assert!(out.tooltip.contains("Claude Max 5x"));
815        assert!(out.tooltip.contains("Session"));
816        assert!(out.tooltip.contains("Weekly"));
817        assert!(out.tooltip.contains("Sonnet only"));
818        assert!(out.tooltip.contains("Extra usage"));
819        assert!(out.tooltip.contains("Updated"));
820        assert!(out.tooltip.contains("62%"));
821        assert!(out.tooltip.contains("27%"));
822        assert!(out.tooltip.contains("$2.50"));
823        assert!(out.tooltip.contains("$50.00"));
824    }
825
826    #[test]
827    fn tooltip_omits_sonnet_and_extra_when_absent() {
828        let mut oc = sample_outcome();
829        oc.snapshot.sonnet = None;
830        oc.snapshot.extra = None;
831        let theme = Theme::default();
832        let out = render_anthropic(&input(&oc, &theme));
833        assert!(!out.tooltip.contains("Sonnet only"));
834        assert!(!out.tooltip.contains("Extra usage"));
835        // Still contains the basics.
836        assert!(out.tooltip.contains("Session"));
837        assert!(out.tooltip.contains("Weekly"));
838    }
839
840    #[test]
841    fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
842        let theme = Theme::default();
843        let mut oc = sample_outcome();
844
845        // The overwhelmingly common case: no grant, and therefore no row.
846        let out = render_anthropic(&input(&oc, &theme));
847        assert!(!out.tooltip.contains("Reset credits"), "{}", out.tooltip);
848
849        oc.snapshot.reset_credits = crate::usage::ResetCredits {
850            available: 1,
851            credits: vec![crate::usage::ResetCredit {
852                title: Some("Opus 5.5 launch reset".into()),
853                expires_at: Some(now() + chrono::Duration::days(28)),
854            }],
855        };
856        let out = render_anthropic(&input(&oc, &theme));
857        assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
858        assert!(
859            out.tooltip.contains("Opus 5.5 launch reset"),
860            "{}",
861            out.tooltip
862        );
863        // The deadline is what the user acts on, so it travels with the row.
864        // Asserted through the countdown rather than the rendered date: the
865        // date is formatted in local time and this suite must not depend on
866        // the machine's zone.
867        assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
868        assert!(out.tooltip.contains("28d"), "{}", out.tooltip);
869
870        let values = build_placeholders(&input(&oc, &theme));
871        assert_eq!(values["resets_available"], "1");
872        assert_eq!(values["resets"], "1 reset available");
873    }
874
875    #[test]
876    fn tooltip_includes_http_error_when_last_error_present() {
877        let mut oc = sample_outcome();
878        oc.last_error = Some((429, "rate limited".into()));
879        let theme = Theme::default();
880        let out = render_anthropic(&input(&oc, &theme));
881        assert!(out.tooltip.contains("HTTP 429"));
882        assert!(out.tooltip.contains("rate limited"));
883    }
884
885    #[test]
886    fn tooltip_omits_http_zero() {
887        // claudebar treats code 0 (no HTTP response) as "don't render"
888        // because it would be misleading.
889        let mut oc = sample_outcome();
890        oc.last_error = Some((0, "n/a".into()));
891        let theme = Theme::default();
892        let out = render_anthropic(&input(&oc, &theme));
893        assert!(!out.tooltip.contains("HTTP 0"));
894    }
895
896    #[test]
897    fn worst_window_promotes_class_to_critical() {
898        let mut oc = sample_outcome();
899        oc.snapshot.weekly.utilization_pct = 95;
900        let theme = Theme::default();
901        let out = render_anthropic(&input(&oc, &theme));
902        assert_eq!(out.class, Class::Critical);
903    }
904
905    #[test]
906    fn pace_color_mode_uses_neutral_wrapper() {
907        let oc = sample_outcome();
908        let theme = Theme::default();
909        let mut inp = input(&oc, &theme);
910        inp.format = "{session_pct}% {session_pace}";
911        inp.format_pace_color = true;
912        let out = render_anthropic(&inp);
913        // Wrapper color should be the foreground (neutral), not severity.
914        assert!(out.text.contains(&theme.fg));
915    }
916
917    #[test]
918    fn wrap_words_breaks_on_width_boundary() {
919        let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
920        // "aaa bbb" (7) fits; "ccc ddd" (7) fits next; "eee fff" (7) next.
921        assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
922    }
923
924    /// #356: the tooltip says what the account's live sessions are doing, and
925    /// says nothing when none are working or waiting.
926    #[test]
927    fn tooltip_names_working_and_waiting_sessions_only_when_there_are_some() {
928        use crate::context::activity::SessionActivity;
929        let oc = sample_outcome();
930        let theme = Theme::default();
931
932        let active = SessionActivity {
933            working: 2,
934            waiting: 1,
935        };
936        let mut inp = input(&oc, &theme);
937        inp.claude_sessions = Some(&active);
938        let out = render_anthropic(&inp);
939        assert!(
940            out.tooltip.contains("2 working · 1 waiting"),
941            "{}",
942            out.tooltip
943        );
944
945        let idle = SessionActivity::default();
946        let mut inp = input(&oc, &theme);
947        inp.claude_sessions = Some(&idle);
948        let quiet = render_anthropic(&inp);
949        assert!(!quiet.tooltip.contains("working"), "{}", quiet.tooltip);
950        assert!(!quiet.tooltip.contains("waiting"), "{}", quiet.tooltip);
951    }
952}