From 4ceb88f03c76177e56a9459997999877a364d231 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:34:59 +0700 Subject: [PATCH 1/3] Add transient provider inventory plumbing --- .../src-tauri/src/commands/bridge.rs | 24 +++++ .../src-tauri/src/commands/provider_detail.rs | 3 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 44 +++++++- apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../src/components/MenuCardDetails.tsx | 51 ++++++++- .../providers/sections/UsageSection.test.tsx | 23 ++++ .../providers/sections/UsageSection.tsx | 33 +++++- apps/desktop-tauri/src/types/bridge.ts | 11 ++ rust/src/cli/usage.rs | 100 +++++++++++++++++- rust/src/core/usage_snapshot.rs | 50 +++++++++ 13 files changed, 338 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8cd5e91924..1a86eab7b4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -178,6 +178,17 @@ pub struct SubscriptionMetadataSnapshot { pub renews_at: Option, } +/// Display-only provider inventory. Redemption identifiers never cross the +/// bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryItemSnapshot { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option, +} + /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -200,6 +211,8 @@ pub struct ProviderUsageSnapshot { pub tertiary: Option, #[serde(default)] pub extra_rate_windows: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inventory: Vec, #[serde(default)] pub cost: Option, #[serde(default)] @@ -384,6 +397,16 @@ impl ProviderUsageSnapshot { window: RateWindowSnapshot::from_rate_window(&extra.window), }) .collect(), + inventory: result + .inventory + .iter() + .map(|item| ProviderInventoryItemSnapshot { + id: item.id.clone(), + title: item.title.clone(), + available_count: item.available_count, + next_expires_at: item.next_expires_at.map(|date| date.to_rfc3339()), + }) + .collect(), cost: result.cost.as_ref().map(|c| CostSnapshotBridge { used: c.used, limit: c.limit, @@ -461,6 +484,7 @@ impl ProviderUsageSnapshot { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 7690a5b2aa..18b1def39b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -26,6 +26,7 @@ pub struct ProviderDetail { pub model_specific: Option, pub tertiary: Option, pub extra_rate_windows: Vec, + pub inventory: Vec, // Cost / pace. pub cost: Option, @@ -91,6 +92,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result::from_timestamp(1_900_000_000, 0).unwrap(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(12.0)), + "web", + ) + .with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Grok, &metadata, &result, None); + + assert_eq!(snapshot.inventory.len(), 1); + assert_eq!(snapshot.inventory[0].available_count, 2); + assert_eq!( + snapshot.inventory[0].next_expires_at.as_deref(), + Some("2030-03-17T17:46:40+00:00") + ); + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(serialized.contains("reset-credits")); + assert!(!serialized.contains("coupon-token-secret")); +} + #[test] fn provider_cache_is_fresh_inside_stale_window() { assert!(super::is_provider_cache_fresh( @@ -1057,6 +1085,7 @@ fn provider_cache_upsert_replaces_existing_provider() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1083,6 +1112,7 @@ fn provider_cache_prunes_disabled_providers() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1116,6 +1146,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1149,6 +1180,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1184,6 +1216,7 @@ fn codex_transient_transport_failure_helper_uses_typed_policy() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1218,6 +1251,7 @@ fn claude_repeated_auth_failure_surfaces_error() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1259,6 +1293,7 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1311,6 +1346,7 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "Web".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1360,6 +1396,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: true, pace_authoritative: true, @@ -1405,6 +1442,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1575,6 +1613,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1607,6 +1646,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 4f5ce88465..f38f080161 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -195,6 +195,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), @@ -243,6 +244,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 00ba7aff36..0ec2e19b85 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1080,6 +1080,7 @@ mod tests { }), tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { used, limit: Some(limit), diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 73d642534c..697b94d7a2 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -296,6 +296,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 4d0b47d24d..8225a10817 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -3,6 +3,7 @@ import type { CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, + ProviderInventoryItem, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -413,6 +414,7 @@ function MetricRow({ export interface MenuCardPresence { hasMetrics: boolean; + hasInventory: boolean; hasCost: boolean; hasPace: boolean; hasCharts: boolean; @@ -456,6 +458,7 @@ export function describeCard( const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasMetrics = visibleMetrics.length > 0; + const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); @@ -465,9 +468,16 @@ export function describeCard( !!provider.pace; const hasDetails = !provider.error && - (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage || !!wayfinderUsage); + (hasMetrics || + hasInventory || + hasCost || + hasPace || + hasCharts || + !!localUsage || + !!wayfinderUsage); return { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -505,6 +515,7 @@ export default function MenuCardDetails({ const { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -540,6 +551,18 @@ export default function MenuCardDetails({ )} + {!provider.error && hasInventory && ( +
+ {provider.inventory?.map((item) => ( + + ))} +
+ )} + {wayfinderUsage && } {hasMetrics && hasCost &&
} @@ -712,3 +735,29 @@ export default function MenuCardDetails({
); } + +function InventoryItemRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && ( + + {formattedExpiry} + + )} +
+ ); +} 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 1a4807c882..c32df0cf15 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 @@ -102,4 +102,27 @@ describe("UsageSection", () => { expect(label.parentElement).toHaveTextContent("No active 5h session"); expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); }); + + it("renders discrete inventory without turning it into a quota bar", async () => { + const detail = provider(); + detail.session = null; + detail.extraRateWindows = []; + detail.inventory = [ + { + id: "reset-credits", + title: "Limit Reset Credits", + availableCount: 2, + nextExpiresAt: "2099-01-01T00:00:00Z", + }, + ]; + + const { container } = render( + + key} /> + , + ); + + expect(await screen.findByText(/Limit Reset Credits: 2 available/)).toBeInTheDocument(); + expect(container.querySelector(".provider-usage-bar__track")).toBeNull(); + }); }); 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 a51c294ed2..64a3fa7b6a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,4 +1,5 @@ import type { + ProviderInventoryItem, ProviderDetail, RateWindowSnapshot, } from "../../../../types/bridge"; @@ -60,7 +61,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { }); } - if (bars.length === 0) { + const inventory = provider.inventory ?? []; + if (bars.length === 0 && inventory.length === 0) { return null; } @@ -76,10 +78,39 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { t={t} /> ))} + {inventory.map((item) => ( + + ))} ); } +function InventoryRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && {formattedExpiry}} +
+ ); +} + function UsageBar({ label, rate, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 865625efeb..963779ae2f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -592,6 +592,13 @@ export interface SubscriptionMetadataSnapshot { renewsAt: string | null; } +export interface ProviderInventoryItem { + id: string; + title: string; + availableCount: number; + nextExpiresAt: string | null; +} + /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = | "ready" @@ -618,6 +625,8 @@ export interface ProviderUsageSnapshot { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; @@ -892,6 +901,8 @@ export interface ProviderDetail { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index e0c19618d9..25b988e5c0 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,11 +1,13 @@ //! Usage command implementation +use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; use crate::core::{ - CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, RateWindow, SourceMode, - TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, instantiate_provider, + CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, ProviderInventoryItem, RateWindow, + SourceMode, TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, + instantiate_provider, }; use crate::settings::ApiKeys; use crate::status::{ProviderStatus as StatusInfo, StatusLevel, fetch_provider_status}; @@ -396,6 +398,23 @@ fn render_json_result( }); } + if !result.inventory.is_empty() { + json_result["inventory"] = serde_json::Value::Array( + result + .inventory + .iter() + .map(|item| { + serde_json::json!({ + "id": &item.id, + "title": &item.title, + "availableCount": item.available_count, + "nextExpiresAt": item.next_expires_at.map(|date| date.to_rfc3339()), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -460,6 +479,7 @@ pub fn render_text_with_status( append_status_line(&mut lines, status); append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); + append_inventory_lines(&mut lines, &result.inventory); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -579,6 +599,38 @@ fn append_usage_window_lines( } } +fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventoryItem]) { + if inventory.is_empty() { + return; + } + let now = Utc::now(); + for item in inventory { + lines.push(format!( + " {}: {} available", + item.title, item.available_count + )); + if let Some(expires_at) = item.next_expires_at { + lines.push(format!( + " Next expires in {}", + format_inventory_countdown(expires_at, now) + )); + } + } +} + +fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { + let seconds = expires_at.signed_duration_since(now).num_seconds(); + if seconds <= 0 { + return "now".to_string(); + } + let minutes = (seconds + 59) / 60; + if minutes >= 24 * 60 { + format!("{}d {}h", minutes / (24 * 60), (minutes / 60) % 24) + } else { + format!("{}h {}m", minutes / 60, minutes % 60) + } +} + fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { if window.is_informational { let description = window.reset_description.as_deref().unwrap_or("unavailable"); @@ -935,6 +987,50 @@ mod tests { ); } + #[test] + fn inventory_is_rendered_in_full_text_but_not_brief_text() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(Utc::now() + chrono::Duration::hours(3)), + }, + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let brief = render_brief_text(ProviderId::Grok, &result); + + assert!(full.contains("Limit Reset Credits: 2 available")); + assert!(full.contains("Next expires in")); + assert!(!brief.contains("Limit Reset Credits")); + } + + #[test] + fn json_inventory_is_additive_and_contains_no_redemption_token() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 1, + next_expires_at: None, + }, + ); + + let json = render_json_result(ProviderId::Grok, result, None); + assert_eq!(json["inventory"][0]["availableCount"], 1); + assert!( + serde_json::to_string(&json) + .unwrap() + .contains("reset-credits") + ); + assert!( + !serde_json::to_string(&json) + .unwrap() + .contains("coupon-token-secret") + ); + } + #[test] fn secondary_label_override_is_shared_by_full_and_brief_renderers() { let result = fetch_result( diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 3cfcd2ccf2..b0a1440814 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -85,6 +85,21 @@ pub struct NamedRateWindow { pub usage_known: bool, } +/// One display-only item of provider-issued discrete inventory. +/// +/// This is deliberately separate from [`RateWindow`]: inventory does not +/// represent a percentage quota and must not participate in quota arithmetic, +/// tray metric selection, pace, notifications, or auto-resume decisions. +/// Provider-specific redemption identifiers stay private to the provider +/// parser and never enter this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderInventoryItem { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option>, +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -585,6 +600,13 @@ pub struct ProviderFetchResult { #[serde(skip_serializing_if = "Option::is_none")] pub wayfinder_usage: Option, + /// Transient non-quota inventory for provider-specific display. + /// + /// The field is intentionally skipped by serde: it belongs to the current + /// fetch and must not change persisted `ProviderFetchResult` JSON. + #[serde(skip)] + pub inventory: Vec, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, @@ -613,6 +635,7 @@ impl ProviderFetchResult { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: source_label.into(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -652,6 +675,12 @@ impl ProviderFetchResult { self.wayfinder_usage = Some(usage); self } + + /// Attach one display-only inventory item without exposing redemption IDs. + pub fn with_inventory_item(mut self, item: ProviderInventoryItem) -> Self { + self.inventory.push(item); + self + } } #[cfg(test)] @@ -669,6 +698,27 @@ mod tests { ); } + #[test] + fn fetch_result_inventory_is_transient_and_not_serialized() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let expiry = DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); + let result = + ProviderFetchResult::new(usage, "api").with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + + assert_eq!(result.inventory.len(), 1); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("inventory").is_none()); + assert!(encoded.get("reset-credits").is_none()); + + let decoded: ProviderFetchResult = serde_json::from_value(encoded).unwrap(); + assert!(decoded.inventory.is_empty()); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); From ac695cd7b5e9ed07426964c3a9ea5126f95dd702 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:48:25 +0700 Subject: [PATCH 2/3] Extract usage renderers below 1k lines and reuse the canonical countdown --- rust/src/cli/usage.rs | 580 ++-------------------------- rust/src/cli/usage/fetch_helpers.rs | 112 ++++++ rust/src/cli/usage/render.rs | 434 +++++++++++++++++++++ rust/src/core/rate_window.rs | 63 ++- 4 files changed, 616 insertions(+), 573 deletions(-) create mode 100644 rust/src/cli/usage/fetch_helpers.rs create mode 100644 rust/src/cli/usage/render.rs diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 25b988e5c0..bf43bcaa14 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,18 +1,29 @@ //! Usage command implementation -use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; -use crate::core::{ - CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, ProviderInventoryItem, RateWindow, - SourceMode, TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, - instantiate_provider, -}; -use crate::settings::ApiKeys; -use crate::status::{ProviderStatus as StatusInfo, StatusLevel, fetch_provider_status}; +use crate::core::{FetchContext, ProviderFetchResult, ProviderId, SourceMode}; mod claude_swap; +mod fetch_helpers; +mod render; + +use fetch_helpers::{fetch_provider_json_output, fetch_provider_text_output}; +pub(super) use render::{ + append_status_line, format_percent, render_status_indicator, render_text_error, +}; +use render::{is_terminal, print_usage_output}; +pub use render::{render_brief_text, render_text, render_text_with_status}; + +pub(super) enum UsageOutput { + Text(Vec), + Json { + results: Vec, + pretty: bool, + }, + Toon(Vec), +} pub const PROVIDER_ARG_HELP: &str = "Provider to query (for example: codex, claude, gemini, antigravity/agy, nanogpt, deepseek, codebuff, windsurf, all, both)"; @@ -247,555 +258,18 @@ fn build_usage_fetch_context(args: &UsageArgs, source_mode: SourceMode) -> Fetch } } -enum UsageOutput { - Text(Vec), - Json { - results: Vec, - pretty: bool, - }, - Toon(Vec), -} - -async fn fetch_provider_text_output(provider_id: ProviderId, command: &UsageCommand) -> String { - match fetch_provider_result(provider_id, command).await { - Ok((result, status)) => { - if command.brief { - render_brief_text(provider_id, &result) - } else { - render_text_with_status(provider_id, &result, status.as_ref(), command.use_color) - } - } - Err(e) => render_text_error(provider_id, &e.to_string(), command.use_color), - } -} - -async fn fetch_provider_json_output( - provider_id: ProviderId, - command: &UsageCommand, -) -> serde_json::Value { - match fetch_provider_result(provider_id, command).await { - Ok((result, status)) => render_json_result(provider_id, result, status.as_ref()), - Err(e) => serde_json::json!({ - "provider": provider_id.cli_name(), - "error": e.to_string(), - }), - } -} - -async fn fetch_provider_result( - provider_id: ProviderId, - command: &UsageCommand, -) -> anyhow::Result<(ProviderFetchResult, Option)> { - let provider = instantiate_provider(provider_id); - let status_future = command - .fetch_status - .then(|| fetch_provider_status(provider_id.cli_name())); - let mut ctx = command.ctx.clone(); - if ctx.api_key.is_none() { - ctx.api_key = resolve_cli_api_key(provider_id, command.account.as_deref())?; - } - let result = provider.fetch_usage(&ctx).await?; - let status = if let Some(fut) = status_future { - fut.await - } else { - None - }; - Ok((result, status)) -} - -/// Resolve an API key from token accounts (active or `--account`) then stored keys. -/// -/// Token-account env injection takes precedence over `api_keys.json` so multi-key -/// providers (OpenRouter, z.ai, ...) honor the selected labeled account. -fn resolve_cli_api_key( - provider_id: ProviderId, - account_ref: Option<&str>, -) -> anyhow::Result> { - if TokenAccountSupport::is_supported(provider_id) - && let Ok(data) = TokenAccountStore::new().load_provider(provider_id) - && !data.accounts.is_empty() - { - let account = if let Some(account_ref) = account_ref { - find_token_account(&data, account_ref)? - } else { - data.active_account().ok_or_else(|| { - anyhow::anyhow!("No active token account for {}", provider_id.display_name()) - })? - }; - if let Some(env) = TokenAccountSupport::env_override(provider_id, &account.token) - && let Some(key) = env.into_values().next() - { - return Ok(Some(key)); - } - } - - Ok(ApiKeys::load() - .get(provider_id.cli_name()) - .map(|s| s.to_string())) -} - -fn find_token_account<'a>( - data: &'a crate::core::ProviderAccountData, - account_ref: &str, -) -> anyhow::Result<&'a crate::core::TokenAccount> { - if let Ok(idx) = account_ref.parse::() - && idx > 0 - && idx <= data.accounts.len() - { - return Ok(&data.accounts[idx - 1]); - } - if let Some(account) = data - .accounts - .iter() - .find(|a| a.label.eq_ignore_ascii_case(account_ref)) - { - return Ok(account); - } - anyhow::bail!( - "Account '{}' not found. Use 'codexbar account list ' to see accounts.", - account_ref - ) -} - -fn render_text_error(provider_id: ProviderId, error_msg: &str, use_color: bool) -> String { - let header = if use_color { - format!("\x1b[1m{}\x1b[0m", provider_id.display_name()) - } else { - provider_id.display_name().to_string() - }; - format!("{} Error: {}", header, error_msg) -} - -fn render_json_result( - provider_id: ProviderId, - result: ProviderFetchResult, - status: Option<&StatusInfo>, -) -> serde_json::Value { - let usage = &result.usage; - let primary_pace = usage - .primary - .window_minutes - .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) - .then(|| UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES)) - .flatten() - .map(pace_json); - let secondary_pace = usage - .secondary - .as_ref() - .and_then(|w| UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080))) - .map(pace_json); - - let mut json_result = serde_json::json!({ - "provider": provider_id.cli_name(), - "source": result.source_label, - "usage": result.usage, - "cost": result.cost, - }); - if primary_pace.is_some() || secondary_pace.is_some() { - json_result["pace"] = serde_json::json!({ - "primary": primary_pace, - "secondary": secondary_pace, - }); - } - - if !result.inventory.is_empty() { - json_result["inventory"] = serde_json::Value::Array( - result - .inventory - .iter() - .map(|item| { - serde_json::json!({ - "id": &item.id, - "title": &item.title, - "availableCount": item.available_count, - "nextExpiresAt": item.next_expires_at.map(|date| date.to_rfc3339()), - }) - }) - .collect(), - ); - } - - if let Some(s) = status { - json_result["status"] = serde_json::json!({ - "level": format!("{:?}", s.level).to_lowercase(), - "description": s.description, - }); - } - - json_result -} - -/// Serialize a [`UsagePace`] into a compact JSON object for the `--json` output. -fn pace_json(pace: UsagePace) -> serde_json::Value { - serde_json::json!({ - "stage": format!("{:?}", pace.stage).to_lowercase(), - "deltaPercent": pace.delta_percent, - "expectedUsedPercent": pace.expected_used_percent, - "willLastToReset": pace.will_last_to_reset, - }) -} - -fn print_usage_output(output: UsageOutput) -> anyhow::Result<()> { - match output { - UsageOutput::Text(sections) => { - println!("{}", sections.join("\n\n")); - } - UsageOutput::Json { results, pretty } => { - let output = if pretty { - serde_json::to_string_pretty(&results)? - } else { - serde_json::to_string(&results)? - }; - println!("{}", output); - } - UsageOutput::Toon(results) => { - println!( - "{}", - super::toon::encode(&serde_json::Value::Array(results)) - ); - } - } - - Ok(()) -} - -/// Check if stdout is a terminal -fn is_terminal() -> bool { - use std::io::IsTerminal; - std::io::stdout().is_terminal() -} - -/// Render usage as text with optional status -pub fn render_text_with_status( - provider: ProviderId, - result: &ProviderFetchResult, - status: Option<&StatusInfo>, - use_color: bool, -) -> String { - let mut lines = Vec::new(); - let metadata = instantiate_provider(provider).metadata().clone(); - - lines.push(render_usage_header(provider, result, status, use_color)); - append_status_line(&mut lines, status); - append_account_lines(&mut lines, &result.usage); - append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); - append_inventory_lines(&mut lines, &result.inventory); - append_cost_line(&mut lines, result.cost.as_ref()); - - lines.join("\n") -} - -fn render_usage_header( - provider: ProviderId, - result: &ProviderFetchResult, - status: Option<&StatusInfo>, - use_color: bool, -) -> String { - let status_indicator = render_status_indicator(status, use_color); - if use_color { - format!( - "\x1b[1m{}\x1b[0m ({}){}", - provider.display_name(), - result.source_label, - status_indicator - ) - } else { - format!( - "{} ({}){}", - provider.display_name(), - result.source_label, - status_indicator - ) - } -} - -fn render_status_indicator(status: Option<&StatusInfo>, use_color: bool) -> String { - let Some(status) = status else { - return String::new(); - }; - - let (symbol, color) = match status.level { - StatusLevel::Operational => ("●", "\x1b[32m"), // Green - StatusLevel::Degraded => ("◐", "\x1b[33m"), // Yellow - StatusLevel::Partial => ("◑", "\x1b[33m"), // Yellow - StatusLevel::Major => ("○", "\x1b[31m"), // Red - StatusLevel::Unknown => ("?", "\x1b[90m"), // Gray - }; - - if use_color { - format!(" {}{}\x1b[0m", color, symbol) - } else { - format!(" {}", symbol) - } -} - -fn append_status_line(lines: &mut Vec, status: Option<&StatusInfo>) { - if let Some(s) = status - && s.level != StatusLevel::Operational - && s.level != StatusLevel::Unknown - { - lines.push(format!(" Status: {}", s.description)); - } -} - -fn append_account_lines(lines: &mut Vec, usage: &UsageSnapshot) { - if let Some(ref email) = usage.account_email { - lines.push(format!(" Account: {}", email)); - } - if let Some(ref method) = usage.login_method { - lines.push(format!(" Plan: {}", method)); - } -} - -fn append_usage_window_lines( - lines: &mut Vec, - usage: &UsageSnapshot, - metadata: &crate::core::ProviderMetadata, - use_color: bool, -) { - let primary_label = usage - .primary_label - .as_deref() - .unwrap_or(metadata.session_label); - append_window_line(lines, primary_label, &usage.primary, use_color); - // Pace for primary windows whose provider published a common cadence. - let pace_minutes = usage.primary.window_minutes.filter(|minutes| { - *minutes == crate::core::SESSION_WINDOW_MINUTES - || *minutes >= crate::core::WEEKLY_WINDOW_MINUTES - }); - if let Some(minutes) = pace_minutes - && let Some(pace) = UsagePace::weekly(&usage.primary, None, minutes) - { - lines.push(format!( - " Pace: {} {}", - pace.stage.emoji(), - pace.format_status() - )); - } - append_secondary_window_line( - lines, - usage.secondary.as_ref(), - usage - .secondary_label - .as_deref() - .unwrap_or(metadata.weekly_label), - use_color, - ); - append_model_specific_line(lines, usage.model_specific.as_ref(), use_color); - // F5 (upstream 0.48.0): monthly (30-day) lane. Label by duration cadence. - if let Some(tertiary) = usage.tertiary.as_ref() { - let cadence = - crate::core::RateWindowCadence::from_minutes(tertiary.window_minutes.unwrap_or(0)); - let label = match cadence { - crate::core::RateWindowCadence::Monthly => "Monthly", - _ => "Tertiary", - }; - append_window_line(lines, label, tertiary, use_color); - } - for extra in &usage.extra_rate_windows { - if extra.usage_known { - append_window_line(lines, &extra.title, &extra.window, use_color); - } - } -} - -fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventoryItem]) { - if inventory.is_empty() { - return; - } - let now = Utc::now(); - for item in inventory { - lines.push(format!( - " {}: {} available", - item.title, item.available_count - )); - if let Some(expires_at) = item.next_expires_at { - lines.push(format!( - " Next expires in {}", - format_inventory_countdown(expires_at, now) - )); - } - } -} - -fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { - let seconds = expires_at.signed_duration_since(now).num_seconds(); - if seconds <= 0 { - return "now".to_string(); - } - let minutes = (seconds + 59) / 60; - if minutes >= 24 * 60 { - format!("{}d {}h", minutes / (24 * 60), (minutes / 60) % 24) - } else { - format!("{}h {}m", minutes / 60, minutes % 60) - } -} - -fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { - if window.is_informational { - let description = window.reset_description.as_deref().unwrap_or("unavailable"); - lines.push(format!(" {:<8} {}", format!("{}:", label), description)); - return; - } - - 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(); - lines.push(format!( - " {:<8} {} {} used{}", - format!("{}:", label), - bar, - format_percent(window.used_percent), - reset - )); -} - -fn append_secondary_window_line( - lines: &mut Vec, - secondary: Option<&RateWindow>, - label: &str, - use_color: bool, -) { - if let Some(secondary) = secondary { - append_window_line(lines, label, secondary, use_color); - let window_minutes = secondary.window_minutes.unwrap_or(10080); - if let Some(pace) = UsagePace::weekly(secondary, None, window_minutes) { - lines.push(format!( - " Pace: {} {}", - pace.stage.emoji(), - pace.format_status() - )); - } - } -} - -fn append_model_specific_line( - lines: &mut Vec, - model_specific: Option<&RateWindow>, - use_color: bool, -) { - if let Some(opus) = model_specific { - let opus_bar = render_progress_bar(opus.used_percent, 20, use_color); - lines.push(format!( - " Opus: {} {} used", - opus_bar, - format_percent(opus.used_percent) - )); - } -} - -pub fn render_brief_text(provider: ProviderId, result: &ProviderFetchResult) -> String { - let metadata = instantiate_provider(provider).metadata().clone(); - let usage = &result.usage; - let primary_label = usage - .primary_label - .as_deref() - .unwrap_or(metadata.session_label); - let mut parts = Vec::new(); - let reset = if usage.primary.is_informational { - parts.push(format!("{primary_label} unavailable")); - usage.secondary.as_ref().unwrap_or(&usage.primary) - } else { - parts.push(format!( - "{} {}", - primary_label, - format_percent(usage.primary.used_percent) - )); - &usage.primary - } - .format_countdown() - .unwrap_or_else(|| "n/a".to_string()); - if let Some(secondary) = &usage.secondary { - parts.push(format!( - "{} {}", - usage - .secondary_label - .as_deref() - .unwrap_or(metadata.weekly_label), - format_percent(secondary.used_percent) - )); - } - parts.push(format!("resets {reset}")); - if let Some(plan) = &usage.login_method { - parts.push(plan.clone()); - } - format!("{}: {}", provider.display_name(), parts.join(", ")) -} - -fn format_percent(percent: f64) -> String { - if !percent.is_finite() { - "0%".to_string() - } else if percent > 0.0 && percent < 1.0 { - "<1%".to_string() - } else { - format!("{:.0}%", percent.clamp(0.0, 100.0)) - } -} - -fn append_cost_line(lines: &mut Vec, cost: Option<&CostSnapshot>) { - let Some(cost) = cost else { - return; - }; - - if let Some(limit) = cost.format_limit() { - lines.push(format!( - " Cost: {} / {} ({})", - cost.format_used(), - limit, - cost.period - )); - } else { - lines.push(format!( - " Cost: {} ({})", - cost.format_used(), - cost.period - )); - } -} - -/// Render usage as text (backwards compatible version) -pub fn render_text(provider: ProviderId, result: &ProviderFetchResult, use_color: bool) -> String { - render_text_with_status(provider, result, None, use_color) -} - -/// Render a text-based progress bar -fn render_progress_bar(percent: f64, width: usize, use_color: bool) -> String { - let percent = if percent.is_finite() { - percent.clamp(0.0, 100.0) - } else { - 0.0 - }; - // percent is clamped to 0..=100, so the rounded product cannot exceed width. - #[expect( - clippy::cast_possible_truncation, - reason = "percent clamped to 0..=100, so the product cannot exceed width" - )] - let filled = ((percent / 100.0) * width as f64).round() as usize; - let empty = width.saturating_sub(filled); - - let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty)); - - if use_color { - let color = if percent >= 90.0 { - "\x1b[31m" // Red - } else if percent >= 70.0 { - "\x1b[33m" // Yellow - } else { - "\x1b[32m" // Green - }; - format!("{}{}\x1b[0m", color, bar) - } else { - bar - } -} - #[cfg(test)] mod tests { use super::*; - use crate::core::{ProviderAccountData, TokenAccount, TokenAccountSupport}; + use crate::core::{ + ProviderAccountData, ProviderInventoryItem, RateWindow, TokenAccount, TokenAccountSupport, + UsageSnapshot, + }; use crate::providers::claude::claude_swap::ClaudeSwapAccount; + use crate::status::{ProviderStatus as StatusInfo, StatusLevel}; + use chrono::Utc; + use fetch_helpers::find_token_account; + use render::render_json_result; fn fetch_result(usage: UsageSnapshot) -> ProviderFetchResult { ProviderFetchResult::new(usage, "test") diff --git a/rust/src/cli/usage/fetch_helpers.rs b/rust/src/cli/usage/fetch_helpers.rs new file mode 100644 index 0000000000..d093a2bbe7 --- /dev/null +++ b/rust/src/cli/usage/fetch_helpers.rs @@ -0,0 +1,112 @@ +//! Fetch plumbing shared by the text, JSON, and toon output paths. + +use super::UsageCommand; +use super::render::{ + render_brief_text, render_json_result, render_text_error, render_text_with_status, +}; +use crate::core::{ + ProviderFetchResult, ProviderId, TokenAccountStore, TokenAccountSupport, instantiate_provider, +}; +use crate::settings::ApiKeys; +use crate::status::{ProviderStatus as StatusInfo, fetch_provider_status}; + +pub async fn fetch_provider_text_output(provider_id: ProviderId, command: &UsageCommand) -> String { + match fetch_provider_result(provider_id, command).await { + Ok((result, status)) => { + if command.brief { + render_brief_text(provider_id, &result) + } else { + render_text_with_status(provider_id, &result, status.as_ref(), command.use_color) + } + } + Err(e) => render_text_error(provider_id, &e.to_string(), command.use_color), + } +} + +pub async fn fetch_provider_json_output( + provider_id: ProviderId, + command: &UsageCommand, +) -> serde_json::Value { + match fetch_provider_result(provider_id, command).await { + Ok((result, status)) => render_json_result(provider_id, result, status.as_ref()), + Err(e) => serde_json::json!({ + "provider": provider_id.cli_name(), + "error": e.to_string(), + }), + } +} + +pub async fn fetch_provider_result( + provider_id: ProviderId, + command: &UsageCommand, +) -> anyhow::Result<(ProviderFetchResult, Option)> { + let provider = instantiate_provider(provider_id); + let status_future = command + .fetch_status + .then(|| fetch_provider_status(provider_id.cli_name())); + let mut ctx = command.ctx.clone(); + if ctx.api_key.is_none() { + ctx.api_key = resolve_cli_api_key(provider_id, command.account.as_deref())?; + } + let result = provider.fetch_usage(&ctx).await?; + let status = if let Some(fut) = status_future { + fut.await + } else { + None + }; + Ok((result, status)) +} + +/// Resolve an API key from token accounts (active or `--account`) then stored keys. +/// +/// Token-account env injection takes precedence over `api_keys.json` so multi-key +/// providers (OpenRouter, z.ai, ...) honor the selected labeled account. +fn resolve_cli_api_key( + provider_id: ProviderId, + account_ref: Option<&str>, +) -> anyhow::Result> { + if TokenAccountSupport::is_supported(provider_id) + && let Ok(data) = TokenAccountStore::new().load_provider(provider_id) + && !data.accounts.is_empty() + { + let account = if let Some(account_ref) = account_ref { + find_token_account(&data, account_ref)? + } else { + data.active_account().ok_or_else(|| { + anyhow::anyhow!("No active token account for {}", provider_id.display_name()) + })? + }; + if let Some(env) = TokenAccountSupport::env_override(provider_id, &account.token) + && let Some(key) = env.into_values().next() + { + return Ok(Some(key)); + } + } + + Ok(ApiKeys::load() + .get(provider_id.cli_name()) + .map(|s| s.to_string())) +} + +pub(super) fn find_token_account<'a>( + data: &'a crate::core::ProviderAccountData, + account_ref: &str, +) -> anyhow::Result<&'a crate::core::TokenAccount> { + if let Ok(idx) = account_ref.parse::() + && idx > 0 + && idx <= data.accounts.len() + { + return Ok(&data.accounts[idx - 1]); + } + if let Some(account) = data + .accounts + .iter() + .find(|a| a.label.eq_ignore_ascii_case(account_ref)) + { + return Ok(account); + } + anyhow::bail!( + "Account '{}' not found. Use 'codexbar account list ' to see accounts.", + account_ref + ) +} diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs new file mode 100644 index 0000000000..e50435fae2 --- /dev/null +++ b/rust/src/cli/usage/render.rs @@ -0,0 +1,434 @@ +//! Usage output rendering: text lines, brief lines, JSON projection. +//! +//! Fetch plumbing lives in super::fetch_helpers; this module owns how a +//! fetched ProviderFetchResult becomes terminal text or JSON output. + +use chrono::Utc; + +use super::UsageOutput; +use crate::core::{ + CostSnapshot, ProviderFetchResult, ProviderId, ProviderInventoryItem, RateWindow, UsagePace, + UsageSnapshot, instantiate_provider, +}; +use crate::status::{ProviderStatus as StatusInfo, StatusLevel}; + +pub fn render_text_error(provider_id: ProviderId, error_msg: &str, use_color: bool) -> String { + let header = if use_color { + format!("\x1b[1m{}\x1b[0m", provider_id.display_name()) + } else { + provider_id.display_name().to_string() + }; + format!("{} Error: {}", header, error_msg) +} + +pub fn render_json_result( + provider_id: ProviderId, + result: ProviderFetchResult, + status: Option<&StatusInfo>, +) -> serde_json::Value { + let usage = &result.usage; + let primary_pace = usage + .primary + .window_minutes + .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) + .then(|| UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES)) + .flatten() + .map(pace_json); + let secondary_pace = usage + .secondary + .as_ref() + .and_then(|w| UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080))) + .map(pace_json); + + let mut json_result = serde_json::json!({ + "provider": provider_id.cli_name(), + "source": result.source_label, + "usage": result.usage, + "cost": result.cost, + }); + if primary_pace.is_some() || secondary_pace.is_some() { + json_result["pace"] = serde_json::json!({ + "primary": primary_pace, + "secondary": secondary_pace, + }); + } + + if !result.inventory.is_empty() { + json_result["inventory"] = serde_json::Value::Array( + result + .inventory + .iter() + .map(|item| { + serde_json::json!({ + "id": &item.id, + "title": &item.title, + "availableCount": item.available_count, + "nextExpiresAt": item.next_expires_at.map(|date| date.to_rfc3339()), + }) + }) + .collect(), + ); + } + + if let Some(s) = status { + json_result["status"] = serde_json::json!({ + "level": format!("{:?}", s.level).to_lowercase(), + "description": s.description, + }); + } + + json_result +} + +/// Serialize a [`UsagePace`] into a compact JSON object for the `--json` output. +fn pace_json(pace: UsagePace) -> serde_json::Value { + serde_json::json!({ + "stage": format!("{:?}", pace.stage).to_lowercase(), + "deltaPercent": pace.delta_percent, + "expectedUsedPercent": pace.expected_used_percent, + "willLastToReset": pace.will_last_to_reset, + }) +} + +pub(super) fn print_usage_output(output: UsageOutput) -> anyhow::Result<()> { + match output { + UsageOutput::Text(sections) => { + println!("{}", sections.join("\n\n")); + } + UsageOutput::Json { results, pretty } => { + let output = if pretty { + serde_json::to_string_pretty(&results)? + } else { + serde_json::to_string(&results)? + }; + println!("{}", output); + } + UsageOutput::Toon(results) => { + println!( + "{}", + crate::cli::toon::encode(&serde_json::Value::Array(results)) + ); + } + } + + Ok(()) +} + +/// Check if stdout is a terminal +pub(super) fn is_terminal() -> bool { + use std::io::IsTerminal; + std::io::stdout().is_terminal() +} + +/// Render usage as text with optional status +pub fn render_text_with_status( + provider: ProviderId, + result: &ProviderFetchResult, + status: Option<&StatusInfo>, + use_color: bool, +) -> String { + let mut lines = Vec::new(); + let metadata = instantiate_provider(provider).metadata().clone(); + + lines.push(render_usage_header(provider, result, status, use_color)); + append_status_line(&mut lines, status); + append_account_lines(&mut lines, &result.usage); + append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); + append_inventory_lines(&mut lines, &result.inventory); + append_cost_line(&mut lines, result.cost.as_ref()); + + lines.join("\n") +} + +fn render_usage_header( + provider: ProviderId, + result: &ProviderFetchResult, + status: Option<&StatusInfo>, + use_color: bool, +) -> String { + let status_indicator = render_status_indicator(status, use_color); + if use_color { + format!( + "\x1b[1m{}\x1b[0m ({}){}", + provider.display_name(), + result.source_label, + status_indicator + ) + } else { + format!( + "{} ({}){}", + provider.display_name(), + result.source_label, + status_indicator + ) + } +} + +pub fn render_status_indicator(status: Option<&StatusInfo>, use_color: bool) -> String { + let Some(status) = status else { + return String::new(); + }; + + let (symbol, color) = match status.level { + StatusLevel::Operational => ("●", "\x1b[32m"), // Green + StatusLevel::Degraded => ("◐", "\x1b[33m"), // Yellow + StatusLevel::Partial => ("◑", "\x1b[33m"), // Yellow + StatusLevel::Major => ("○", "\x1b[31m"), // Red + StatusLevel::Unknown => ("?", "\x1b[90m"), // Gray + }; + + if use_color { + format!(" {}{}\x1b[0m", color, symbol) + } else { + format!(" {}", symbol) + } +} + +pub fn append_status_line(lines: &mut Vec, status: Option<&StatusInfo>) { + if let Some(s) = status + && s.level != StatusLevel::Operational + && s.level != StatusLevel::Unknown + { + lines.push(format!(" Status: {}", s.description)); + } +} + +fn append_account_lines(lines: &mut Vec, usage: &UsageSnapshot) { + if let Some(ref email) = usage.account_email { + lines.push(format!(" Account: {}", email)); + } + if let Some(ref method) = usage.login_method { + lines.push(format!(" Plan: {}", method)); + } +} + +fn append_usage_window_lines( + lines: &mut Vec, + usage: &UsageSnapshot, + metadata: &crate::core::ProviderMetadata, + use_color: bool, +) { + let primary_label = usage + .primary_label + .as_deref() + .unwrap_or(metadata.session_label); + append_window_line(lines, primary_label, &usage.primary, use_color); + // Pace for primary windows whose provider published a common cadence. + let pace_minutes = usage.primary.window_minutes.filter(|minutes| { + *minutes == crate::core::SESSION_WINDOW_MINUTES + || *minutes >= crate::core::WEEKLY_WINDOW_MINUTES + }); + if let Some(minutes) = pace_minutes + && let Some(pace) = UsagePace::weekly(&usage.primary, None, minutes) + { + lines.push(format!( + " Pace: {} {}", + pace.stage.emoji(), + pace.format_status() + )); + } + append_secondary_window_line( + lines, + usage.secondary.as_ref(), + usage + .secondary_label + .as_deref() + .unwrap_or(metadata.weekly_label), + use_color, + ); + append_model_specific_line(lines, usage.model_specific.as_ref(), use_color); + // F5 (upstream 0.48.0): monthly (30-day) lane. Label by duration cadence. + if let Some(tertiary) = usage.tertiary.as_ref() { + let cadence = + crate::core::RateWindowCadence::from_minutes(tertiary.window_minutes.unwrap_or(0)); + let label = match cadence { + crate::core::RateWindowCadence::Monthly => "Monthly", + _ => "Tertiary", + }; + append_window_line(lines, label, tertiary, use_color); + } + for extra in &usage.extra_rate_windows { + if extra.usage_known { + append_window_line(lines, &extra.title, &extra.window, use_color); + } + } +} + +fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventoryItem]) { + if inventory.is_empty() { + return; + } + let now = Utc::now(); + for item in inventory { + lines.push(format!( + " {}: {} available", + item.title, item.available_count + )); + if let Some(expires_at) = item.next_expires_at { + lines.push(format!( + " Next expires in {}", + crate::core::format_countdown_until(expires_at, now) + )); + } + } +} + +fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { + if window.is_informational { + let description = window.reset_description.as_deref().unwrap_or("unavailable"); + lines.push(format!(" {:<8} {}", format!("{}:", label), description)); + return; + } + + 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(); + lines.push(format!( + " {:<8} {} {} used{}", + format!("{}:", label), + bar, + format_percent(window.used_percent), + reset + )); +} + +fn append_secondary_window_line( + lines: &mut Vec, + secondary: Option<&RateWindow>, + label: &str, + use_color: bool, +) { + if let Some(secondary) = secondary { + append_window_line(lines, label, secondary, use_color); + let window_minutes = secondary.window_minutes.unwrap_or(10080); + if let Some(pace) = UsagePace::weekly(secondary, None, window_minutes) { + lines.push(format!( + " Pace: {} {}", + pace.stage.emoji(), + pace.format_status() + )); + } + } +} + +fn append_model_specific_line( + lines: &mut Vec, + model_specific: Option<&RateWindow>, + use_color: bool, +) { + if let Some(opus) = model_specific { + let opus_bar = render_progress_bar(opus.used_percent, 20, use_color); + lines.push(format!( + " Opus: {} {} used", + opus_bar, + format_percent(opus.used_percent) + )); + } +} + +pub fn render_brief_text(provider: ProviderId, result: &ProviderFetchResult) -> String { + let metadata = instantiate_provider(provider).metadata().clone(); + let usage = &result.usage; + let primary_label = usage + .primary_label + .as_deref() + .unwrap_or(metadata.session_label); + let mut parts = Vec::new(); + let reset = if usage.primary.is_informational { + parts.push(format!("{primary_label} unavailable")); + usage.secondary.as_ref().unwrap_or(&usage.primary) + } else { + parts.push(format!( + "{} {}", + primary_label, + format_percent(usage.primary.used_percent) + )); + &usage.primary + } + .format_countdown() + .unwrap_or_else(|| "n/a".to_string()); + if let Some(secondary) = &usage.secondary { + parts.push(format!( + "{} {}", + usage + .secondary_label + .as_deref() + .unwrap_or(metadata.weekly_label), + format_percent(secondary.used_percent) + )); + } + parts.push(format!("resets {reset}")); + if let Some(plan) = &usage.login_method { + parts.push(plan.clone()); + } + format!("{}: {}", provider.display_name(), parts.join(", ")) +} + +pub fn format_percent(percent: f64) -> String { + if !percent.is_finite() { + "0%".to_string() + } else if percent > 0.0 && percent < 1.0 { + "<1%".to_string() + } else { + format!("{:.0}%", percent.clamp(0.0, 100.0)) + } +} + +fn append_cost_line(lines: &mut Vec, cost: Option<&CostSnapshot>) { + let Some(cost) = cost else { + return; + }; + + if let Some(limit) = cost.format_limit() { + lines.push(format!( + " Cost: {} / {} ({})", + cost.format_used(), + limit, + cost.period + )); + } else { + lines.push(format!( + " Cost: {} ({})", + cost.format_used(), + cost.period + )); + } +} + +/// Render usage as text (backwards compatible version) +pub fn render_text(provider: ProviderId, result: &ProviderFetchResult, use_color: bool) -> String { + render_text_with_status(provider, result, None, use_color) +} + +/// Render a text-based progress bar +fn render_progress_bar(percent: f64, width: usize, use_color: bool) -> String { + let percent = if percent.is_finite() { + percent.clamp(0.0, 100.0) + } else { + 0.0 + }; + // percent is clamped to 0..=100, so the rounded product cannot exceed width. + #[expect( + clippy::cast_possible_truncation, + reason = "percent clamped to 0..=100, so the product cannot exceed width" + )] + let filled = ((percent / 100.0) * width as f64).round() as usize; + let empty = width.saturating_sub(filled); + + let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty)); + + if use_color { + let color = if percent >= 90.0 { + "\x1b[31m" // Red + } else if percent >= 70.0 { + "\x1b[33m" // Yellow + } else { + "\x1b[32m" // Green + }; + format!("{}{}\x1b[0m", color, bar) + } else { + bar + } +} diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index e4b2086ac5..1e943df983 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -209,26 +209,7 @@ impl RateWindow { /// Format the reset time as a countdown string pub fn format_countdown(&self) -> Option { - let resets_at = self.resets_at?; - let now = Utc::now(); - - if resets_at <= now { - return Some("now".to_string()); - } - - let duration = resets_at - now; - let hours = duration.num_hours(); - let total_minutes = ((duration.num_seconds() + 59) / 60).max(1); - let minutes = total_minutes % 60; - - if hours > 24 { - let days = hours / 24; - Some(format!("{}d {}h", days, hours % 24)) - } else if hours > 0 { - Some(format!("{}h {}m", hours, minutes)) - } else { - Some(format!("{}m", minutes)) - } + Some(format_countdown_until(self.resets_at?, Utc::now())) } fn finite_percent(value: f64) -> f64 { @@ -275,6 +256,32 @@ impl Default for RateWindow { } } +/// Canonical countdown formatting for any future deadline (`{d}d {h}h`, +/// `{h}h {m}m`, `{m}m`, or `"now"` once elapsed). +/// +/// [`RateWindow::format_countdown`] delegates here so every surface (tray, +/// CLI, inventory rows) renders one countdown dialect; the 24-hour boundary +/// is intentionally `> 24` (exactly 24h renders `"24h 0m"`). +pub fn format_countdown_until(until: DateTime, now: DateTime) -> String { + if until <= now { + return "now".to_string(); + } + + let duration = until - now; + let hours = duration.num_hours(); + let total_minutes = ((duration.num_seconds() + 59) / 60).max(1); + let minutes = total_minutes % 60; + + if hours > 24 { + let days = hours / 24; + format!("{}d {}h", days, hours % 24) + } else if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } +} + #[cfg(test)] mod tests { use super::*; @@ -427,4 +434,20 @@ mod tests { assert_eq!(RateWindowCadence::Monthly.label_key(), "monthly"); assert_eq!(RateWindowCadence::Unknown.label_key(), "unknown"); } + + #[test] + fn countdown_exactly_24h_renders_hours_not_days() { + let now = Utc.with_ymd_and_hms(2026, 9, 20, 12, 0, 0).unwrap(); + let until = now + chrono::Duration::hours(24); + + assert_eq!(format_countdown_until(until, now), "24h 0m"); + } + + #[test] + fn countdown_elapsed_deadline_renders_now() { + let now = Utc.with_ymd_and_hms(2026, 9, 20, 12, 0, 0).unwrap(); + let until = now - chrono::Duration::seconds(5); + + assert_eq!(format_countdown_until(until, now), "now"); + } } From 7d1a686d7e9940f1e06427f8f297562eb4e10356 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:48:25 +0700 Subject: [PATCH 3/3] Localize inventory counts and share one inventory row component --- .../src/components/InventoryRows.tsx | 38 +++++++++++++++++++ .../src/components/MenuCardDetails.tsx | 29 ++------------ apps/desktop-tauri/src/i18n/keys.ts | 1 + .../providers/sections/UsageSection.test.tsx | 4 +- .../providers/sections/UsageSection.tsx | 32 ++-------------- rust/src/locale.rs | 1 + rust/src/locale/en-US.ftl | 1 + rust/src/locale/es-MX.ftl | 1 + rust/src/locale/ja-JP.ftl | 1 + rust/src/locale/ko-KR.ftl | 1 + rust/src/locale/ru-RU.ftl | 1 + rust/src/locale/tr-TR.ftl | 1 + rust/src/locale/zh-CN.ftl | 1 + rust/src/locale/zh-TW.ftl | 1 + 14 files changed, 58 insertions(+), 55 deletions(-) create mode 100644 apps/desktop-tauri/src/components/InventoryRows.tsx diff --git a/apps/desktop-tauri/src/components/InventoryRows.tsx b/apps/desktop-tauri/src/components/InventoryRows.tsx new file mode 100644 index 0000000000..ac4a0577df --- /dev/null +++ b/apps/desktop-tauri/src/components/InventoryRows.tsx @@ -0,0 +1,38 @@ +import type { ProviderInventoryItem } from "../types/bridge"; +import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; +import { useLocale } from "../hooks/useLocale"; + +/** + * One discrete-inventory line: "{title}: {count} available [expiry]". + * + * Shared by the tray menu card (`MenuCardDetails`) and the settings provider + * detail (`UsageSection`); each surface passes its own layout classes. + */ +export function InventoryItemRow({ + item, + resetTimeRelative, + lineClassName, + expiryClassName, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; + lineClassName: string; + expiryClassName?: string; +}) { + const { t } = useLocale(); + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {`${item.title}: ${t("InventoryAvailableCount").replace("{}", String(item.availableCount))}`} + {formattedExpiry && expiryClassName && ( + {formattedExpiry} + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 8225a10817..6cc61276a5 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -3,7 +3,6 @@ import type { CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, - ProviderInventoryItem, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -20,6 +19,7 @@ import { formatEta } from "../lib/formatEta"; import type { LocaleKey } from "../i18n/keys"; import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; +import { InventoryItemRow } from "./InventoryRows"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import PaceDetailsChart from "./PaceDetailsChart"; @@ -558,6 +558,8 @@ export default function MenuCardDetails({ key={item.id} item={item} resetTimeRelative={display.resetTimeRelative} + lineClassName="menu-card__cost-line" + expiryClassName="menu-card__cost-line--muted" /> ))} @@ -736,28 +738,3 @@ export default function MenuCardDetails({ ); } -function InventoryItemRow({ - item, - resetTimeRelative, -}: { - item: ProviderInventoryItem; - resetTimeRelative: boolean; -}) { - const formattedExpiry = useFormattedResetTime( - item.nextExpiresAt, - null, - resetTimeRelative, - "expires", - ); - - return ( -
- {item.title}: {item.availableCount} available - {formattedExpiry && ( - - {formattedExpiry} - - )} -
- ); -} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index dbe5d41ac7..5466d4fa1a 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -271,6 +271,7 @@ export const ALL_LOCALE_KEYS = [ "NextExpiresInHoursMinutes", "NextExpiresInMinutes", "NextExpiresDueNow", + "InventoryAvailableCount", "TrayDisplayTitle", "ShowInTray", "CreditsLabel", 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 c32df0cf15..3bac57f7aa 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 @@ -69,7 +69,9 @@ function provider(): ProviderDetail { describe("UsageSection", () => { beforeEach(() => { vi.clearAllMocks(); - tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle()); + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ InventoryAvailableCount: "{} available" }), + ); eventMocks.listen.mockResolvedValue(() => {}); }); 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 64a3fa7b6a..3c0f0a639b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,8 +1,5 @@ -import type { - ProviderInventoryItem, - ProviderDetail, - RateWindowSnapshot, -} from "../../../../types/bridge"; +import type { ProviderDetail, RateWindowSnapshot } from "../../../../types/bridge"; +import { InventoryItemRow } from "../../../../components/InventoryRows"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; @@ -79,38 +76,17 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { /> ))} {inventory.map((item) => ( - ))} ); } -function InventoryRow({ - item, - resetTimeRelative, -}: { - item: ProviderInventoryItem; - resetTimeRelative: boolean; -}) { - const formattedExpiry = useFormattedResetTime( - item.nextExpiresAt, - null, - resetTimeRelative, - "expires", - ); - - return ( -
- {item.title}: {item.availableCount} available - {formattedExpiry && {formattedExpiry}} -
- ); -} - function UsageBar({ label, rate, diff --git a/rust/src/locale.rs b/rust/src/locale.rs index b144f9f487..775db1a39c 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -505,6 +505,7 @@ locale_keys! { NextExpiresInHoursMinutes, NextExpiresInMinutes, NextExpiresDueNow, + InventoryAvailableCount, // Provider detail - Tray Display TrayDisplayTitle, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index ec1424fccb..e7df28688f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -256,6 +256,7 @@ NextExpiresInDaysHours = Next expires in { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Next expires in { "{}" }h { "{}" }m NextExpiresInMinutes = Next expires in { "{}" }m NextExpiresDueNow = Expires now +InventoryAvailableCount = { "{}" } available FloatBarShowCost = Show Local Cost FloatBarShowCostDescription = Shows estimated local usage cost in the floating bar. TrayDisplayTitle = Tray Display diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 1967158871..3723f97a11 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -247,6 +247,7 @@ NextExpiresInDaysHours = Próximo vence en { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Próximo vence en { "{}" }h { "{}" }m NextExpiresInMinutes = Próximo vence en { "{}" }m NextExpiresDueNow = Vence ahora +InventoryAvailableCount = { "{}" } available FloatBarShowCost = Mostrar costo local FloatBarShowCostDescription = Muestra el costo estimado del uso local en la barra flotante. TrayDisplayTitle = Pantalla de bandeja diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index d8f1f94755..ea7a236497 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -247,6 +247,7 @@ NextExpiresInDaysHours = 次回の有効期限まで { "{}" }日 { "{}" }時間 NextExpiresInHoursMinutes = 次回の有効期限まで { "{}" }時間 { "{}" }分 NextExpiresInMinutes = 次回の有効期限まで { "{}" }分 NextExpiresDueNow = まもなく期限切れ +InventoryAvailableCount = { "{}" } available FloatBarShowCost = ローカルコストを表示 FloatBarShowCostDescription = フローティングバーにローカル使用量の推定コストを表示します。 TrayDisplayTitle = トレイ表示 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 89c8885f8d..4b6e3e5220 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -247,6 +247,7 @@ NextExpiresInDaysHours = 다음 만료까지 { "{}" }일 { "{}" }시간 NextExpiresInHoursMinutes = 다음 만료까지 { "{}" }시간 { "{}" }분 NextExpiresInMinutes = 다음 만료까지 { "{}" }분 NextExpiresDueNow = 곧 만료 +InventoryAvailableCount = { "{}" } available FloatBarShowCost = 로컬 비용 표시 FloatBarShowCostDescription = 플로팅 바에 로컬 사용량의 예상 비용을 표시합니다. TrayDisplayTitle = 트레이 표시 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 931e50ee69..5b4282fad3 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -231,6 +231,7 @@ NextExpiresInDaysHours = Срок действия следующего исте NextExpiresInHoursMinutes = Срок действия следующего истекает через { "{}" }h { "{}" }m NextExpiresInMinutes = Срок действия следующего истекает через { "{}" }m NextExpiresDueNow = Срок действия истекает сейчас +InventoryAvailableCount = { "{}" } available FloatBarShowCost = Показать местную стоимость FloatBarShowCostDescription = Показывает расчетную стоимость местного использования на плавающей полосе. TrayDisplayTitle = Дисплей лотка diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 529e61f776..6b4efa1c32 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -253,6 +253,7 @@ NextExpiresInDaysHours = Sonraki süre bitimine { "{}" } g { "{}" } sa NextExpiresInHoursMinutes = Sonraki süre bitimine { "{}" } sa { "{}" } dk NextExpiresInMinutes = Sonraki süre bitimine { "{}" } dk NextExpiresDueNow = Süresi şimdi doluyor +InventoryAvailableCount = { "{}" } available FloatBarShowCost = Yerel Maliyeti Göster FloatBarShowCostDescription = Yüzen çubukta tahmini yerel kullanım maliyetini gösterir. TrayDisplayTitle = Tepsi Görünümü diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index aeb4d01601..7d26561b9e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -247,6 +247,7 @@ NextExpiresInDaysHours = 下一张将在 { "{}" } 天 { "{}" } 小时后过期 NextExpiresInHoursMinutes = 下一张将在 { "{}" } 小时 { "{}" } 分钟后过期 NextExpiresInMinutes = 下一张将在 { "{}" } 分钟后过期 NextExpiresDueNow = 即将过期 +InventoryAvailableCount = { "{}" } available FloatBarShowCost = 显示本地费用 FloatBarShowCostDescription = 在浮动栏中显示本地使用量的估算费用。 TrayDisplayTitle = 托盘显示 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 32ad55ef7e..5ea51081fb 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -247,6 +247,7 @@ NextExpiresInDaysHours = 下一張將在 { "{}" } 天 { "{}" } 小時後過期 NextExpiresInHoursMinutes = 下一張將在 { "{}" } 小時 { "{}" } 分鐘後過期 NextExpiresInMinutes = 下一張將在 { "{}" } 分鐘後過期 NextExpiresDueNow = 即將過期 +InventoryAvailableCount = { "{}" } available FloatBarShowCost = 顯示本機費用 FloatBarShowCostDescription = 在浮動列中顯示本機使用量的估算費用。 TrayDisplayTitle = 系統匣顯示