Skip to main content

ai_usagebar/opencode_go/
vendor.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5
6use crate::countdown;
7use crate::format::{placeholders, substitute, updated_at_hm};
8use crate::pacing::{self, PaceSeverity};
9use crate::pango::{color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
12use crate::usage::UsageWindow;
13use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
14use crate::waybar::{Class, WaybarOutput};
15
16use super::fetch::FetchOutcome;
17use super::types::{Usage, Window};
18
19pub const DEFAULT_FORMAT: &str = "{ocg_rolling_pct}% · {ocg_rolling_reset}";
20const DEFAULT_PLAN: &str = "OpenCode Go";
21const UNAVAILABLE: &str = "—";
22
23/// Window lengths for pacing math. The usage endpoint reports only `status`,
24/// `percent`, and `resetsAt` — never a duration — so these are constants with
25/// a recorded provenance:
26/// - `rolling` is the 5-hour limit (`packages/console/app/src/routes/zen/go/v1/usage.ts`
27///   `formatUsage` + `packages/console/app/src/routes/zen/util/handler.ts` and
28///   `i18n/en.ts` "5-hour usage limit reached", validated live via
29///   `GET https://opencode.ai/zen/go/v1/usage`).
30/// - `weekly` resets Monday 00:00 UTC (7 days, same response capture).
31///
32/// There is deliberately no monthly constant: the monthly window follows the
33/// subscription cycle (`getMonthlyBounds(now, timeSubscribed)` upstream —
34/// 28/29/31-day months depending on the subscriber), so any fixed length
35/// would pace against a wrong denominator and publish a wrong `window_secs`.
36/// The monthly reset is still shown; only pacing is omitted until the API
37/// reports real cycle bounds.
38pub const ROLLING_WINDOW: chrono::Duration = chrono::Duration::hours(5);
39pub const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
40
41impl From<FetchOutcome> for VendorOutcome {
42    fn from(outcome: FetchOutcome) -> Self {
43        outcome.map(crate::usage::VendorSnapshot::OpenCodeGo)
44    }
45}
46
47/// Build placeholders with the historical default pacing tolerance.
48///
49/// Keep this signature stable for library callers. Rendering uses the private
50/// tolerance-aware helper so `--pace-tolerance` still applies.
51pub fn build_placeholders(usage: &Usage, now: DateTime<Utc>) -> HashMap<&'static str, String> {
52    build_placeholders_with_plan(DEFAULT_PLAN, usage, now)
53}
54
55pub fn build_placeholders_with_plan(
56    plan: &str,
57    usage: &Usage,
58    now: DateTime<Utc>,
59) -> HashMap<&'static str, String> {
60    build_placeholders_with_plan_and_tolerance(plan, usage, pacing::DEFAULT_TOLERANCE, now)
61}
62
63fn build_placeholders_with_tolerance(
64    usage: &Usage,
65    pace_tolerance: u32,
66    now: DateTime<Utc>,
67) -> HashMap<&'static str, String> {
68    build_placeholders_with_plan_and_tolerance(DEFAULT_PLAN, usage, pace_tolerance, now)
69}
70
71fn build_placeholders_with_plan_and_tolerance(
72    plan: &str,
73    usage: &Usage,
74    pace_tolerance: u32,
75    now: DateTime<Utc>,
76) -> HashMap<&'static str, String> {
77    let plan = sanitize(plan);
78    let rolling = window_values(usage.rolling.as_ref(), now);
79    let weekly = window_values(usage.weekly.as_ref(), now);
80    let monthly = window_values(usage.monthly.as_ref(), now);
81    let rolling_pace = window_pacing(usage.rolling.as_ref(), ROLLING_WINDOW, pace_tolerance, now);
82    let weekly_pace = window_pacing(usage.weekly.as_ref(), WEEKLY_WINDOW, pace_tolerance, now);
83
84    placeholders([
85        (
86            "vendor_short",
87            VendorId::OpenCodeGo.short_name().to_string(),
88        ),
89        ("plan", plan.clone()),
90        ("ocg_plan", plan),
91        ("session_pct", rolling.percent.clone()),
92        ("session_reset", rolling.reset.clone()),
93        ("session_elapsed", rolling_pace.elapsed.clone()),
94        ("weekly_pct", weekly.percent.clone()),
95        ("weekly_reset", weekly.reset.clone()),
96        ("weekly_elapsed", weekly_pace.elapsed.clone()),
97        ("ocg_rolling_pct", rolling.percent),
98        ("ocg_rolling_reset", rolling.reset),
99        ("ocg_rolling_status", rolling.status),
100        ("ocg_rolling_elapsed", rolling_pace.elapsed),
101        ("ocg_rolling_pace", rolling_pace.ratio_pace),
102        ("ocg_rolling_pace_indicator", rolling_pace.point_pace),
103        ("ocg_weekly_pct", weekly.percent),
104        ("ocg_weekly_reset", weekly.reset),
105        ("ocg_weekly_status", weekly.status),
106        ("ocg_weekly_elapsed", weekly_pace.elapsed),
107        ("ocg_weekly_pace", weekly_pace.ratio_pace),
108        ("ocg_weekly_pace_indicator", weekly_pace.point_pace),
109        ("ocg_monthly_pct", monthly.percent),
110        ("ocg_monthly_reset", monthly.reset),
111        ("ocg_monthly_status", monthly.status),
112        // No monthly pacing: the cycle length is subscriber-dependent (see
113        // the `ROLLING_WINDOW` provenance note), so any elapsed/pace figure
114        // would pace against a guessed denominator. Empty strings, matching
115        // what an absent window yields on the Z.AI renderer.
116        ("ocg_monthly_elapsed", String::new()),
117        ("ocg_monthly_pace", String::new()),
118        ("ocg_monthly_pace_indicator", String::new()),
119    ])
120}
121
122/// Elapsed fraction and pace glyphs for one window, as ready placeholder
123/// values. Empty strings when the window is absent, mirroring the Z.AI
124/// renderer's convention; `pacing::calc` degrades to neutral 0/glyphs when
125/// the reset is unreported (which cannot happen for this vendor — `resetsAt`
126/// is required — but keeps the contract explicit).
127#[derive(Default)]
128struct WindowPacing {
129    elapsed: String,
130    ratio_pace: String,
131    point_pace: String,
132}
133
134fn window_pacing(
135    window: Option<&Window>,
136    duration: chrono::Duration,
137    pace_tolerance: u32,
138    now: DateTime<Utc>,
139) -> WindowPacing {
140    let Some(window) = window else {
141        return WindowPacing::default();
142    };
143    let p = pacing::calc(
144        utilization_pct(window),
145        Some(window.resets_at),
146        now,
147        duration,
148        pace_tolerance,
149    );
150    WindowPacing {
151        elapsed: p.elapsed_pct.to_string(),
152        ratio_pace: p.ratio_pace.glyph().to_string(),
153        point_pace: p.point_pace.glyph().to_string(),
154    }
155}
156
157/// Project an OpenCode Go window onto the shared shape the tooltip helper
158/// draws. Percentages arrive as floats; panels round them like every other
159/// vendor's integer-percent convention.
160fn as_usage_window(window: &Window, duration: chrono::Duration) -> UsageWindow {
161    UsageWindow {
162        utilization_pct: utilization_pct(window),
163        resets_at: Some(window.resets_at),
164        window_duration: duration,
165    }
166}
167
168fn utilization_pct(window: &Window) -> i32 {
169    window.percent.round().clamp(0.0, 100.0) as i32
170}
171
172#[derive(Debug)]
173struct WindowValues {
174    percent: String,
175    reset: String,
176    status: String,
177}
178
179fn window_values(window: Option<&Window>, now: DateTime<Utc>) -> WindowValues {
180    let Some(window) = window else {
181        return WindowValues {
182            percent: UNAVAILABLE.to_string(),
183            reset: UNAVAILABLE.to_string(),
184            status: UNAVAILABLE.to_string(),
185        };
186    };
187    WindowValues {
188        percent: window.percent.to_string(),
189        reset: countdown::format(Some(window.resets_at), now),
190        status: sanitize(&window.status),
191    }
192}
193
194fn sanitize(value: &str) -> String {
195    crate::display::sanitize_untrusted_field(value)
196}
197
198pub fn severity(usage: &Usage) -> PaceSeverity {
199    usage
200        .rolling
201        .iter()
202        .chain(usage.weekly.iter())
203        .chain(usage.monthly.iter())
204        .map(|window| window.percent as i32)
205        .max()
206        .map(severity_for)
207        .unwrap_or(PaceSeverity::Low)
208}
209
210/// Renderer shape matches the existing vendor adapters. `snap` remains local
211/// because the shared enum does not yet have an OpenCode-Go arm.
212pub fn render(
213    outcome: &VendorOutcome,
214    snap: &Usage,
215    theme: &Theme,
216    opts: &RenderOpts,
217    now: DateTime<Utc>,
218) -> WaybarOutput {
219    render_with_meta(
220        snap,
221        outcome.stale,
222        outcome.last_error.as_ref(),
223        outcome.cache_age,
224        theme,
225        opts,
226        now,
227    )
228}
229
230fn render_with_meta(
231    snap: &Usage,
232    stale: bool,
233    last_error: Option<&(u16, String)>,
234    cache_age: Option<Duration>,
235    theme: &Theme,
236    opts: &RenderOpts,
237    now: DateTime<Utc>,
238) -> WaybarOutput {
239    let sev = severity(snap);
240    let format = opts.format.as_deref().unwrap_or(DEFAULT_FORMAT);
241    let values = escaped_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
242    let mut text = substitute(format, &values);
243    if stale {
244        text.push_str(" ⏸");
245    }
246    let icon_prefix = match opts.icon.as_deref() {
247        Some(icon) if !icon.is_empty() => format!("{} ", escape(icon)),
248        _ => String::new(),
249    };
250    let bar_text = color_span(severity_color(sev, theme), &format!("{icon_prefix}{text}"));
251    let tooltip = opts
252        .tooltip_format
253        .as_deref()
254        .map(|format| substitute(format, &values))
255        .unwrap_or_else(|| render_tooltip(snap, stale, last_error, cache_age, theme, opts, now));
256
257    WaybarOutput {
258        text: bar_text,
259        tooltip,
260        class: Class::from(sev),
261    }
262}
263
264fn escaped_placeholders_with_tolerance(
265    usage: &Usage,
266    pace_tolerance: u32,
267    now: DateTime<Utc>,
268) -> HashMap<&'static str, String> {
269    let mut values = build_placeholders_with_tolerance(usage, pace_tolerance, now);
270    for key in [
271        "plan",
272        "ocg_plan",
273        "ocg_rolling_status",
274        "ocg_weekly_status",
275        "ocg_monthly_status",
276    ] {
277        if let Some(value) = values.get_mut(key) {
278            *value = escape(value);
279        }
280    }
281    values
282}
283
284fn render_tooltip(
285    snap: &Usage,
286    stale: bool,
287    last_error: Option<&(u16, String)>,
288    cache_age: Option<Duration>,
289    theme: &Theme,
290    opts: &RenderOpts,
291    now: DateTime<Utc>,
292) -> String {
293    let mut lines = vec![TooltipLine::Center(format!(
294        "<span font_weight='bold' foreground='{}'>{}</span>",
295        theme.blue,
296        escape(DEFAULT_PLAN)
297    ))];
298    lines.push(TooltipLine::Sep);
299    lines.push(TooltipLine::Body(String::new()));
300
301    let row =
302        |w: &UsageWindow| WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts);
303    let mut present = false;
304    for (label, window, duration) in [
305        ("  Rolling (5h)", snap.rolling.as_ref(), ROLLING_WINDOW),
306        ("  Weekly (7d)", snap.weekly.as_ref(), WEEKLY_WINDOW),
307    ] {
308        let Some(window) = window else {
309            continue;
310        };
311        present = true;
312        let projected = as_usage_window(window, duration);
313        push_window_with_row(&mut lines, label, &projected, theme, now, row(&projected));
314    }
315    // Monthly keeps its reset countdown but no pace glyph: the cycle length
316    // is subscriber-dependent, so there is no exact denominator to pace
317    // against (same reason the report carries no `window_secs` for it).
318    if let Some(window) = snap.monthly.as_ref() {
319        present = true;
320        let pct = utilization_pct(window);
321        let color = severity_color(severity_for(pct), theme);
322        let bar = crate::pango::progress_bar(pct, color, theme, None);
323        lines.push(TooltipLine::Body(format!(
324            " <span foreground='{}'>  Monthly</span>",
325            theme.fg
326        )));
327        lines.push(TooltipLine::Body(format!(
328            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>"
329        )));
330        lines.push(TooltipLine::Body(format!(
331            " <span foreground='{}'>  ⏱  Resets in {}</span>",
332            theme.dim,
333            escape(&countdown::format(Some(window.resets_at), now))
334        )));
335    }
336    if !present {
337        lines.push(TooltipLine::Body(format!(
338            " <span foreground='{}'>no usage windows reported</span>",
339            theme.dim
340        )));
341    }
342    if stale {
343        lines.push(TooltipLine::Body(String::new()));
344        lines.push(TooltipLine::Body(format!(
345            " <span foreground='{}'>  ⏸  Showing cached data</span>",
346            theme.orange
347        )));
348    }
349    if let Some((code, message)) = last_error
350        && *code != 0
351    {
352        lines.push(TooltipLine::Body(String::new()));
353        lines.push(TooltipLine::Sep);
354        lines.push(TooltipLine::Body(format!(
355            " <span foreground='{}'>  HTTP {code}: {}</span>",
356            theme.orange,
357            escape(message)
358        )));
359    }
360
361    lines.push(TooltipLine::Body(String::new()));
362    lines.push(TooltipLine::Sep);
363    lines.push(TooltipLine::Body(format!(
364        " <span foreground='{}'>  Updated {}</span>",
365        theme.dim,
366        updated_at_hm(now, cache_age)
367    )));
368    render_bordered(&lines, theme)
369}
370
371#[cfg(test)]
372mod tests {
373    use chrono::{DateTime, Utc};
374
375    use super::*;
376    use crate::opencode_go::types::{Usage, Window};
377
378    fn at(value: &str) -> DateTime<Utc> {
379        value.parse().expect("RFC3339 timestamp")
380    }
381
382    fn sample_usage() -> Usage {
383        Usage {
384            rolling: Some(Window {
385                status: "ok".into(),
386                percent: 12.3,
387                resets_at: at("2026-08-16T20:00:00Z"),
388            }),
389            weekly: Some(Window {
390                status: "rate-limited".into(),
391                percent: 45.6,
392                resets_at: at("2026-08-20T00:00:00Z"),
393            }),
394            monthly: Some(Window {
395                status: "ok".into(),
396                percent: 78.9,
397                resets_at: at("2026-09-01T00:00:00Z"),
398            }),
399        }
400    }
401
402    #[test]
403    fn exposes_exact_opencode_go_and_generic_placeholders() {
404        let values = build_placeholders(&sample_usage(), at("2026-08-16T18:00:00Z"));
405
406        assert_eq!(values["vendor_short"], "ocg");
407        assert_eq!(values["session_pct"], "12.3");
408        assert_eq!(values["weekly_pct"], "45.6");
409        assert_eq!(values["ocg_rolling_pct"], "12.3");
410        assert_eq!(values["ocg_rolling_status"], "ok");
411        assert_eq!(values["ocg_weekly_pct"], "45.6");
412        assert_eq!(values["ocg_weekly_status"], "rate-limited");
413        assert_eq!(values["ocg_monthly_pct"], "78.9");
414        assert_eq!(values["ocg_monthly_status"], "ok");
415    }
416
417    #[test]
418    fn default_format_is_rolling_percentage_and_reset() {
419        assert_eq!(DEFAULT_FORMAT, "{ocg_rolling_pct}% · {ocg_rolling_reset}");
420    }
421
422    #[test]
423    fn absent_windows_are_unavailable_not_zero() {
424        let values = build_placeholders(
425            &Usage {
426                rolling: None,
427                weekly: None,
428                monthly: None,
429            },
430            at("2026-08-16T18:00:00Z"),
431        );
432
433        for key in [
434            "session_pct",
435            "weekly_pct",
436            "ocg_rolling_pct",
437            "ocg_weekly_pct",
438            "ocg_monthly_pct",
439        ] {
440            assert_eq!(values[key], "—", "{key} should be unavailable");
441            assert_ne!(values[key], "0");
442        }
443        for key in [
444            "session_reset",
445            "weekly_reset",
446            "ocg_rolling_reset",
447            "ocg_weekly_reset",
448            "ocg_monthly_reset",
449            "ocg_rolling_status",
450            "ocg_weekly_status",
451            "ocg_monthly_status",
452        ] {
453            assert_eq!(values[key], "—", "{key} should be unavailable");
454        }
455    }
456
457    fn opts() -> RenderOpts {
458        RenderOpts {
459            format: None,
460            tooltip_format: None,
461            icon: None,
462            pace_tolerance: 5,
463            format_pace_color: false,
464            tooltip_pace_pts: false,
465        }
466    }
467
468    fn outcome_for(snap: &Usage) -> VendorOutcome {
469        VendorOutcome {
470            snapshot: crate::usage::VendorSnapshot::OpenCodeGo(snap.clone()),
471            stale: false,
472            last_error: None,
473            cache_age: Some(std::time::Duration::from_secs(10)),
474        }
475    }
476
477    #[test]
478    fn elapsed_placeholders_follow_window_progress() {
479        // 2h left of a 5h window → 60% elapsed; 3d left of a 7d window → 57%.
480        let now = at("2026-08-16T18:00:00Z");
481        let usage = Usage {
482            rolling: Some(Window {
483                status: "ok".into(),
484                percent: 42.0,
485                resets_at: at("2026-08-16T20:00:00Z"),
486            }),
487            weekly: Some(Window {
488                status: "ok".into(),
489                percent: 15.0,
490                resets_at: at("2026-08-19T18:00:00Z"),
491            }),
492            monthly: Some(Window {
493                status: "ok".into(),
494                percent: 70.0,
495                resets_at: at("2026-08-31T18:00:00Z"),
496            }),
497        };
498        let values = build_placeholders(&usage, now);
499        assert_eq!(values["session_elapsed"], "60");
500        assert_eq!(values["weekly_elapsed"], "57");
501        assert_eq!(values["ocg_rolling_elapsed"], "60");
502        assert_eq!(values["ocg_weekly_elapsed"], "57");
503    }
504
505    #[test]
506    fn pace_placeholders_follow_usage_vs_elapsed() {
507        // Rolling: 80% used vs 60% elapsed → ahead. Weekly: 15% vs 57% → under.
508        let now = at("2026-08-16T18:00:00Z");
509        let usage = Usage {
510            rolling: Some(Window {
511                status: "ok".into(),
512                percent: 80.0,
513                resets_at: at("2026-08-16T20:00:00Z"),
514            }),
515            weekly: Some(Window {
516                status: "ok".into(),
517                percent: 15.0,
518                resets_at: at("2026-08-19T18:00:00Z"),
519            }),
520            monthly: Some(Window {
521                status: "ok".into(),
522                percent: 70.0,
523                resets_at: at("2026-08-31T18:00:00Z"),
524            }),
525        };
526        let values = build_placeholders(&usage, now);
527        assert_eq!(values["ocg_rolling_pace"], "↑");
528        assert_eq!(values["ocg_rolling_pace_indicator"], "↑");
529        assert_eq!(values["ocg_weekly_pace"], "↓");
530        assert_eq!(values["ocg_weekly_pace_indicator"], "↓");
531    }
532
533    #[test]
534    fn monthly_keeps_reset_but_omits_pacing_until_cycle_bounds_are_known() {
535        // The monthly cycle is subscriber-dependent (28/29/31 days), so no
536        // fixed denominator may pace it and no `window_secs` may describe it.
537        // The reset countdown and status still report.
538        let now = at("2026-08-16T18:00:00Z");
539        let usage = Usage {
540            rolling: None,
541            weekly: None,
542            monthly: Some(Window {
543                status: "ok".into(),
544                percent: 70.0,
545                resets_at: at("2026-08-31T18:00:00Z"),
546            }),
547        };
548        let values = build_placeholders(&usage, now);
549        assert_eq!(values["ocg_monthly_pct"], "70");
550        assert_eq!(values["ocg_monthly_reset"], "15d 0h");
551        assert_eq!(values["ocg_monthly_status"], "ok");
552        assert_eq!(values["ocg_monthly_elapsed"], "");
553        assert_eq!(values["ocg_monthly_pace"], "");
554        assert_eq!(values["ocg_monthly_pace_indicator"], "");
555    }
556
557    #[test]
558    fn public_builder_keeps_the_default_tolerance_api() {
559        let now = at("2026-08-16T18:00:00Z");
560        let usage = sample_usage();
561        assert_eq!(
562            build_placeholders(&usage, now),
563            build_placeholders_with_tolerance(&usage, pacing::DEFAULT_TOLERANCE, now)
564        );
565    }
566
567    #[test]
568    fn custom_tolerance_changes_the_ratio_pace() {
569        // 53% used vs 50% elapsed → ratio 106%: ahead at ±5, on track at ±10.
570        let now = at("2026-08-16T18:00:00Z");
571        let usage = Usage {
572            rolling: Some(Window {
573                status: "ok".into(),
574                percent: 53.0,
575                resets_at: at("2026-08-16T20:30:00Z"),
576            }),
577            weekly: None,
578            monthly: None,
579        };
580        assert_eq!(
581            build_placeholders_with_tolerance(&usage, 5, now)["ocg_rolling_pace"],
582            "↑"
583        );
584        assert_eq!(
585            build_placeholders_with_tolerance(&usage, 10, now)["ocg_rolling_pace"],
586            "→"
587        );
588    }
589
590    #[test]
591    fn elapsed_and_pace_placeholders_are_empty_without_window() {
592        let values = build_placeholders(
593            &Usage {
594                rolling: None,
595                weekly: None,
596                monthly: None,
597            },
598            at("2026-08-16T18:00:00Z"),
599        );
600        for key in [
601            "session_elapsed",
602            "weekly_elapsed",
603            "ocg_rolling_elapsed",
604            "ocg_rolling_pace",
605            "ocg_rolling_pace_indicator",
606            "ocg_weekly_elapsed",
607            "ocg_weekly_pace",
608            "ocg_weekly_pace_indicator",
609            "ocg_monthly_elapsed",
610            "ocg_monthly_pace",
611            "ocg_monthly_pace_indicator",
612        ] {
613            assert_eq!(values[key], "", "{key} should be empty");
614        }
615    }
616
617    #[test]
618    fn tooltip_shows_the_pace_arrow_next_to_each_percentage() {
619        // Rolling: 80% used with 2h left of 5h → 60% elapsed → ahead.
620        let now = at("2026-08-16T18:00:00Z");
621        let usage = Usage {
622            rolling: Some(Window {
623                status: "ok".into(),
624                percent: 80.0,
625                resets_at: at("2026-08-16T20:00:00Z"),
626            }),
627            weekly: None,
628            monthly: None,
629        };
630        let out = render(
631            &outcome_for(&usage),
632            &usage,
633            &Theme::default(),
634            &opts(),
635            now,
636        );
637        assert!(out.tooltip.contains("80% ↑"), "{}", out.tooltip);
638    }
639
640    #[test]
641    fn tooltip_monthly_row_keeps_reset_without_a_pace_glyph() {
642        let now = at("2026-08-16T18:00:00Z");
643        let usage = Usage {
644            rolling: None,
645            weekly: None,
646            monthly: Some(Window {
647                status: "ok".into(),
648                percent: 70.0,
649                resets_at: at("2026-08-31T18:00:00Z"),
650            }),
651        };
652        let out = render(
653            &outcome_for(&usage),
654            &usage,
655            &Theme::default(),
656            &opts(),
657            now,
658        );
659        assert!(out.tooltip.contains("Monthly"), "{}", out.tooltip);
660        assert!(out.tooltip.contains("70%"), "{}", out.tooltip);
661        assert!(
662            !out.tooltip.contains('↑') && !out.tooltip.contains('→') && !out.tooltip.contains('↓'),
663            "monthly row must not grow a pace glyph: {}",
664            out.tooltip
665        );
666        assert!(out.tooltip.contains("Resets in 15d 0h"), "{}", out.tooltip);
667    }
668
669    #[test]
670    fn plan_and_status_are_sanitized() {
671        let usage = Usage {
672            rolling: Some(Window {
673                status: "ok\u{1b}[31m\u{7}".into(),
674                percent: 1.0,
675                resets_at: at("2026-08-16T20:00:00Z"),
676            }),
677            weekly: None,
678            monthly: None,
679        };
680        let values = build_placeholders_with_plan(
681            "OpenCode\u{1b}[31m Go",
682            &usage,
683            at("2026-08-16T18:00:00Z"),
684        );
685
686        assert!(!values["plan"].contains('\u{1b}'));
687        assert!(!values["ocg_rolling_status"].contains('\u{1b}'));
688        assert!(!values["ocg_rolling_status"].contains('\u{7}'));
689    }
690}