diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 07b289498f..bc9ba159ef 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -42,6 +42,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..9f94466a70 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -23,6 +23,9 @@ pub struct RateWindowSnapshot { pub is_exhausted: bool, #[serde(default)] pub is_informational: bool, + /// `reset_description` is a detail line (for example spend amounts), not reset wording. + #[serde(default)] + pub description_is_detail: bool, #[serde(default)] pub reserve_percent: Option, #[serde(default)] @@ -53,6 +56,7 @@ impl RateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: rw.description_is_detail, } } @@ -518,6 +522,7 @@ impl ProviderUsageSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }, primary_label: Some(metadata.session_label.to_string()), secondary: None, @@ -980,6 +985,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs index 6498eec07d..04bbb1ac48 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs @@ -32,6 +32,10 @@ fn compact_reset_description( return Some(format_compact_reset_countdown(dt, lang)); } + if window.description_is_detail { + return None; + } + window .reset_description .as_deref() @@ -128,3 +132,44 @@ pub(crate) fn friendly_provider_error(id: ProviderId, error: &str) -> String { trimmed.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use codexbar::core::RateWindow; + use codexbar::settings::Language; + + #[test] + fn detail_backed_description_is_never_a_tray_reset_label() { + let window = RateWindowSnapshot::from_rate_window( + &RateWindow::with_details( + 13.0, + None, + None, + Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()), + ) + .with_description_as_detail(), + ); + + assert!(window.description_is_detail); + assert_eq!(compact_tray_status_label(&window, Language::English), "13%"); + } + + #[test] + fn detail_backed_window_keeps_its_countdown_when_reset_is_known() { + let window = RateWindowSnapshot::from_rate_window( + &RateWindow::with_details( + 13.0, + None, + Some(chrono::Utc::now() + chrono::Duration::minutes(125)), + Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()), + ) + .with_description_as_detail(), + ); + + let label = compact_tray_status_label(&window, Language::English); + + assert!(label.starts_with("13% • "), "{label}"); + assert!(!label.contains("EUR"), "{label}"); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..57652d7f04 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1320,6 +1320,7 @@ mod reset_backfill_tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..ff257376c2 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -178,6 +178,7 @@ mod tests { reserve_percent: None, reserve_description: None, reserve_eta_seconds: None, + description_is_detail: false, reserve_will_last_to_reset: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index aebb7c2115..882573b666 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1034,6 +1034,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }, primary_label: None, secondary: secondary_percent.map(|pct| crate::commands::RateWindowSnapshot { @@ -1048,6 +1049,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }), secondary_label: None, model_specific: None, @@ -1063,6 +1065,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }), tertiary_label: None, extra_rate_windows: Vec::new(), @@ -1127,6 +1130,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }, } } @@ -1537,6 +1541,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1581,6 +1586,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index f6e8f5f532..65a2408a15 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -63,6 +63,7 @@ fn same_window(left: &RateWindowSnapshot, right: &RateWindowSnapshot) -> bool { && left.window_minutes == right.window_minutes && left.resets_at == right.resets_at && left.reset_description == right.reset_description + && left.description_is_detail == right.description_is_detail && left.is_informational == right.is_informational } @@ -267,6 +268,7 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, } } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..3b840688be 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -55,7 +55,7 @@ function rateWindow( function provider( error: string | null, usedPercent = 0, - opts: { exhausted?: boolean; resetDescription?: string | null } = {}, + opts: { exhausted?: boolean; resetDescription?: string | null; resetsAt?: string | null } = {}, ): ProviderUsageSnapshot { return { providerId: "claude", @@ -356,6 +356,46 @@ describe("MenuCard", () => { expect(await screen.findByText("Fable only")).toBeInTheDocument(); }); + it.each([false, true])( + "shows detail-backed amounts as their own line, never as reset text (resetsAt: %s)", + async (hasReset) => { + const resetsAt = hasReset + ? new Date(Date.now() + 3 * 60 * 60 * 1000 + 30_000).toISOString() + : null; + const snapshot = provider(null, 75, { + resetDescription: "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + resetsAt, + }); + snapshot.providerId = "mistral"; + snapshot.primary.descriptionIsDetail = true; + snapshot.selectedMetric.descriptionIsDetail = true; + snapshot.extraRateWindows = [ + { + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: { + ...rateWindow(13, { + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + resetsAt, + }), + descriptionIsDetail: true, + }, + }, + ]; + + renderCard(snapshot); + + const primaryDetail = await screen.findByText( + "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + ); + const planDetail = screen.getByText("34.07 EUR / 255.00 EUR · 220.93 EUR remaining"); + expect(primaryDetail).toHaveClass("menu-metric__detail"); + expect(planDetail).toHaveClass("menu-metric__detail"); + expect(screen.queryByText(/Resets .*EUR/)).not.toBeInTheDocument(); + expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(hasReset ? 2 : 0); + }, + ); + it("renders informational metrics without quota percentages", async () => { const snapshot = provider(null, 20); snapshot.extraRateWindows = [ diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..3826fc9823 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -24,6 +24,7 @@ import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; +import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; import PaceDetailsChart from "./PaceDetailsChart"; /** Format a reserve description from raw pace data at render time. */ @@ -337,9 +338,10 @@ function MetricRow({ const barDisplayPct = showAsUsed ? barPct : Math.max(0, Math.min(100, remain)); const displayLabel = showAsUsed ? t("PanelUsedSuffix") : t("PanelLeftSuffix"); const level = levelOf(remain, snap.isExhausted); + const detailText = windowDetailText(snap); const resetText = useFormattedResetTime( snap.resetsAt, - isInformational ? null : snap.resetDescription, + isInformational ? null : resetDescriptionFallback(snap), resetTimeRelative, resetFormatMode ?? "reset", ); @@ -380,6 +382,9 @@ function MetricRow({ {resetText} )} + {!compactOverview && detailText && ( +
{detailText}
+ )} {!compactOverview && !isInformational && snap.isExhausted && (
{exhaustedLabel}
)} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d..f24e1fda7d 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -47,6 +47,7 @@ type RateWindowOptions = { informational?: boolean; resetsAt?: string | null; resetDescription?: string | null; + descriptionIsDetail?: boolean; }; function rateWindow( @@ -61,6 +62,7 @@ function rateWindow( resetDescription: opts.resetDescription ?? null, isExhausted: opts.exhausted ?? false, isInformational: opts.informational, + descriptionIsDetail: opts.descriptionIsDetail, reservePercent: null, reserveDescription: null, }; @@ -76,6 +78,7 @@ function snapshot( errorState?: ProviderUsageSnapshot["errorState"]; resetsAt?: string | null; resetDescription?: string | null; + descriptionIsDetail?: boolean; informational?: boolean; secondary?: { used: number; @@ -325,6 +328,25 @@ describe("FloatBar", () => { }); }); + it("never shows a detail-backed description as reset text", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 13, { + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + descriptionIsDetail: true, + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowResetInline: true }), + ); + + const { container } = renderFloatBar(bootstrap({ floatBarShowResetInline: true })); + await waitFor(() => { + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.getAttribute("title")).toBe("Claude: 13% used"); + expect(container.querySelector(".floatbar__reset")).toBeNull(); + }); + }); + it("keeps an informational primary window when no secondary window is available", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 10, { informational: true }), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index e8d71af8fe..c01f3501c3 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -20,6 +20,7 @@ import { import { ProviderIcon } from "../components/providers/ProviderIcon"; import { getProviderIcon } from "../components/providers/providerIcons"; import { describeProviderState } from "../lib/providerState"; +import { resetDescriptionFallback } from "../lib/usageWindows"; import type { BootstrapState, ProviderLocalUsageSummary, @@ -221,7 +222,7 @@ function ProviderPill({ const label = state.isProblem ? stateLabel : `${Math.round(displayPercent)}%`; const resetText = useFormattedResetTime( rateWindow.resetsAt, - rateWindow.resetDescription, + resetDescriptionFallback(rateWindow), resetRelative, ); const resetSuffix = resetText ? `\n${resetText}` : ""; diff --git a/apps/desktop-tauri/src/lib/usageWindows.test.ts b/apps/desktop-tauri/src/lib/usageWindows.test.ts index 2876b33810..e5183d7153 100644 --- a/apps/desktop-tauri/src/lib/usageWindows.test.ts +++ b/apps/desktop-tauri/src/lib/usageWindows.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { RateWindowSnapshot } from "../types/bridge"; -import { selectSingleMetricUsageWindow } from "./usageWindows"; +import { + resetDescriptionFallback, + selectSingleMetricUsageWindow, + windowDetailText, +} from "./usageWindows"; function rateWindow( overrides: Partial = {}, @@ -46,3 +50,32 @@ describe("selectSingleMetricUsageWindow", () => { expect(selectSingleMetricUsageWindow({ primary, secondary })).toBe(primary); }); }); + +describe("detail-backed descriptions", () => { + const amounts = "34.07 EUR / 255.00 EUR · 220.93 EUR remaining"; + + it("keeps ordinary descriptions as the reset fallback and shows no detail line", () => { + const window = rateWindow({ resetDescription: "Resets in 2h" }); + + expect(resetDescriptionFallback(window)).toBe("Resets in 2h"); + expect(windowDetailText(window)).toBeNull(); + }); + + it("moves a detail-backed description out of the reset fallback", () => { + const window = rateWindow({ resetDescription: ` ${amounts} `, descriptionIsDetail: true }); + + expect(resetDescriptionFallback(window)).toBeNull(); + expect(windowDetailText(window)).toBe(amounts); + }); + + it("ignores blank and informational detail-backed descriptions", () => { + expect( + windowDetailText(rateWindow({ resetDescription: " ", descriptionIsDetail: true })), + ).toBeNull(); + expect( + windowDetailText( + rateWindow({ resetDescription: amounts, descriptionIsDetail: true, isInformational: true }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageWindows.ts b/apps/desktop-tauri/src/lib/usageWindows.ts index 9a55daf098..9179e1dce3 100644 --- a/apps/desktop-tauri/src/lib/usageWindows.ts +++ b/apps/desktop-tauri/src/lib/usageWindows.ts @@ -12,3 +12,22 @@ export function selectSingleMetricUsageWindow( ? secondary : primary; } + +/** + * Reset-wording fallback for a window. A detail-backed description (for + * example spend amounts) is never reset text, so it must not reach + * `normalizeResetDescription` through the reset formatter. + */ +export function resetDescriptionFallback( + window: Pick, +): string | null { + return window.descriptionIsDetail ? null : window.resetDescription; +} + +/** Secondary detail line for a detail-backed window, or null. */ +export function windowDetailText( + window: Pick, +): string | null { + if (!window.descriptionIsDetail || window.isInformational) return null; + return window.resetDescription?.trim() || null; +} diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c..b01bee4339 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2100,7 +2100,8 @@ body:has(.tray-panel-reveal) { transition: width 0.3s; } -.provider-usage-bar__reset { +.provider-usage-bar__reset, +.provider-usage-bar__detail { font-size: 0.68rem; color: var(--text-muted); } @@ -3779,7 +3780,8 @@ html:has(.menu-surface--tray) { max-width: 100%; } .menu-surface--tray .menu-metric__pct, -.menu-surface--tray .menu-metric__reset { +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__detail { font-size: 10px; } .menu-surface--tray .menu-metric__reserve { @@ -4305,6 +4307,15 @@ html:has(.menu-surface--tray) { white-space: nowrap; } +/* Detail-backed window description (for example spend amounts): its own + secondary line under the metric row, never reset wording. */ +.menu-metric__detail { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow-wrap: anywhere; +} + .menu-metric__reset { font-size: 11px; color: var(--text-secondary); @@ -5051,7 +5062,8 @@ html:has(.menu-surface--tray) { } .menu-metric__pct, -.menu-metric__reset { +.menu-metric__reset, +.menu-metric__detail { font-size: var(--font-caption); } .menu-card__cost-line, diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index e79ac3293a..7e57ed3dea 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -61,7 +61,8 @@ } :is(.menu-surface--tray, .menu-surface--popout) .menu-metric__pct, -:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__reset { +:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__reset, +:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__detail { font-size: 11px; line-height: 1.4; } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index cf7ca9b884..734d47c414 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -86,6 +86,51 @@ describe("UsageSection", () => { expect(screen.getByText("42%")).toBeInTheDocument(); }); + it.each([false, true])( + "renders detail-backed amounts as a detail line, not reset text (resetsAt: %s)", + async (hasReset) => { + const detail = provider(); + const resetsAt = hasReset + ? new Date(Date.now() + 3 * 60 * 60 * 1000 + 30_000).toISOString() + : null; + detail.session = { + ...rateWindow(75), + resetsAt, + resetDescription: "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + descriptionIsDetail: true, + }; + detail.extraRateWindows = [ + { + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: { + ...rateWindow(13), + resetsAt, + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + descriptionIsDetail: true, + }, + }, + ]; + + const { container } = render( + + key} /> + , + ); + + const detailLines = await screen.findAllByText(/EUR remaining/); + expect(detailLines.map((line) => line.textContent)).toEqual([ + "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + ]); + detailLines.forEach((line) => expect(line).toHaveClass("provider-usage-bar__detail")); + expect(screen.queryByText(/Resets .*EUR/)).not.toBeInTheDocument(); + expect(container.querySelectorAll(".provider-usage-bar__reset")).toHaveLength( + hasReset ? 2 : 0, + ); + }, + ); + it("filters only hidden metric and extra rows", async () => { const detail = provider(); detail.weekly = rateWindow(30); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..b5babf826c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -9,6 +9,7 @@ import { ProviderDisplayRow } from "../../../../components/ProviderDisplayRow"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; +import { resetDescriptionFallback, windowDetailText } from "../../../../lib/usageWindows"; interface Props { provider: ProviderDetail; @@ -121,9 +122,10 @@ function UsageBar({ const usedPct = Number.isFinite(rate.usedPercent) ? Math.max(0, rate.usedPercent) : 0; const pct = Math.min(100, usedPct); const isInformational = rate.isInformational === true; + const detailText = windowDetailText(rate); const formattedReset = useFormattedResetTime( rate.resetsAt, - rate.resetDescription, + resetDescriptionFallback(rate), resetTimeRelative, ); const resetHint = formattedReset @@ -161,6 +163,7 @@ function UsageBar({ {!isInformational && resetHint && ( {resetHint} )} + {detailText && {detailText}} ); } \ No newline at end of file diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..53d8c843ea 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -552,6 +552,8 @@ export interface RateWindowSnapshot { resetDescription: string | null; isExhausted: boolean; isInformational?: boolean; + /** `resetDescription` is a detail line (for example spend amounts), not reset wording. */ + descriptionIsDetail?: boolean; reservePercent: number | null; reserveDescription: string | null; reserveWillLastToReset?: boolean; diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index 62fd50310d..722a0c663a 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -325,17 +325,34 @@ fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, } let bar = render_progress_bar(window.used_percent, 20, use_color); - let reset = window - .format_countdown() - .map(|c| format!(" (resets in {})", c)) - .unwrap_or_default(); + let countdown = window.format_countdown(); + let reset_suffix = match (window.description_is_detail, countdown.as_deref()) { + (false, Some(countdown)) => format!(" (resets in {countdown})"), + _ => String::new(), + }; lines.push(format!( " {:<8} {} {} used{}", format!("{}:", label), bar, format_percent(window.used_percent), - reset + reset_suffix, )); + + if window.description_is_detail { + // The description is a detail line (for example spend amounts), so + // the reset stays on its own line and only exists with a real date. + if let Some(countdown) = countdown { + lines.push(format!(" resets in {countdown}")); + } + if let Some(detail) = window + .reset_description + .as_deref() + .map(str::trim) + .filter(|detail| !detail.is_empty()) + { + lines.push(format!(" {detail}")); + } + } } fn append_secondary_window_line( diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index d332131b81..dcba8fad01 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -2,8 +2,8 @@ use super::*; use crate::core::{ - CostSnapshot, ProviderAccountData, ProviderDisplayDetail, ProviderInventoryItem, RateWindow, - TokenAccount, TokenAccountSupport, UsageSnapshot, + CostSnapshot, NamedRateWindow, ProviderAccountData, ProviderDisplayDetail, + ProviderInventoryItem, RateWindow, TokenAccount, TokenAccountSupport, UsageSnapshot, }; use crate::providers::claude::claude_swap::ClaudeSwapAccount; use crate::status::{ProviderStatus as StatusInfo, StatusLevel}; @@ -324,3 +324,91 @@ fn json_inventory_is_additive_and_contains_no_redemption_token() { .contains("coupon-token-secret") ); } + +fn detail_window(used: f64, detail: &str, resets_at: Option>) -> RateWindow { + RateWindow::with_details(used, None, resets_at, Some(detail.to_string())) + .with_description_as_detail() +} + +fn detail_backed_result(resets_at: Option>) -> ProviderFetchResult { + let mut usage = UsageSnapshot::new(detail_window( + 75.0, + "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + resets_at, + )) + .with_primary_label("Included API"); + usage.extra_rate_windows.push(NamedRateWindow::new( + "mistral-monthly-plan", + "Monthly Plan", + detail_window( + 13.0, + "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + resets_at, + ), + )); + fetch_result(usage) +} + +#[test] +fn detail_backed_windows_print_reset_then_amounts_lines() { + let resets_at = Utc::now() + chrono::Duration::minutes(61); + let output = render_text( + ProviderId::Mistral, + &detail_backed_result(Some(resets_at)), + false, + ); + let lines: Vec<&str> = output.lines().collect(); + + let primary = lines + .iter() + .position(|line| line.starts_with(" Included API:")) + .expect("primary line"); + assert!(lines[primary].ends_with("75% used")); + assert!(lines[primary + 1].starts_with(" resets in ")); + assert_eq!( + lines[primary + 2], + " 19.17 EUR / 25.50 EUR · 6.33 EUR remaining" + ); + let plan = lines + .iter() + .position(|line| line.starts_with(" Monthly Plan:")) + .expect("plan line"); + assert!(lines[plan].ends_with("13% used")); + assert!(lines[plan + 1].starts_with(" resets in ")); + assert_eq!( + lines[plan + 2], + " 34.07 EUR / 255.00 EUR · 220.93 EUR remaining" + ); + assert!(!output.contains("(resets in")); +} + +#[test] +fn detail_backed_windows_omit_reset_line_without_reset_date() { + let output = render_text(ProviderId::Mistral, &detail_backed_result(None), false); + let lines: Vec<&str> = output.lines().collect(); + + assert!(!output.contains("resets in")); + let plan = lines + .iter() + .position(|line| line.starts_with(" Monthly Plan:")) + .expect("plan line"); + assert_eq!( + lines[plan + 1], + " 34.07 EUR / 255.00 EUR · 220.93 EUR remaining" + ); +} + +#[test] +fn detail_backed_flag_stays_out_of_json_output() { + let json = render_json_result(ProviderId::Mistral, detail_backed_result(None), None); + let windows = json["usage"]["extra_rate_windows"] + .as_array() + .expect("extra windows"); + assert_eq!(windows[0]["id"], "mistral-monthly-plan"); + assert_eq!( + windows[0]["window"]["reset_description"], + "34.07 EUR / 255.00 EUR · 220.93 EUR remaining" + ); + assert!(windows[0]["window"].get("descriptionIsDetail").is_none()); + assert!(windows[0]["window"].get("description_is_detail").is_none()); +} diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 1e943df983..cf250ef73d 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -92,6 +92,13 @@ pub struct RateWindow { #[serde(default)] pub is_informational: bool, + /// Whether `reset_description` is a detail line (for example spend + /// amounts) rather than reset wording. Renderers show it as its own + /// secondary line and never as a reset time. Provider-owned; kept out of + /// serialized output so CLI JSON is unchanged. + #[serde(default, skip_serializing)] + pub description_is_detail: bool, + /// Whether the provider explicitly supplied the usage percentage. /// Internal-only metadata prevents a missing value normalized to zero from /// becoming an exported quota measurement. @@ -112,6 +119,7 @@ impl RateWindow { resets_at: None, reset_description: None, is_informational: false, + description_is_detail: false, usage_known: true, } } @@ -152,10 +160,17 @@ impl RateWindow { resets_at, reset_description, is_informational: false, + description_is_detail: false, usage_known: true, } } + /// Mark `reset_description` as a detail line instead of reset wording. + pub fn with_description_as_detail(mut self) -> Self { + self.description_is_detail = true; + self + } + pub(crate) fn with_usage_known(mut self, usage_known: bool) -> Self { self.usage_known = usage_known; self diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index c1f8793c21..9b97b0d7b5 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -496,6 +496,7 @@ impl UsableQuota { resets_at: reset, reset_description, is_informational: false, + description_is_detail: false, usage_known: true, } } diff --git a/rust/src/providers/mistral/mod.rs b/rust/src/providers/mistral/mod.rs index bcaa15e48c..33f82bc908 100644 --- a/rust/src/providers/mistral/mod.rs +++ b/rust/src/providers/mistral/mod.rs @@ -390,7 +390,9 @@ impl MistralProvider { "No usage this month".to_string() }; - let primary = RateWindow::with_details(0.0, None, reset_date, Some(cost_description)); + // The description carries spend amounts, not reset wording. + let primary = RateWindow::with_details(0.0, None, reset_date, Some(cost_description)) + .with_description_as_detail(); let mut usage = UsageSnapshot::new(primary); if summary.model_count > 0 { usage = usage.with_login_method(format!("{} model(s)", summary.model_count)); @@ -443,6 +445,7 @@ impl MistralProvider { budget.resets_at, Some(description), ) + .with_description_as_detail() } fn build_price_index(prices: Vec) -> HashMap { @@ -662,6 +665,19 @@ mod tests { "mistral-monthly-plan" ); assert_eq!(result.cost.as_ref().map(|cost| cost.used), Some(12.5)); + assert!(result.usage.primary.description_is_detail); + assert!( + result.usage.extra_rate_windows[0] + .window + .description_is_detail + ); + assert_eq!( + result.usage.extra_rate_windows[0] + .window + .reset_description + .as_deref(), + Some("10.00 EUR / 20.00 EUR · 10.00 EUR remaining") + ); } #[test]