1use 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
22pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
24
25pub 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 pub claude_sessions: Option<&'a crate::context::activity::SessionActivity>,
40}
41
42pub 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 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
62fn render_bar_text(input: &RenderInput, class: Class) -> String {
65 let values = build_placeholders(input);
66 let mut text = substitute(input.format, &values);
67
68 if input.outcome.stale {
70 text.push_str(" ⏸");
71 }
72
73 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
96fn 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 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 (
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 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 insert_pace(
275 &mut v,
276 "sonnet",
277 &pacing::Pacing::neutral(),
278 input.format_pace_color,
279 theme,
280 );
281 }
282 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
350fn 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; 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 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 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 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 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
659fn 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 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 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 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 assert!(out.text.contains("62%"));
824 assert!(out.text.contains("1h 30m"));
825 assert_eq!(out.class, Class::Mid); }
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 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(); 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 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 <5x> & Team · Fable & <Opus>";
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 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 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 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 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 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 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 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 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 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
1116 }
1117
1118 #[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}