From 97845cd3af2b427244d2d1cc82c910a0a3a0afb7 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Sat, 29 Aug 2026 09:50:17 -0700 Subject: [PATCH] feat(usage_limits): per-window session/weekly tokens and {bar} placeholder Add $cship.usage_limits.session and .weekly tokens (aliases .five_hour / .seven_day) that render a single standard window colored by that window's own utilization, rather than max(5h, 7d) as $cship.usage_limits does. This lets the session and weekly limits live on separate lines and escalate independently. Add a {bar} placeholder to the per-window format strings, plus bar_width / bar_filled_char / bar_empty_char config, so each window can show its own proportional Unicode progress bar (mirroring context_bar). Docs, JSON schema, and README updated; unit tests cover bar rendering, placeholder substitution, independent per-window coloring, and aliases. --- README.md | 1 + docs/configuration.md | 25 +++ docs/faq.md | 27 +++ docs/public/config-schema.json | 23 ++ src/config.rs | 9 + src/explain.rs | 13 +- src/modules/mod.rs | 10 + src/modules/usage_limits.rs | 375 +++++++++++++++++++++++++++++---- 8 files changed, 443 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 5e513ff..b3fa4ab 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Everything in the [Claude Code status line documentation](https://code.claude.co | `$cship.usage_limits` | API usage limits (5hr / 7-day, plus per-model and extra-usage when available) | | `$cship.usage_limits.per_model` | 7-day per-model breakdown (opus / sonnet / cowork / oauth) | | `$cship.usage_limits.extra_usage` | Extra-credits section with `{active}` indicator | +| `$cship.usage_limits.session` / `.weekly` | The 5h / 7d window alone, colored by its own utilization (aliases: `.five_hour` / `.seven_day`; supports a `{bar}`) | | `$cship.account` | Authenticated Anthropic account (work/personal); map org names via `labels` | | `$cship.peak_usage` | Peak-time indicator (US Pacific business hours) | | `$cship.agent` | Sub-agent name | diff --git a/docs/configuration.md b/docs/configuration.md index 7836fa3..21dd90b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -400,9 +400,30 @@ Displays 5-hour and 7-day API utilization percentages with time-to-reset. | `$cship.usage_limits.cowork` | 7-day Cowork utilization only | | `$cship.usage_limits.oauth_apps` | 7-day OAuth-apps utilization only | | `$cship.usage_limits.extra_usage` | Extra-credits display (only when the account has extra usage enabled) | +| `$cship.usage_limits.session` (alias `.five_hour`) | Only the 5h window, colored by *its own* utilization | +| `$cship.usage_limits.weekly` (alias `.seven_day`) | Only the 7d window, colored by *its own* utilization | The sub-tokens let you place sections independently in your `lines` layout — e.g., keep the 5h/7d pair on one row and push per-model onto a second row. +Unlike `$cship.usage_limits` (whose threshold color keys off the *higher* of the two windows), the `session` and `weekly` tokens each escalate independently on their own percentage. Combined with the `{bar}` placeholder and `bar_width`, this lets you render a separate progress bar per window — one line for the 5h session, one for the 7d week — each turning yellow/red on its own: + +```toml +[cship] +lines = [ + "$cship.usage_limits.session", + "$cship.usage_limits.weekly", +] + +[cship.usage_limits] +five_hour_format = "5h {bar} {pct}%" +seven_day_format = "7d {bar} {pct}%" +bar_width = 20 +warn_threshold = 50 +warn_style = "yellow" +critical_threshold = 80 +critical_style = "bold red" +``` + | Field | Type | Default | Description | |-------|------|---------|-------------| | `disabled` | `bool` | `false` | Hide this module | @@ -417,6 +438,9 @@ The sub-tokens let you place sections independently in your `lines` layout — e | `extra_usage_format` | `string` | `"{active} extra: {pct}% (${used}/${limit})"` | Format for the extra-usage section | | `show_per_model` | `bool` | `false` | When `true`, `$cship.usage_limits` appends the per-model breakdown (opus, sonnet, cowork, oauth) to the default `5h \| 7d` output. The extra-usage section always renders when enabled, regardless of this flag. Default is `false` so existing status bars retain their pre-1.5 shape. | | `separator` | `string` | `" \| "` | String placed between sections | +| `bar_width` | `integer` | `10` | Width (chars) of the `{bar}` placeholder | +| `bar_filled_char` | `string` | `"█"` | Filled character for `{bar}` | +| `bar_empty_char` | `string` | `"░"` | Empty character for `{bar}` | | `warn_threshold` | `float` | — | % at which style switches to `warn_style` | | `warn_style` | `string` | `"yellow"` | Style at warn level | | `critical_threshold` | `float` | — | % at which style switches to `critical_style` | @@ -432,6 +456,7 @@ The sub-tokens let you place sections independently in your `lines` layout — e | `{reset}` | Time-until-reset string (e.g. `4h12m`) | | `{reset_at}` | Absolute local reset time — clock-only if today (e.g. `7:42 PM`), weekday-prefixed otherwise (e.g. `Mon 9:00 AM`); `?` if unknown, `now` if already past | | `{pace}` | Signed headroom vs linear consumption — `+20%` (under pace), `-15%` (over pace), or `?` when unknown | +| `{bar}` | Proportional Unicode bar for this window (e.g. `████░░░░░░`), sized by `bar_width`. **`five_hour_format` / `seven_day_format` only** (and the `session` / `weekly` tokens); not substituted in the per-model or extra-usage formats. | **Placeholders specific to `extra_usage_format`** (the standard `{remaining}` percentage placeholder does **not** apply here — use `{remaining_credits}` instead): diff --git a/docs/faq.md b/docs/faq.md index 89b3045..b9c0c81 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -81,6 +81,33 @@ extra_usage_format = "{active} ${used}/${limit}" --- +## How do I show the session and weekly limits on separate lines, each with its own bar and color? + +`$cship.usage_limits` renders both windows together and colors the whole segment off the *higher* of the two (so a hot 5h window turns the 7d text red as well). The per-window tokens render each window on its own and color it off **its own** utilization: + +- `$cship.usage_limits.session` — the 5-hour window (alias `.five_hour`) +- `$cship.usage_limits.weekly` — the 7-day window (alias `.seven_day`) + +Combine them with the `{bar}` placeholder (available in `five_hour_format` / `seven_day_format`, sized by `bar_width`) to get an independent progress bar per window: + +```toml +[cship] +lines = ["$cship.usage_limits.session", "$cship.usage_limits.weekly"] + +[cship.usage_limits] +five_hour_format = "5h {bar} {pct}%" +seven_day_format = "7d {bar} {pct}%" +bar_width = 20 +warn_threshold = 50 +warn_style = "yellow" +critical_threshold = 80 +critical_style = "bold red" +``` + +Now the 5h bar can be green while the 7d bar is red (or vice versa), each escalating on its own percentage. + +--- + ## I'm on a Claude Enterprise plan and `cship.usage_limits` shows nothing — why? Claude Enterprise reports usage through `extra_usage` (monthly credit pool) diff --git a/docs/public/config-schema.json b/docs/public/config-schema.json index 942a050..3d7d5aa 100644 --- a/docs/public/config-schema.json +++ b/docs/public/config-schema.json @@ -1051,6 +1051,29 @@ "string", "null" ] + }, + "bar_width": { + "description": "Width (in characters) of the `{bar}` placeholder available in\n`five_hour_format` / `seven_day_format` and the per-period tokens\n(`$cship.usage_limits.session`/`.weekly`, a.k.a. `.five_hour`/`.seven_day`).\nDefault: 10.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "bar_filled_char": { + "description": "Filled character for the `{bar}` placeholder. Default: \"█\".", + "type": [ + "string", + "null" + ] + }, + "bar_empty_char": { + "description": "Empty character for the `{bar}` placeholder. Default: \"░\".", + "type": [ + "string", + "null" + ] } } }, diff --git a/src/config.rs b/src/config.rs index 7fdc6ee..e6dfe2d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -373,6 +373,15 @@ pub struct UsageLimitsConfig { /// Placeholders: {pct}, {reset}, {remaining}, {pace} /// Default: "oauth {pct}%" pub oauth_apps_format: Option, + /// Width (in characters) of the `{bar}` placeholder available in + /// `five_hour_format` / `seven_day_format` and the per-period tokens + /// (`$cship.usage_limits.session`/`.weekly`, a.k.a. `.five_hour`/`.seven_day`). + /// Default: 10. + pub bar_width: Option, + /// Filled character for the `{bar}` placeholder. Default: "█". + pub bar_filled_char: Option, + /// Empty character for the `{bar}` placeholder. Default: "░". + pub bar_empty_char: Option, } /// Configuration for `[cship.peak_usage]` — peak-time indicator. diff --git a/src/explain.rs b/src/explain.rs index 1222f8b..23cdd9f 100644 --- a/src/explain.rs +++ b/src/explain.rs @@ -267,21 +267,28 @@ fn error_hint_for( .as_deref() .map(std::path::Path::new) .and_then(|p| crate::cache::read_usage_limits(p, true, None)); - let is_per_model_subtoken = matches!( + // Sub-tokens that require a standard 5h/7d signal and so are + // inherently absent on Enterprise (per-window and per-model). + let is_plan_specific_subtoken = matches!( top, "usage_limits.per_model" | "usage_limits.opus" | "usage_limits.sonnet" | "usage_limits.cowork" | "usage_limits.oauth_apps" + | "usage_limits.five_hour" + | "usage_limits.seven_day" + | "usage_limits.session" + | "usage_limits.weekly" ); match cached { Some(d) if crate::modules::usage_limits::lacks_standard_signal(&d) - && is_per_model_subtoken => + && is_plan_specific_subtoken => { ( - "per-model breakdowns are unavailable on this plan".into(), + "the 5h/7d and per-model breakdowns are unavailable on this plan" + .into(), "Claude Enterprise reports usage via monthly credits (`extra_usage`) only. Use `$cship.usage_limits` or `$cship.usage_limits.extra_usage` instead.".into(), ) } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 2eae169..0d77bc4 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -57,6 +57,10 @@ pub const ALL_NATIVE_MODULES: &[&str] = &[ "cship.usage_limits.cowork", "cship.usage_limits.oauth_apps", "cship.usage_limits.extra_usage", + "cship.usage_limits.five_hour", + "cship.usage_limits.seven_day", + "cship.usage_limits.session", + "cship.usage_limits.weekly", "cship.peak_usage", "cship.account", ]; @@ -137,6 +141,12 @@ pub fn render_module( "cship.usage_limits.cowork" => usage_limits::render_cowork(ctx, cfg), "cship.usage_limits.oauth_apps" => usage_limits::render_oauth_apps(ctx, cfg), "cship.usage_limits.extra_usage" => usage_limits::render_extra_usage(ctx, cfg), + "cship.usage_limits.five_hour" | "cship.usage_limits.session" => { + usage_limits::render_five_hour(ctx, cfg) + } + "cship.usage_limits.seven_day" | "cship.usage_limits.weekly" => { + usage_limits::render_seven_day(ctx, cfg) + } "cship.peak_usage" => peak_usage::render(ctx, cfg), "cship.account" => account::render(ctx, cfg), other => { diff --git a/src/modules/usage_limits.rs b/src/modules/usage_limits.rs index 3a8408c..50b9818 100644 --- a/src/modules/usage_limits.rs +++ b/src/modules/usage_limits.rs @@ -218,6 +218,70 @@ pub fn render_per_model(ctx: &Context, cfg: &CshipConfig) -> Option { Some(apply_threshold(&content, &data, cfg)) } +/// Which standard usage window a per-period token renders. +enum Period { + FiveHour, + SevenDay, +} + +/// Render a single standard window (5h or 7d) as its own token, colored by *that +/// window's* utilization — unlike `$cship.usage_limits`, whose threshold color keys +/// off `max(5h, 7d)`. This lets `session` and `weekly` sit on separate lines, each +/// escalating independently. Returns `None` on plans without standard 5h/7d +/// windows (e.g. Enterprise) — unlike `render`, it does not fall back to the +/// extra-usage section, since this token is specifically the 5h/7d window. +fn render_period(ctx: &Context, cfg: &CshipConfig, period: Period) -> Option { + let data = resolve_data(ctx, cfg)?; + if lacks_standard_signal(&data) { + return None; + } + let default_ul_cfg = UsageLimitsConfig::default(); + let ul_cfg = cfg.usage_limits.as_ref().unwrap_or(&default_ul_cfg); + let now = now_epoch(); + + let (pct, epoch, window_secs, fmt) = match period { + Period::FiveHour => ( + data.five_hour_pct, + resolve_epoch(data.five_hour_resets_at_epoch, &data.five_hour_resets_at), + FIVE_HOUR_SECS, + ul_cfg + .five_hour_format + .as_deref() + .unwrap_or("5h: {pct}% resets in {reset}"), + ), + Period::SevenDay => ( + data.seven_day_pct, + resolve_epoch(data.seven_day_resets_at_epoch, &data.seven_day_resets_at), + SEVEN_DAY_SECS, + ul_cfg + .seven_day_format + .as_deref() + .unwrap_or("7d: {pct}% resets in {reset}"), + ), + }; + + let content = format_period_part(fmt, pct, epoch, window_secs, now, ul_cfg); + Some(crate::ansi::apply_style_with_threshold( + &content, + Some(pct), + ul_cfg.style.as_deref(), + ul_cfg.warn_threshold, + ul_cfg.warn_style.as_deref(), + ul_cfg.critical_threshold, + ul_cfg.critical_style.as_deref(), + )) +} + +/// Render only the 5-hour (session) window, colored by its own utilization. +pub fn render_five_hour(ctx: &Context, cfg: &CshipConfig) -> Option { + render_period(ctx, cfg, Period::FiveHour) +} + +/// Render only the 7-day (weekly) window, colored by its own utilization. +pub fn render_seven_day(ctx: &Context, cfg: &CshipConfig) -> Option { + render_period(ctx, cfg, Period::SevenDay) +} + /// Render a single model's usage (opus, sonnet, cowork, or oauth). fn render_model( ctx: &Context, @@ -356,6 +420,68 @@ fn data_from_stdin_rate_limits(ctx: &Context) -> Option { }) } +/// Reset-window lengths, used for pace calculation. +const FIVE_HOUR_SECS: u64 = 18_000; +const SEVEN_DAY_SECS: u64 = 604_800; + +/// Default `{bar}` width in characters when `bar_width` is unset. +const DEFAULT_BAR_WIDTH: usize = 10; + +/// Upper bound on `{bar}` width. A statusline is narrow, and this caps the +/// string `render_bar` allocates from an over-large `bar_width`. +const MAX_BAR_WIDTH: usize = 200; + +/// Render a proportional Unicode bar for `pct` (0..=100) using the module's bar config. +/// +/// Mirrors `context_bar`'s fill math (kept in sync deliberately): the fill count +/// floors via `as usize` truncation (a visual approximation) while the `{pct}` text +/// rounds. Width is clamped to `MAX_BAR_WIDTH`. Fill/empty glyphs and width are +/// configurable via `bar_filled_char` / `bar_empty_char` / `bar_width`. +fn render_bar(pct: f64, cfg: &UsageLimitsConfig) -> String { + let width = cfg + .bar_width + .map(|w| (w as usize).min(MAX_BAR_WIDTH)) + .unwrap_or(DEFAULT_BAR_WIDTH); + let filled = (((pct / 100.0) * width as f64) as usize).min(width); + let empty = width - filled; + let filled_char = cfg.bar_filled_char.as_deref().unwrap_or("█"); + let empty_char = cfg.bar_empty_char.as_deref().unwrap_or("░"); + filled_char.repeat(filled) + &empty_char.repeat(empty) +} + +/// Substitute all per-period placeholders (including `{bar}`) into `fmt`. +/// +/// `window_secs` is the reset-window length used for the `{pace}` calculation +/// (`FIVE_HOUR_SECS` for the 5h window, `SEVEN_DAY_SECS` for the 7d window). +fn format_period_part( + fmt: &str, + pct: f64, + epoch: Option, + window_secs: u64, + now: u64, + cfg: &UsageLimitsConfig, +) -> String { + let pct_str = format!("{:.0}", pct); + let remaining_str = format!("{:.0}", (100.0 - pct).max(0.0)); + let reset_str = format_reset(epoch, now); + let reset_at_str = format_reset_at(epoch, now); + let pace_str = format_pace(calculate_pace(pct, epoch, window_secs, now)); + let out = fmt + .replace("{pct}", &pct_str) + .replace("{remaining}", &remaining_str) + .replace("{reset}", &reset_str) + .replace("{reset_at}", &reset_at_str) + .replace("{pace}", &pace_str); + // Substitute `{bar}` last, and only when referenced: the (potentially large) + // bar string is then built solely when needed, and its glyphs are never + // re-scanned for the other placeholders. + if out.contains("{bar}") { + out.replace("{bar}", &render_bar(pct, cfg)) + } else { + out + } +} + /// Format usage data using configurable format strings. /// /// Placeholders in format strings (all occurrences are substituted): @@ -364,6 +490,7 @@ fn data_from_stdin_rate_limits(ctx: &Context) -> Option { /// - `{reset}` — time-until-reset string (e.g. `"4h12m"`) /// - `{reset_at}` — absolute local reset time (e.g. `"7:42 PM"` or `"Mon 9:00 AM"`) /// - `{pace}` — signed pace string (e.g. `"+20%"`, `"-15%"`, `"?"`) +/// - `{bar}` — proportional Unicode bar for this period (e.g. `"████░░░░░░"`) /// /// Per-model breakdowns (opus, sonnet, cowork, oauth_apps) are appended when /// the API returns non-null data. Extra usage is appended when enabled. @@ -376,34 +503,9 @@ fn format_output(data: &UsageLimitsData, cfg: &UsageLimitsConfig) -> String { let sep = cfg.separator.as_deref().unwrap_or(" | "); let now = now_epoch(); - const FIVE_HOUR_SECS: u64 = 18_000; - const SEVEN_DAY_SECS: u64 = 604_800; - let five_h_epoch = resolve_epoch(data.five_hour_resets_at_epoch, &data.five_hour_resets_at); let seven_d_epoch = resolve_epoch(data.seven_day_resets_at_epoch, &data.seven_day_resets_at); - let five_h_pct = format!("{:.0}", data.five_hour_pct); - let five_h_remaining = format!("{:.0}", (100.0 - data.five_hour_pct).max(0.0)); - let five_h_reset = format_reset(five_h_epoch, now); - let five_h_reset_at = format_reset_at(five_h_epoch, now); - let five_h_pace = format_pace(calculate_pace( - data.five_hour_pct, - five_h_epoch, - FIVE_HOUR_SECS, - now, - )); - - let seven_d_pct = format!("{:.0}", data.seven_day_pct); - let seven_d_remaining = format!("{:.0}", (100.0 - data.seven_day_pct).max(0.0)); - let seven_d_reset = format_reset(seven_d_epoch, now); - let seven_d_reset_at = format_reset_at(seven_d_epoch, now); - let seven_d_pace = format_pace(calculate_pace( - data.seven_day_pct, - seven_d_epoch, - SEVEN_DAY_SECS, - now, - )); - let five_h_fmt = cfg .five_hour_format .as_deref() @@ -413,18 +515,22 @@ fn format_output(data: &UsageLimitsData, cfg: &UsageLimitsConfig) -> String { .as_deref() .unwrap_or("7d: {pct}% resets in {reset}"); - let five_h_part = five_h_fmt - .replace("{pct}", &five_h_pct) - .replace("{remaining}", &five_h_remaining) - .replace("{reset}", &five_h_reset) - .replace("{reset_at}", &five_h_reset_at) - .replace("{pace}", &five_h_pace); - let seven_d_part = seven_d_fmt - .replace("{pct}", &seven_d_pct) - .replace("{remaining}", &seven_d_remaining) - .replace("{reset}", &seven_d_reset) - .replace("{reset_at}", &seven_d_reset_at) - .replace("{pace}", &seven_d_pace); + let five_h_part = format_period_part( + five_h_fmt, + data.five_hour_pct, + five_h_epoch, + FIVE_HOUR_SECS, + now, + cfg, + ); + let seven_d_part = format_period_part( + seven_d_fmt, + data.seven_day_pct, + seven_d_epoch, + SEVEN_DAY_SECS, + now, + cfg, + ); let mut parts: Vec = vec![five_h_part, seven_d_part]; @@ -928,6 +1034,201 @@ mod tests { ); } + // ── {bar} placeholder + per-period token tests ─────────────────────────── + + #[test] + fn test_render_bar_fills_proportionally() { + let cfg = UsageLimitsConfig::default(); + assert_eq!(render_bar(0.0, &cfg), "░░░░░░░░░░"); + assert_eq!(render_bar(50.0, &cfg), "█████░░░░░"); + assert_eq!(render_bar(100.0, &cfg), "██████████"); + } + + #[test] + fn test_render_bar_clamps_and_floors() { + let cfg = UsageLimitsConfig::default(); + // Over 100 must not overflow the width. + assert_eq!(render_bar(150.0, &cfg), "██████████"); + // 99.5% floors to 9 filled (visual approximation), not 10. + assert_eq!(render_bar(99.5, &cfg), "█████████░"); + } + + #[test] + fn test_render_bar_custom_width_and_chars() { + let cfg = UsageLimitsConfig { + bar_width: Some(4), + bar_filled_char: Some("#".to_string()), + bar_empty_char: Some("-".to_string()), + ..Default::default() + }; + assert_eq!(render_bar(50.0, &cfg), "##--"); + } + + #[test] + fn test_format_period_part_substitutes_bar() { + let cfg = UsageLimitsConfig { + bar_width: Some(10), + ..Default::default() + }; + let out = format_period_part("5h {bar} {pct}%", 40.0, None, FIVE_HOUR_SECS, 0, &cfg); + assert_eq!(out, "5h ████░░░░░░ 40%"); + } + + /// Builds a Context whose stdin rate_limits carry the given 5h/7d percentages. + fn ctx_with_pcts(five: f64, seven: f64) -> (tempfile::TempDir, Context) { + use crate::context::{RateLimitPeriod, RateLimits}; + let dir = tempfile::tempdir().unwrap(); + let transcript = dir.path().join("test.jsonl"); + let ctx = Context { + transcript_path: Some(transcript.to_str().unwrap().to_string()), + rate_limits: Some(RateLimits { + five_hour: Some(RateLimitPeriod { + used_percentage: Some(five), + resets_at: Some(9_999_999_999), + }), + seven_day: Some(RateLimitPeriod { + used_percentage: Some(seven), + resets_at: Some(9_999_999_999), + }), + }), + ..Default::default() + }; + (dir, ctx) + } + + /// A per-period token is colored by its OWN percentage, not `max(5h, 7d)`: + /// with 5h high and 7d low, the 5h token escalates while the 7d token stays base. + #[test] + fn test_per_period_tokens_color_independently() { + let (_dir, ctx) = ctx_with_pcts(85.0, 20.0); + let cfg = CshipConfig { + usage_limits: Some(UsageLimitsConfig { + // base style unset → below-threshold periods render without ANSI + warn_threshold: Some(50.0), + warn_style: Some("bold".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let five = render_five_hour(&ctx, &cfg).unwrap(); + let seven = render_seven_day(&ctx, &cfg).unwrap(); + assert!( + five.contains('\x1b'), + "5h at 85% should be styled (warn): {five:?}" + ); + assert!( + !seven.contains('\x1b'), + "7d at 20% should be unstyled despite high 5h: {seven:?}" + ); + } + + /// `session`/`weekly` are aliases of `five_hour`/`seven_day`. + #[test] + fn test_period_token_aliases_match() { + let (_dir, ctx) = ctx_with_pcts(42.0, 38.0); + let cfg = CshipConfig { + usage_limits: Some(UsageLimitsConfig::default()), + ..Default::default() + }; + let five = crate::modules::render_module("cship.usage_limits.five_hour", &ctx, &cfg); + let session = crate::modules::render_module("cship.usage_limits.session", &ctx, &cfg); + let seven = crate::modules::render_module("cship.usage_limits.seven_day", &ctx, &cfg); + let weekly = crate::modules::render_module("cship.usage_limits.weekly", &ctx, &cfg); + assert_eq!(five, session); + assert_eq!(seven, weekly); + assert!(five.is_some() && seven.is_some()); + } + + /// `{bar}` is substituted inside the combined `$cship.usage_limits` output too, + /// not only the standalone per-window tokens. + #[test] + fn test_bar_substituted_in_combined_render() { + let (_dir, ctx) = ctx_with_pcts(50.0, 0.0); + let cfg = CshipConfig { + usage_limits: Some(UsageLimitsConfig { + five_hour_format: Some("5h {bar}".to_string()), + seven_day_format: Some("7d {bar}".to_string()), + bar_width: Some(10), + ..Default::default() + }), + ..Default::default() + }; + let out = render(&ctx, &cfg).unwrap(); + // 5h at 50% → half-filled; 7d at 0% → empty. Pins each window's own pct. + assert!( + out.contains("5h █████░░░░░"), + "5h bar must reflect 50%: {out:?}" + ); + assert!( + out.contains("7d ░░░░░░░░░░"), + "7d bar must reflect 0%: {out:?}" + ); + } + + /// `{bar}` is a per-window placeholder only: the per-model and extra-usage + /// formatters leave it literal (docs make this exact negative claim). + #[test] + fn test_bar_not_substituted_in_per_model_or_extra() { + use crate::usage_limits::UsageLimitsData; + // Per-model formatter. + let opus = format_single_model("opus", Some(40.0), &None, Some("opus {bar} {pct}%")) + .expect("opus pct present"); + assert!( + opus.contains("{bar}") && opus.contains("40%"), + "opus_format must not expand {{bar}}: {opus:?}" + ); + // Extra-usage formatter. + let data = UsageLimitsData { + extra_usage_enabled: Some(true), + extra_usage_utilization: Some(25.0), + extra_usage_used_credits: Some(500.0), + ..Default::default() + }; + let cfg = UsageLimitsConfig { + extra_usage_format: Some("extra {bar} {pct}%".to_string()), + ..Default::default() + }; + let extra = format_extra_usage(&data, &cfg).expect("extra usage enabled"); + assert!( + extra.contains("{bar}"), + "extra_usage_format must not expand {{bar}}: {extra:?}" + ); + } + + #[test] + fn test_render_bar_zero_width_is_empty() { + let cfg = UsageLimitsConfig { + bar_width: Some(0), + ..Default::default() + }; + assert_eq!(render_bar(50.0, &cfg), ""); + } + + #[test] + fn test_render_bar_clamps_oversized_width() { + let cfg = UsageLimitsConfig { + bar_width: Some(1_000_000), + ..Default::default() + }; + // Width is capped at MAX_BAR_WIDTH regardless of the configured value. + assert_eq!(render_bar(100.0, &cfg).chars().count(), MAX_BAR_WIDTH); + } + + /// Enterprise-style plans (no standard 5h/7d signal) yield no per-period token. + #[test] + fn test_per_period_tokens_none_without_standard_signal() { + let dir = tempfile::tempdir().unwrap(); + let transcript = dir.path().join("test.jsonl"); + let ctx = Context { + transcript_path: Some(transcript.to_str().unwrap().to_string()), + rate_limits: None, + ..Default::default() + }; + let cfg = CshipConfig::default(); + assert!(render_five_hour(&ctx, &cfg).is_none()); + assert!(render_seven_day(&ctx, &cfg).is_none()); + } + // ── apply_threshold() extra_usage fallback tests ───────────────────────── #[test]