From f914f406729a71beba7d9d193e22c0fe6dd3e010 Mon Sep 17 00:00:00 2001 From: Caique Reis Date: Fri, 23 Oct 2026 17:14:50 -0300 Subject: [PATCH 1/4] feat(poller): add OpenRouter as a usage provider Collect spend and credit data from the OpenRouter management API: /credits for the account balance, /analytics/query for the live daily, weekly and monthly windows, and /activity for token totals. The management key is read from the Windows Credential Manager first and falls back to OPENROUTER_MANAGEMENT_KEY, so the secret never lands in settings.json. A configurable budget converts a cap between daily, weekly and monthly periods, so the gauge can measure spend against either one. Each provider descriptor has its own native menu command id; a regression test now guards against collisions with the static menu commands. --- Cargo.toml | 1 + src/app_settings.rs | 54 ++ src/context_menu.rs | 7 + src/icons/brand-marks.ttf | Bin 3000 -> 3120 bytes src/localization/mod.rs | 4 + src/main.rs | 1 + src/poller.rs | 8 +- src/poller/openrouter.rs | 1079 +++++++++++++++++++++++++++++++++++++ src/poller/tests.rs | 5 + src/providers.rs | 73 ++- src/secret_store.rs | 220 ++++++++ src/ui/theme.rs | 1 + src/window.rs | 31 +- 13 files changed, 1466 insertions(+), 18 deletions(-) create mode 100644 src/poller/openrouter.rs create mode 100644 src/secret_store.rs diff --git a/Cargo.toml b/Cargo.toml index 5ccd66a4..c2570005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ features = [ "Win32_Graphics_DirectComposition", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", + "Win32_Security_Credentials", "Win32_Storage_FileSystem", ] diff --git a/src/app_settings.rs b/src/app_settings.rs index d2e1d7f1..fcc12627 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -23,6 +23,27 @@ pub const POLL_1_HOUR: u32 = POLL_1_HOUR_SECONDS * 1_000; // SetTimer clamps longer intervals to USER_TIMER_MAXIMUM (i32::MAX ms). pub const MAX_POLL_MINUTES: u32 = i32::MAX as u32 / POLL_1_MIN; +/// Period used for the OpenRouter budget amount and its monitoring window. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BudgetPeriod { + Daily, + Weekly, + #[default] + Monthly, +} + +impl BudgetPeriod { + /// Days covered by one period, given the days in the current calendar month. + pub fn days(self, month_days: u32) -> f64 { + match self { + BudgetPeriod::Daily => 1.0, + BudgetPeriod::Weekly => 7.0, + BudgetPeriod::Monthly => f64::from(month_days), + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SettingsFile { #[serde(default)] @@ -66,6 +87,30 @@ pub struct SettingsFile { show_grok: bool, #[serde(default)] show_copilot: bool, + #[serde(default)] + show_openrouter: bool, + /// Personal spending cap (USD). When set, OpenRouter's gauge measures spend + /// against this cap, converted from `openrouter_budget_period` to the + /// `openrouter_monitor_period` window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openrouter_budget_amount: Option, + /// Period the budget amount is defined for (the "rate"). + #[serde(default)] + pub openrouter_budget_period: BudgetPeriod, + /// Period the OpenRouter gauge displays and measures spend for. + #[serde(default)] + pub openrouter_monitor_period: BudgetPeriod, + /// Whether the OpenRouter display shows the cap next to the spend. + #[serde(default)] + pub openrouter_show_cap: bool, + /// Credits manually entered as added in the current month (USD). When set, + /// the balance line shows what is left of this deposit, not the lifetime + /// account balance (the OpenRouter API has no per-month deposit endpoint). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openrouter_monthly_deposit: Option, + /// Whether the balance line also prints the monthly deposit next to it. + #[serde(default)] + pub openrouter_show_deposit: bool, #[serde(default = "default_true")] pub custom_theme_enabled: bool, /// Show what is left of each allowance instead of what has been spent, so @@ -137,6 +182,13 @@ impl Default for SettingsFile { show_cursor: false, show_grok: false, show_copilot: false, + show_openrouter: false, + openrouter_budget_amount: None, + openrouter_budget_period: BudgetPeriod::Monthly, + openrouter_monitor_period: BudgetPeriod::Monthly, + openrouter_show_cap: false, + openrouter_monthly_deposit: None, + openrouter_show_deposit: false, custom_theme_enabled: true, usage_countdown: false, active_theme_path: None, @@ -235,6 +287,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor, ProviderId::Grok => self.show_grok, ProviderId::Copilot => self.show_copilot, + ProviderId::OpenRouter => self.show_openrouter, } } @@ -283,6 +336,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor = enabled, ProviderId::Grok => self.show_grok = enabled, ProviderId::Copilot => self.show_copilot = enabled, + ProviderId::OpenRouter => self.show_openrouter = enabled, } } diff --git a/src/context_menu.rs b/src/context_menu.rs index bf87f113..cf0b3eab 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -424,6 +424,13 @@ pub fn classic_context_menu() -> ContextMenuDocument { provider: Provider::Copilot, }, ), + ContextMenuItem::action( + "provider-openrouter", + "OpenRouter", + Action::ToggleProvider { + provider: Provider::OpenRouter, + }, + ), ], ); let languages = std::iter::once(ContextMenuItem::action( diff --git a/src/icons/brand-marks.ttf b/src/icons/brand-marks.ttf index f05fc76207b78854a762f063c99fcb7195dbd0c4..148bd32d8a17246facb699f632b6014b6bdf4ad3 100644 GIT binary patch delta 356 zcmdlXzCmJwa(y%d1LFb)1_qP#oXWHW&*Qs*{5K2?9MdvV6H|^E>7@eY)&Q|t22gmHnkG7>O<8iIJI+g^`t!jgg&^ XgOL;Hv ( + strings.token_expired_title, + self.text("Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor."), + ), } } diff --git a/src/main.rs b/src/main.rs index 818c93ba..3ad3ffc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod models; mod native_interop; mod poller; mod providers; +mod secret_store; mod studio_app; mod theme; mod theme_engine; diff --git a/src/poller.rs b/src/poller.rs index a6ac2e0b..e51f2d6b 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -283,6 +283,7 @@ mod copilot; mod cursor; mod grok; mod opencode; +pub(crate) mod openrouter; mod retry_after; mod windows_credentials; @@ -294,7 +295,7 @@ struct ProviderPoller { credential_watch: fn(bool) -> CredentialWatchSnapshot, } -const PROVIDER_POLLERS: [ProviderPoller; 7] = [ +const PROVIDER_POLLERS: [ProviderPoller; 8] = [ ProviderPoller { id: ProviderId::Claude, poll: claude::poll_claude_code, @@ -330,6 +331,11 @@ const PROVIDER_POLLERS: [ProviderPoller; 7] = [ poll: copilot::poll_copilot, credential_watch: copilot::credential_watch_snapshot, }, + ProviderPoller { + id: ProviderId::OpenRouter, + poll: openrouter::poll_openrouter, + credential_watch: openrouter::credential_watch_snapshot, + }, ]; fn provider_poller(provider: ProviderId) -> Option<&'static ProviderPoller> { diff --git a/src/poller/openrouter.rs b/src/poller/openrouter.rs new file mode 100644 index 00000000..b2fc9804 --- /dev/null +++ b/src/poller/openrouter.rs @@ -0,0 +1,1079 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use super::{build_agent, PollError}; +use crate::app_settings::BudgetPeriod; +use crate::diagnose; +use crate::models::{CreditsSection, UsageData, UsageLimit, UsageSection}; + +/// OpenRouter management endpoints. A management key reads account-level data; +/// an optional API key additionally exposes the key's own usage via `/key`. +const BASE_URL: &str = "https://openrouter.ai/api/v1"; +const MANAGEMENT_KEY_ENV: &str = "OPENROUTER_MANAGEMENT_KEY"; +const API_KEY_ENV: &str = "OPENROUTER_API_KEY"; + +/// Windows Credential Manager target for the OpenRouter management key. +pub const MANAGEMENT_KEY_TARGET: &str = "ClaudeCodeUsageMonitor/OpenRouter/management-key"; +const SECONDS_PER_DAY: i64 = 86_400; + +/// `GET /credits` -> account balance and lifetime usage. +#[derive(Deserialize)] +struct CreditsResponse { + data: CreditsData, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CreditsData { + #[serde(default)] + total_credits: f64, + #[serde(default)] + total_usage: f64, +} + +/// `GET /activity` -> one entry per completed UTC day (today excluded). +#[derive(Deserialize)] +struct ActivityResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct ActivityEntry { + #[serde(default)] + date: String, + #[serde(default)] + usage: f64, + #[serde(default)] + prompt_tokens: u64, + #[serde(default)] + completion_tokens: u64, + #[serde(default)] + reasoning_tokens: u64, + #[serde(default)] + cached_tokens: u64, + #[serde(default)] + requests: u64, +} + +/// `GET /key` -> per-window spend for the key that made the request. +#[derive(Deserialize)] +struct KeyResponse { + data: KeyData, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct KeyData { + /// Only today's per-key spend is used now: `/activity` (management key) + /// already reports the account-wide weekly/monthly windows. + #[serde(default)] + usage_daily: f64, +} + +struct Credentials { + management_key: String, + api_key: Option, +} + +/// Cheap connectivity probe for the settings UI: validates a management key and +/// returns `(total_credits, total_usage)`. +pub fn probe_management_key(key: &str) -> Result<(f64, f64), PollError> { + let response = fetch_credits(key)?; + Ok((response.data.total_credits, response.data.total_usage)) +} + +pub(super) fn poll_openrouter() -> Result { + let credentials = read_credentials().ok_or_else(|| { + diagnose::log("OpenRouter usage poll failed: no management key found"); + PollError::NoCredentials + })?; + poll_account(&credentials) +} + +/// Fingerprints only: secrets are hashed so a change can be detected without +/// ever logging the key itself. +pub(super) fn credential_watch_snapshot(_all_sources: bool) -> Vec { + vec![ + secret_signature(MANAGEMENT_KEY_ENV), + secret_signature(API_KEY_ENV), + ] +} + +fn poll_account(credentials: &Credentials) -> Result { + let credits = fetch_credits(&credentials.management_key)?; + let activity = fetch_activity(&credentials.management_key)?; + let key = match &credentials.api_key { + Some(api_key) => match fetch_key(api_key) { + Ok(key) => Some(key), + Err(error) => { + // The management key already answered; a key without the + // right scope (or a revoked key) must not blank the account. + diagnose::log(format!( + "OpenRouter /key poll failed; continuing without daily spend: {error:?}" + )); + None + } + }, + None => None, + }; + let now = SystemTime::now(); + let days = today_days(now); + // Analytics reports live spend for every window; /activity only has + // completed days and /key's usage_daily lags. A single day-granularity query + // spanning the wider of the week/month windows feeds all three. + let (week_start, _) = week_bounds(days); + let (month_start, _) = month_bounds(days); + let analytics = fetch_daily_usage( + &credentials.management_key, + week_start.min(month_start), + days, + ); + Ok(usage_from_account( + &credits.data, + &activity.data, + key.as_ref().map(|key| &key.data), + budget_config(), + days, + analytics.as_deref(), + )) +} + +/// OpenRouter budget configuration from the app's settings file. +#[derive(Clone, Copy, Debug)] +struct BudgetConfig { + amount: Option, + budget_period: BudgetPeriod, + monitor_period: BudgetPeriod, + show_cap: bool, + month_deposit: Option, + show_deposit: bool, +} + +impl Default for BudgetConfig { + fn default() -> Self { + Self { + amount: None, + budget_period: BudgetPeriod::Monthly, + monitor_period: BudgetPeriod::Monthly, + show_cap: false, + month_deposit: None, + show_deposit: false, + } + } +} + +/// Cache of the `/credits` total captured on the first poll of a UTC day, used +/// to derive live "today" spend. +fn day_baseline_path() -> std::path::PathBuf { + crate::app_settings::settings_path().with_file_name("openrouter-day.json") +} + +fn live_today_spend(total_usage: f64, today_days: i64) -> f64 { + let path = day_baseline_path(); + let stored = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()); + let same_day = stored + .as_ref() + .and_then(|value| value.get("day")) + .and_then(serde_json::Value::as_i64) + == Some(today_days); + if same_day { + let base = stored + .and_then(|value| value.get("usage").and_then(serde_json::Value::as_f64)) + .unwrap_or(total_usage); + (total_usage - base).max(0.0) + } else { + let snapshot = serde_json::json!({ "day": today_days, "usage": total_usage }); + let _ = std::fs::write(&path, snapshot.to_string()); + 0.0 + } +} + +fn budget_config() -> BudgetConfig { + let Ok(text) = std::fs::read_to_string(crate::app_settings::settings_path()) else { + return BudgetConfig::default(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return BudgetConfig::default(); + }; + BudgetConfig { + amount: value + .get("openrouter_budget_amount") + .and_then(serde_json::Value::as_f64) + .filter(|amount| amount.is_finite() && *amount > 0.0), + budget_period: value + .get("openrouter_budget_period") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .unwrap_or_default(), + monitor_period: value + .get("openrouter_monitor_period") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .unwrap_or_default(), + show_cap: value + .get("openrouter_show_cap") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + month_deposit: value + .get("openrouter_monthly_deposit") + .and_then(serde_json::Value::as_f64) + .filter(|deposit| deposit.is_finite() && *deposit > 0.0), + show_deposit: value + .get("openrouter_show_deposit") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + } +} + +fn fetch_credits(management_key: &str) -> Result { + get_json(&format!("{BASE_URL}/credits"), management_key, "credits") +} + +fn fetch_activity(management_key: &str) -> Result { + get_json(&format!("{BASE_URL}/activity"), management_key, "activity") +} + +fn fetch_key(api_key: &str) -> Result { + get_json(&format!("{BASE_URL}/key"), api_key, "key") +} + +/// Live per-day spend (UTC day index -> USD) from the analytics API. +/// +/// One day-granularity query spanning the wider of the week/month windows feeds +/// today, the current week and the current month, all in real time. Failures +/// degrade to `None` so a degraded analytics endpoint falls back to /activity. +fn fetch_daily_usage(management_key: &str, start_days: i64, today: i64) -> Option> { + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + let seconds = now % SECONDS_PER_DAY as u64; + let (start_year, start_month, start_day) = civil_from_days(start_days); + let (end_year, end_month, end_day) = civil_from_days(today); + let body = serde_json::json!({ + "metrics": ["total_usage"], + "granularity": "day", + "time_range": { + "start": format!("{start_year:04}-{start_month:02}-{start_day:02}T00:00:00Z"), + "end": format!( + "{end_year:04}-{end_month:02}-{end_day:02}T{:02}:{:02}:{:02}Z", + seconds / 3600, + (seconds % 3600) / 60, + seconds % 60 + ), + }, + }); + let response: serde_json::Value = post_json( + &format!("{BASE_URL}/analytics/query"), + management_key, + &body, + "/analytics/query", + ) + .ok()?; + let rows = response.get("data")?.get("data")?.as_array()?; + Some( + rows.iter() + .filter_map(|row| { + let day = date_days(row.get("date__day")?.as_str()?)?; + let usage = row.get("total_usage").and_then(serde_json::Value::as_f64)?; + Some((day, usage)) + }) + .collect(), + ) +} + +fn post_json Deserialize<'de>>( + url: &str, + token: &str, + body: &serde_json::Value, + endpoint: &str, +) -> Result { + let mut response = match build_agent()? + .post(url) + .header("Authorization", &format!("Bearer {token}")) + .header("Accept", "application/json") + .send_json(body) + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(error) => { + diagnose::log_error(&format!("OpenRouter {endpoint} request failed"), error); + return Err(PollError::RequestFailed); + } + }; + response.body_mut().read_json::().map_err(|error| { + diagnose::log_error( + &format!("OpenRouter {endpoint} response is not valid JSON"), + error, + ); + PollError::RequestFailed + }) +} + +fn get_json Deserialize<'de>>( + url: &str, + token: &str, + endpoint: &str, +) -> Result { + let mut response = match build_agent()? + .get(url) + .header("Authorization", &format!("Bearer {token}")) + .header("Accept", "application/json") + .call() + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(error) => { + diagnose::log_error(&format!("OpenRouter /{endpoint} request failed"), error); + return Err(PollError::RequestFailed); + } + }; + + response.body_mut().read_json::().map_err(|error| { + diagnose::log_error( + &format!("OpenRouter /{endpoint} response is not valid JSON"), + error, + ); + PollError::RequestFailed + }) +} + +/// Map OpenRouter account data into the shared usage model. +/// +/// OpenRouter has no five-hour window, so nothing claims the `session` slot. +/// The balance lands in `credits` (currency), while calendar-week and +/// calendar-month spend become the `weekly`/`monthly` gauges, normalised +/// against the account's total credits so the percentages stay meaningful. +/// Token counts and today's spend reach themes through opt-in `limits`. +fn usage_from_account( + credits: &CreditsData, + activity: &[ActivityEntry], + key: Option<&KeyData>, + budget: BudgetConfig, + today_days: i64, + analytics: Option<&[(i64, f64)]>, +) -> UsageData { + let total_credits = credits.total_credits.max(0.0); + let balance = (credits.total_credits - credits.total_usage).max(0.0); + + let (week_start, next_week) = week_bounds(today_days); + let (month_start, next_month) = month_bounds(today_days); + let month_days = (next_month - month_start).max(1) as u32; + + // Analytics gives live spend for every window. Without it, fall back to the + // completed-day activity sums plus a live today (credits delta or /key). + let (today_spend, week_spend, month_spend) = match analytics { + Some(rows) => ( + sum_days(rows, today_days, today_days + 1), + sum_days(rows, week_start, next_week), + sum_days(rows, month_start, next_month), + ), + None => { + let live_today = live_today_spend(credits.total_usage, today_days); + let today = live_today.max(key.map(|key| key.usage_daily.max(0.0)).unwrap_or(0.0)); + ( + today, + sum_usage_between(activity, week_start, next_week) + today, + sum_usage_between(activity, month_start, next_month) + today, + ) + } + }; + + // The budget amount is a rate per `budget_period`; convert it to whichever + // window is monitored, so a monthly cap can be watched day by day. + let daily_rate = budget + .amount + .filter(|amount| *amount > 0.0) + .map(|amount| amount / budget.budget_period.days(month_days)); + let cap_for = |period: BudgetPeriod| match daily_rate { + Some(rate) => rate * period.days(month_days), + None => total_credits, + }; + let spend_for = |period: BudgetPeriod| match period { + BudgetPeriod::Daily => today_spend, + BudgetPeriod::Weekly => week_spend, + BudgetPeriod::Monthly => month_spend, + }; + + let (monthly_percentage, weekly_percentage, credits_section) = match daily_rate { + Some(_) => { + let monitor_spend = spend_for(budget.monitor_period); + let monitor_cap = cap_for(budget.monitor_period); + ( + share_of_total(month_spend, cap_for(BudgetPeriod::Monthly)), + share_of_total(week_spend, cap_for(BudgetPeriod::Weekly)), + (monitor_cap > 0.0).then_some(CreditsSection { + percentage: share_of_total(monitor_spend, monitor_cap), + // Not clamped: themes derive `spent` from `total - remaining`, + // so this keeps the real spend visible when over the cap (the + // gauge percentage still clamps at 100). + remaining: monitor_cap - monitor_spend, + total: monitor_cap, + }), + ) + } + None => ( + share_of_total(month_spend, total_credits), + share_of_total(week_spend, total_credits), + (total_credits > 0.0).then_some(CreditsSection { + percentage: share_of_total(credits.total_usage, credits.total_credits), + remaining: balance, + total: total_credits, + }), + ), + }; + + let tokens = token_totals(activity); + + let mut limits = vec![UsageLimit { + key: "tokens".into(), + kind: "tokens".into(), + label: "Tokens".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "prompt_tokens": tokens.prompt, + "completion_tokens": tokens.completion, + "reasoning_tokens": tokens.reasoning, + "cached_tokens": tokens.cached, + "requests": tokens.requests, + })), + is_active: tokens.prompt + tokens.completion + tokens.reasoning + tokens.cached > 0, + usage: UsageSection { + available: true, + percentage: 0.0, + resets_at: days_to_system_time(next_month), + }, + }]; + + // Line 1 (daily) and line 2 (weekly or monthly) for the two-line OpenRouter + // display. Dollar amounts ride in the scope so themes can print them. + let long_period = budget.monitor_period; + let long_spend = spend_for(long_period); + let long_cap = cap_for(long_period); + let day_cap = cap_for(BudgetPeriod::Daily); + // A zero/absent amount turns the cap off: the gauge still falls back to the + // credit total, but the printed cap is hidden and shows 0. + let display_cap_for = |period: BudgetPeriod| { + if daily_rate.is_some() { + cap_for(period) + } else { + 0.0 + } + }; + let cap_visible = daily_rate.is_some() && budget.show_cap; + let long_label = match long_period { + BudgetPeriod::Daily => "1d", + BudgetPeriod::Monthly => "30d", + BudgetPeriod::Weekly => "7d", + }; + let long_reset = match long_period { + BudgetPeriod::Daily => today_days + 1, + BudgetPeriod::Monthly => next_month, + BudgetPeriod::Weekly => next_week, + }; + limits.push(UsageLimit { + key: "daily".into(), + kind: "daily".into(), + label: "1d".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "usage_usd": today_spend, + "cap_usd": display_cap_for(BudgetPeriod::Daily), + })), + is_active: true, + usage: UsageSection { + available: true, + percentage: share_of_total(today_spend, day_cap), + resets_at: days_to_system_time(today_days + 1), + }, + }); + limits.push(UsageLimit { + key: "long".into(), + kind: "window".into(), + label: long_label.into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "usage_usd": long_spend, + "cap_usd": display_cap_for(long_period), + "show_cap": if cap_visible { 1.0 } else { 0.0 }, + })), + is_active: true, + usage: UsageSection { + available: true, + percentage: share_of_total(long_spend, long_cap), + resets_at: days_to_system_time(long_reset), + }, + }); + + // Line 1: account balance. The bar uses the fraction still available, so it + // shrinks as spend grows (a fuel-gauge read of the remaining credits). + // The printed number is always the real account balance + // (total_credits - total_usage). A manually entered monthly deposit only + // re-scales the bar denominator; when unset the lifetime total is used. + let (acct_balance, acct_total) = (balance, budget.month_deposit.unwrap_or(total_credits)); + // With the toggle on, the balance line prints the deposit next to the + // balance; when no deposit is set it falls back to the accumulated account + // total (acct_total below). + let deposit_visible = budget.show_deposit; + let remaining_pct = if acct_total > 0.0 { + (acct_balance / acct_total * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + limits.push(UsageLimit { + key: "account".into(), + kind: "account".into(), + label: "Balance".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "balance_usd": acct_balance, + "total_usd": acct_total, + "remaining_pct": remaining_pct, + "show_deposit": if deposit_visible { 1.0 } else { 0.0 }, + })), + is_active: acct_total > 0.0, + usage: UsageSection { + available: acct_total > 0.0, + percentage: remaining_pct, + resets_at: None, + }, + }); + + if key.is_some() { + // `/key`'s `usage_daily` lags (often 0 while spending), so the "today" + // limit reuses the live value derived from the credits delta instead of + // the per-key counter. The limit only exists when a key is configured. + let daily = today_spend; + limits.push(UsageLimit { + key: "today".into(), + kind: "daily".into(), + label: "Today".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ "usage_usd": daily })), + is_active: daily > 0.0, + usage: UsageSection { + available: true, + percentage: share_of_total(daily, total_credits), + resets_at: days_to_system_time(today_days + 1), + }, + }); + } + + UsageData { + limits, + // No five-hour window: the short slot stays empty. + session: UsageSection::default(), + weekly: UsageSection { + available: true, + percentage: weekly_percentage, + resets_at: days_to_system_time(next_week), + }, + weekly_label: Some("7d".into()), + monthly: Some(UsageSection { + available: true, + percentage: monthly_percentage, + resets_at: days_to_system_time(next_month), + }), + credits: credits_section, + stale: false, + ..Default::default() + } +} + +fn share_of_total(amount: f64, total: f64) -> f64 { + if !amount.is_finite() || total <= 0.0 || !total.is_finite() { + return 0.0; + } + (amount / total * 100.0).clamp(0.0, 100.0) +} + +#[derive(Default)] +struct TokenTotals { + prompt: u64, + completion: u64, + reasoning: u64, + cached: u64, + requests: u64, +} + +fn token_totals(activity: &[ActivityEntry]) -> TokenTotals { + activity + .iter() + .fold(TokenTotals::default(), |mut totals, entry| { + totals.prompt = totals.prompt.saturating_add(entry.prompt_tokens); + totals.completion = totals.completion.saturating_add(entry.completion_tokens); + totals.reasoning = totals.reasoning.saturating_add(entry.reasoning_tokens); + totals.cached = totals.cached.saturating_add(entry.cached_tokens); + totals.requests = totals.requests.saturating_add(entry.requests); + totals + }) +} + +fn sum_days(rows: &[(i64, f64)], start: i64, end: i64) -> f64 { + rows.iter() + .filter(|(day, usage)| *day >= start && *day < end && usage.is_finite() && *usage >= 0.0) + .map(|(_, usage)| *usage) + .sum() +} + +fn sum_usage_between(activity: &[ActivityEntry], start: i64, end: i64) -> f64 { + activity + .iter() + .filter_map(|entry| { + let days = date_days(&entry.date)?; + (days >= start && days < end && entry.usage.is_finite() && entry.usage >= 0.0) + .then_some(entry.usage) + }) + .sum() +} + +/// Monday-based week boundaries in days since 1970-01-01 (a Thursday). +fn week_bounds(today_days: i64) -> (i64, i64) { + let weekday = (today_days + 3).rem_euclid(7); + let start = today_days - weekday; + (start, start + 7) +} + +/// Calendar-month boundaries: first day of this month and of the next. +fn month_bounds(today_days: i64) -> (i64, i64) { + let (year, month, _) = civil_from_days(today_days); + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + ( + days_from_civil(year, month, 1).unwrap_or(today_days), + days_from_civil(next_year, next_month, 1).unwrap_or(today_days + 31), + ) +} + +fn days_to_system_time(days: i64) -> Option { + let seconds = u64::try_from(days.max(0)) + .ok()? + .checked_mul(SECONDS_PER_DAY as u64)?; + UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds)) +} + +fn today_days(now: SystemTime) -> i64 { + now.duration_since(UNIX_EPOCH) + .map(|duration| (duration.as_secs() / SECONDS_PER_DAY as u64) as i64) + .unwrap_or(0) +} + +/// Days since 1970-01-01 for a UTC date string. +/// +/// OpenRouter's `/activity` returns timestamps like `2026-10-21 00:00:00`; +/// earlier payloads used a bare `YYYY-MM-DD`. Only the leading date matters. +fn date_days(value: &str) -> Option { + let head = value.get(..10)?; + let bytes = head.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + let year = parse_digits(&bytes[0..4])? as i64; + let month = parse_digits(&bytes[5..7])?; + let day = parse_digits(&bytes[8..10])?; + days_from_civil(year, month, day) +} + +fn parse_digits(bytes: &[u8]) -> Option { + if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + bytes.iter().try_fold(0_u32, |value, byte| { + value.checked_mul(10)?.checked_add(u32::from(byte - b'0')) + }) +} + +fn days_from_civil(year: i64, month: u32, day: u32) -> Option { + if !(1970..=9999).contains(&year) + || !(1..=12).contains(&month) + || day == 0 + || day > days_in_month(year, month) + { + return None; + } + let mut days: i64 = 0; + for y in 1970..year { + days += if is_leap(y) { 366 } else { 365 }; + } + let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for m in 1..month { + days += i64::from(month_days[m as usize]); + if m == 2 && is_leap(year) { + days += 1; + } + } + Some(days + i64::from(day) - 1) +} + +fn days_in_month(year: i64, month: u32) -> u32 { + match month { + 2 if is_leap(year) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + _ => 0, + } +} + +fn is_leap(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// Howard Hinnant's civil-from-days algorithm, inverted for `days_from_civil`. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (year + i64::from(month <= 2), month, day) +} + +fn read_credentials() -> Option { + let management_key = resolve_management_key()?; + let api_key = non_empty_environment(API_KEY_ENV).filter(|key| is_header_safe_token(key)); + Some(Credentials { + management_key, + api_key, + }) +} + +/// Resolve the management key by trying the credential store first, then the +/// environment variable as a fallback. A value that fails token validation is +/// logged (without the value) and the next source is tried. +fn resolve_management_key() -> Option { + // Source 1: Windows Credential Manager. + match crate::secret_store::read_secret(MANAGEMENT_KEY_TARGET) { + Ok(Some(value)) => { + if is_header_safe_token(&value) { + return Some(value); + } + diagnose::log( + "OpenRouter management key from credential store contains characters \ + unsafe for a header; falling back to environment variable", + ); + } + Ok(None) => {} + Err(err) => { + diagnose::log(format!( + "OpenRouter credential store read failed; \ + falling back to environment variable: {err}" + )); + } + } + + // Source 2: environment variable. + let value = non_empty_environment(MANAGEMENT_KEY_ENV)?; + if is_header_safe_token(&value) { + return Some(value); + } + diagnose::log( + "OpenRouter management key from environment variable contains characters \ + unsafe for a header", + ); + None +} + +/// Tokens go into a header, so anything that could split it is unusable. +fn is_header_safe_token(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_graphic()) +} + +fn non_empty_environment(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn secret_signature(name: &str) -> String { + match non_empty_environment(name) { + Some(value) => { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{name}|present|{}|{:x}", value.len(), hasher.finish()) + } + None => format!("{name}|missing"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TODAY_2026_10_22: i64 = 20_748; // 2026-10-22 is a Thursday. + + fn credits(total_credits: f64, total_usage: f64) -> CreditsData { + CreditsData { + total_credits, + total_usage, + } + } + + #[test] + fn balance_maps_to_credits_and_week_month_spend_to_gauges() { + // 2026-10-19 (Mon) and 2026-10-20 fall inside this calendar week; + // 2026-09-30 is last month. + let activity = [ + ActivityEntry { + date: "2026-10-19".into(), + usage: 1.5, + prompt_tokens: 100, + completion_tokens: 200, + requests: 3, + ..Default::default() + }, + ActivityEntry { + date: "2026-10-20".into(), + usage: 2.5, + prompt_tokens: 50, + completion_tokens: 60, + reasoning_tokens: 10, + cached_tokens: 5, + requests: 1, + ..Default::default() + }, + ActivityEntry { + date: "2026-09-30".into(), + usage: 10.0, + prompt_tokens: 1, + completion_tokens: 1, + requests: 1, + ..Default::default() + }, + ]; + + let data = usage_from_account( + &credits(100.0, 20.0), + &activity, + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + + let credits = data.credits.expect("credits present"); + assert_eq!(credits.remaining, 80.0); + assert_eq!(credits.total, 100.0); + assert_eq!(credits.percentage, 20.0); + + // This week spends 4.0 of 100 credits; the month includes 2026-10-19/20 + // but not 2026-09-30. + assert_eq!(data.weekly.percentage, 4.0); + assert_eq!(data.monthly.as_ref().unwrap().percentage, 4.0); + assert!(data.weekly.available); + // No five-hour window. + assert!(!data.session.available); + assert_eq!(data.weekly_label.as_deref(), Some("7d")); + + // Tokens are aggregated across the whole activity window. + let tokens = data + .limits + .iter() + .find(|limit| limit.key == "tokens") + .unwrap(); + let scope = tokens.scope.as_ref().unwrap(); + assert_eq!(scope["prompt_tokens"], 151); + assert_eq!(scope["completion_tokens"], 261); + assert_eq!(scope["reasoning_tokens"], 10); + assert_eq!(scope["cached_tokens"], 5); + assert_eq!(scope["requests"], 5); + assert!(tokens.is_active); + } + + #[test] + fn monthly_budget_overrides_the_gauge_denominator() { + let activity = [ActivityEntry { + date: "2026-10-20 00:00:00".into(), + usage: 5.0, + ..Default::default() + }]; + let data = usage_from_account( + &credits(140.0, 0.0), + &activity, + None, + BudgetConfig { + amount: Some(20.0), + budget_period: BudgetPeriod::Monthly, + monitor_period: BudgetPeriod::Monthly, + show_cap: true, + month_deposit: None, + show_deposit: true, + }, + TODAY_2026_10_22, + None, + ); + // Monthly gauge: 5 / 20 = 25% instead of 5 / 140. + assert!((data.monthly.as_ref().unwrap().percentage - 25.0).abs() < 1e-9); + let credits = data.credits.expect("budget credits present"); + assert!((credits.percentage - 25.0).abs() < 1e-9); + assert!((credits.total - 20.0).abs() < 1e-9); + assert!((credits.remaining - 15.0).abs() < 1e-9); + } + + #[test] + fn key_endpoint_adds_today_spend_without_overriding_activity_windows() { + let key = KeyData { + usage_daily: 0.5, + ..Default::default() + }; + // Window spend is account-wide, from /activity, not from /key. + let activity = [ActivityEntry { + date: "2026-10-20 00:00:00".into(), // same week and month + usage: 4.0, + ..Default::default() + }]; + let data = usage_from_account( + &credits(200.0, 0.0), + &activity, + Some(&key), + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + + // Window spend includes /activity (4.0) plus today's /key spend (0.5). + assert!((data.weekly.percentage - 2.25).abs() < 1e-9); // 4.5 / 200 * 100 + assert!((data.monthly.as_ref().unwrap().percentage - 2.25).abs() < 1e-9); + + let today = data + .limits + .iter() + .find(|limit| limit.key == "today") + .expect("today limit present"); + assert_eq!(today.scope.as_ref().unwrap()["usage_usd"], 0.5); + assert!((today.usage.percentage - 0.25).abs() < 1e-9); // 0.5 / 200 * 100 + assert!(today.is_active); + } + + #[test] + fn zero_credits_suppress_the_balance_and_clamp_percentages() { + let data = usage_from_account( + &credits(0.0, 0.0), + &[], + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + assert!(data.credits.is_none()); + assert_eq!(data.weekly.percentage, 0.0); + assert_eq!(data.monthly.as_ref().unwrap().percentage, 0.0); + assert!(!data.session.available); + } + + #[test] + fn weekly_and_monthly_resets_land_on_utc_boundaries() { + let data = usage_from_account( + &credits(10.0, 0.0), + &[], + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + // 2026-10-22 is Thursday; the next Monday is 2026-10-26. + let expected_monday = days_to_system_time(date_days("2026-10-26").unwrap()); + assert_eq!(data.weekly.resets_at, expected_monday); + let expected_month = days_to_system_time(date_days("2026-11-01").unwrap()); + assert_eq!(data.monthly.as_ref().unwrap().resets_at, expected_month); + } + + #[test] + fn activity_dates_outside_the_windows_are_ignored_for_spend() { + let activity = [ + ActivityEntry { + date: "2026-10-12".into(), // last week (Monday 2026-10-12) + usage: 9.0, + ..Default::default() + }, + ActivityEntry { + date: "2026-10-21".into(), // this week + usage: 1.0, + ..Default::default() + }, + ]; + let data = usage_from_account( + &credits(10.0, 0.0), + &activity, + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + assert_eq!(data.weekly.percentage, 10.0); // only 1.0 / 10 + } + + #[test] + fn civil_date_helpers_round_trip() { + for (days, civil) in [ + (0_i64, (1970_i64, 1_u32, 1_u32)), + (date_days("2000-03-01").unwrap(), (2000, 3, 1)), + (date_days("2024-02-29").unwrap(), (2024, 2, 29)), + (date_days("2026-10-22").unwrap(), (2026, 10, 22)), + (date_days("2026-12-31").unwrap(), (2026, 12, 31)), + ] { + assert_eq!(civil_from_days(days), civil); + assert_eq!(days_from_civil(civil.0, civil.1, civil.2), Some(days)); + } + } + + #[test] + fn week_and_month_bounds_are_monday_and_first_of_month() { + assert_eq!(week_bounds(TODAY_2026_10_22), (20_745, 20_752)); // Mon 10-19 .. Mon 10-26 + assert_eq!( + month_bounds(TODAY_2026_10_22), + ( + date_days("2026-10-01").unwrap(), + date_days("2026-11-01").unwrap() + ) + ); + // December rolls into the next year. + let dec31 = date_days("2026-12-31").unwrap(); + assert_eq!( + month_bounds(dec31), + ( + date_days("2026-12-01").unwrap(), + date_days("2027-01-01").unwrap() + ) + ); + } + + #[test] + fn malformed_activity_dates_are_skipped() { + assert_eq!(date_days("2026-10-22"), Some(TODAY_2026_10_22)); + // Real /activity timestamps carry a time; only the date prefix counts. + assert_eq!(date_days("2026-10-21 00:00:00"), date_days("2026-10-21")); + for invalid in [ + "", + "2026-13-01", + "2026-00-10", + "2026-02-30", + "not-a-date", + "2026/10/22", + ] { + assert_eq!(date_days(invalid), None, "{invalid}"); + } + } + + #[test] + fn header_unsafe_tokens_are_refused() { + assert!(is_header_safe_token("sk-or-v1-fixture")); + assert!(!is_header_safe_token("")); + assert!(!is_header_safe_token("sk-or\r\nX-Test: injected")); + assert!(!is_header_safe_token("two words")); + } +} diff --git a/src/poller/tests.rs b/src/poller/tests.rs index e29c58b9..45765e98 100644 --- a/src/poller/tests.rs +++ b/src/poller/tests.rs @@ -131,6 +131,7 @@ fn claude_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -154,6 +155,7 @@ fn codex_failure_does_not_block_claude_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("claude data should keep the poll successful"); @@ -177,6 +179,7 @@ fn returns_first_error_when_no_enabled_provider_succeeds() { ProviderId::Cursor => Err(PollError::NoCredentials), ProviderId::Grok => Err(PollError::NoCredentials), ProviderId::Copilot => Err(PollError::NoCredentials), + ProviderId::OpenRouter => Err(PollError::NoCredentials), }, ) .expect_err("all-provider failure should return an error"); @@ -297,6 +300,7 @@ fn antigravity_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -320,6 +324,7 @@ fn opencode_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("Codex data should keep the poll successful"); diff --git a/src/providers.rs b/src/providers.rs index aa685b6b..9d07c88a 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -16,6 +16,7 @@ pub enum ProviderId { Cursor = 4, Grok = 5, Copilot = 6, + OpenRouter = 7, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -34,7 +35,7 @@ pub struct ProviderDescriptor { pub default_enabled: bool, } -pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ +pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 8] = [ ProviderDescriptor { id: ProviderId::Claude, key: "claude", @@ -98,10 +99,19 @@ pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ native_menu_command_id: 66, default_enabled: false, }, + ProviderDescriptor { + id: ProviderId::OpenRouter, + key: "openrouter", + cache_key: "openrouter", + display_name: "OpenRouter", + settings_description: "Collect usage from OpenRouter", + native_menu_command_id: 69, + default_enabled: false, + }, ]; impl ProviderId { - pub const ALL: [Self; 7] = [ + pub const ALL: [Self; 8] = [ Self::Claude, Self::Codex, Self::Antigravity, @@ -109,6 +119,7 @@ impl ProviderId { Self::Cursor, Self::Grok, Self::Copilot, + Self::OpenRouter, ]; pub const fn descriptor(self) -> &'static ProviderDescriptor { @@ -256,4 +267,62 @@ mod tests { ); } } + + #[test] + fn provider_menu_command_ids_are_unique_and_do_not_collide_with_static_commands() { + use crate::window::{ + IDM_DASHBOARD, IDM_FREQ_15MIN, IDM_FREQ_1HOUR, IDM_FREQ_1MIN, IDM_FREQ_5MIN, + IDM_LANG_FIRST, IDM_LANG_SYSTEM, IDM_LOCK_TASKBAR, IDM_PROVIDER_ANTIGRAVITY_EXTERNAL, + IDM_PROVIDER_ANTIGRAVITY_GEMINI, IDM_START_WITH_WINDOWS, IDM_VERSION_ACTION, + }; + + let ids: Vec = PROVIDER_DESCRIPTORS + .iter() + .map(|d| d.native_menu_command_id) + .collect(); + + // All ids must be pairwise unique. + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!( + ids[i], ids[j], + "providers '{}' and '{}' share native_menu_command_id {}", + PROVIDER_DESCRIPTORS[i].key, PROVIDER_DESCRIPTORS[j].key, ids[i] + ); + } + } + + // No id may collide with a reserved static command id. + let reserved: &[u16] = &[ + 1, // WM_COMMAND refresh (posted directly) + 2, // WM_COMMAND exit (posted directly) + IDM_FREQ_1MIN, + IDM_FREQ_5MIN, + IDM_FREQ_15MIN, + IDM_FREQ_1HOUR, + IDM_START_WITH_WINDOWS, + IDM_LOCK_TASKBAR, + IDM_VERSION_ACTION, + IDM_PROVIDER_ANTIGRAVITY_GEMINI, + IDM_PROVIDER_ANTIGRAVITY_EXTERNAL, + IDM_DASHBOARD, + IDM_LANG_SYSTEM, + ]; + + for &id in &ids { + for &res in reserved { + assert_ne!( + id, res, + "provider native_menu_command_id {} collides with a reserved static command id", + id + ); + } + assert!( + id < IDM_LANG_FIRST, + "provider native_menu_command_id {} falls in the language id range (>= {})", + id, + IDM_LANG_FIRST + ); + } + } } diff --git a/src/secret_store.rs b/src/secret_store.rs new file mode 100644 index 00000000..1c39d6ae --- /dev/null +++ b/src/secret_store.rs @@ -0,0 +1,220 @@ +//! Windows Credential Manager integration. +//! +//! Secrets are stored as generic credentials in the per-user vault, protected +//! by DPAPI. The blob is the raw UTF-8 encoding of the secret value. + +use std::fmt; + +use windows::Win32::Foundation::ERROR_NOT_FOUND; +use windows::Win32::Security::Credentials::{ + CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS, + CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, +}; + +/// Error returned by Windows Credential Manager operations. +/// +/// Contains the operation name and Win32 error code; the secret value is +/// never included. +#[derive(Debug)] +pub struct SecretError { + operation: &'static str, + code: u32, +} + +impl fmt::Display for SecretError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "credential store {} failed: Win32 error {:#010x}", + self.operation, self.code + ) + } +} + +impl std::error::Error for SecretError {} + +impl SecretError { + fn new(operation: &'static str, code: u32) -> Self { + Self { operation, code } + } +} + +fn to_wide_null(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Store `secret` under `target` in the Windows Credential Manager. +/// +/// Uses `CRED_TYPE_GENERIC` and `CRED_PERSIST_LOCAL_MACHINE`. An existing +/// credential with the same target is silently overwritten. +/// +/// Returns an error if `secret` is empty or contains only whitespace. +pub fn write_secret(target: &str, secret: &str) -> Result<(), SecretError> { + if secret.trim().is_empty() { + return Err(SecretError::new("write", 87)); // ERROR_INVALID_PARAMETER + } + + let target_wide = to_wide_null(target); + let blob = secret.as_bytes(); + + let cred = CREDENTIALW { + Flags: CRED_FLAGS(0), + Type: CRED_TYPE_GENERIC, + TargetName: windows::core::PWSTR(target_wide.as_ptr() as *mut u16), + Comment: windows::core::PWSTR::null(), + LastWritten: Default::default(), + CredentialBlobSize: blob.len() as u32, + CredentialBlob: blob.as_ptr() as *mut u8, + Persist: CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: windows::core::PWSTR::null(), + UserName: windows::core::PWSTR::null(), + }; + + unsafe { CredWriteW(&cred, 0) }.map_err(|e| SecretError::new("write", e.code().0 as u32)) +} + +/// Retrieve the secret stored under `target` from the Windows Credential Manager. +/// +/// Returns `Ok(None)` when the credential does not exist. +pub fn read_secret(target: &str) -> Result, SecretError> { + let target_wide = to_wide_null(target); + let mut pcred: *mut CREDENTIALW = std::ptr::null_mut(); + + let result = unsafe { + CredReadW( + windows::core::PCWSTR(target_wide.as_ptr()), + CRED_TYPE_GENERIC, + Some(0), + &mut pcred, + ) + }; + + match result { + Err(e) if e.code() == ERROR_NOT_FOUND.to_hresult() => Ok(None), + Err(e) => Err(SecretError::new("read", e.code().0 as u32)), + Ok(()) => { + // Safety: CredReadW returned Ok so pcred points to a valid CREDENTIALW + // allocated by the system. CredFree must be called regardless of outcome. + let decoded = unsafe { + let cred = &*pcred; + let blob = if cred.CredentialBlobSize == 0 { + &[][..] + } else { + std::slice::from_raw_parts( + cred.CredentialBlob, + cred.CredentialBlobSize as usize, + ) + }; + let value = std::str::from_utf8(blob) + .map(str::to_owned) + .map_err(|_| SecretError::new("read", 13)); // ERROR_INVALID_DATA + CredFree(pcred as *const _); + value + }; + Ok(Some(decoded?)) + } + } +} + +/// Remove the secret stored under `target` from the Windows Credential Manager. +/// +/// A missing credential is treated as success (`Ok(())`). +pub fn delete_secret(target: &str) -> Result<(), SecretError> { + let target_wide = to_wide_null(target); + + let result = unsafe { + CredDeleteW( + windows::core::PCWSTR(target_wide.as_ptr()), + CRED_TYPE_GENERIC, + Some(0), + ) + }; + + match result { + Err(e) if e.code() == ERROR_NOT_FOUND.to_hresult() => Ok(()), + Err(e) => Err(SecretError::new("delete", e.code().0 as u32)), + Ok(()) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The credential vault is a shared OS resource and does not enjoy six + /// concurrent readers/writers, so the tests take turns. + static VAULT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn vault_guard() -> std::sync::MutexGuard<'static, ()> { + VAULT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn test_target(name: &str) -> String { + format!( + "ClaudeCodeUsageMonitor/test/{}/{}", + std::process::id(), + name + ) + } + + #[test] + fn write_then_read_returns_same_value() { + let _guard = vault_guard(); + let target = test_target("write_read"); + write_secret(&target, "test-value-abc").unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some("test-value-abc")); + delete_secret(&target).unwrap(); + } + + #[test] + fn read_unknown_target_returns_none() { + let _guard = vault_guard(); + let target = test_target("read_unknown"); + let result = read_secret(&target).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn write_delete_read_returns_none() { + let _guard = vault_guard(); + let target = test_target("write_delete_read"); + write_secret(&target, "ephemeral-secret").unwrap(); + delete_secret(&target).unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn write_twice_overwrites() { + let _guard = vault_guard(); + let target = test_target("overwrite"); + write_secret(&target, "first-value").unwrap(); + write_secret(&target, "second-value").unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some("second-value")); + delete_secret(&target).unwrap(); + } + + #[test] + fn delete_unknown_target_is_ok() { + let _guard = vault_guard(); + let target = test_target("delete_unknown"); + assert!(delete_secret(&target).is_ok()); + } + + #[test] + fn non_ascii_secret_round_trips() { + let _guard = vault_guard(); + let target = test_target("non_ascii"); + let secret = "日本語テスト🔑"; + write_secret(&target, secret).unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some(secret)); + delete_secret(&target).unwrap(); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index f9f7a449..9ca5fa7d 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -62,6 +62,7 @@ pub(crate) fn provider_mark_glyph(provider: ProviderId) -> char { ProviderId::Cursor => '\u{f005}', ProviderId::Grok => '\u{f006}', ProviderId::Copilot => '\u{f007}', + ProviderId::OpenRouter => '\u{f008}', } } diff --git a/src/window.rs b/src/window.rs index e9eff271..f95026d6 100644 --- a/src/window.rs +++ b/src/window.rs @@ -201,19 +201,19 @@ fn perform_update_action(hwnd: HWND) { const RETRY_BASE_MS: u32 = 30_000; // 30 seconds // Menu item IDs for update frequency -const IDM_FREQ_1MIN: u16 = 10; -const IDM_FREQ_5MIN: u16 = 11; -const IDM_FREQ_15MIN: u16 = 12; -const IDM_FREQ_1HOUR: u16 = 13; -const IDM_START_WITH_WINDOWS: u16 = 20; -const IDM_LOCK_TASKBAR: u16 = 21; -const IDM_VERSION_ACTION: u16 = 31; -const IDM_LANG_SYSTEM: u16 = 100; -const IDM_LANG_FIRST: u16 = 101; -const IDM_DASHBOARD: u16 = 71; +pub(crate) const IDM_FREQ_1MIN: u16 = 10; +pub(crate) const IDM_FREQ_5MIN: u16 = 11; +pub(crate) const IDM_FREQ_15MIN: u16 = 12; +pub(crate) const IDM_FREQ_1HOUR: u16 = 13; +pub(crate) const IDM_START_WITH_WINDOWS: u16 = 20; +pub(crate) const IDM_LOCK_TASKBAR: u16 = 21; +pub(crate) const IDM_VERSION_ACTION: u16 = 31; +pub(crate) const IDM_LANG_SYSTEM: u16 = 100; +pub(crate) const IDM_LANG_FIRST: u16 = 101; +pub(crate) const IDM_DASHBOARD: u16 = 71; // 62 stays the whole-provider Antigravity toggle used by saved custom menus. -pub(super) const IDM_PROVIDER_ANTIGRAVITY_GEMINI: u16 = 68; -pub(super) const IDM_PROVIDER_ANTIGRAVITY_EXTERNAL: u16 = 67; +pub(crate) const IDM_PROVIDER_ANTIGRAVITY_GEMINI: u16 = 68; +pub(crate) const IDM_PROVIDER_ANTIGRAVITY_EXTERNAL: u16 = 67; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -3337,9 +3337,10 @@ fn reload_external_settings(hwnd: HWND) { // previously ejected widget so the lock takes effect at once. redock_locked_widget(hwnd); } - if providers_changed { - request_poll(hwnd); - } + // The OpenRouter budget, cap and deposit are baked into the poll result, so + // any settings edit needs a re-poll - not just provider visibility changes. + // `request_poll` coalesces while a poll is in flight. + request_poll(hwnd); sync_tray_icon(hwnd); position_at_taskbar(); render_layered(); From 5919fb54ebf5844dba2df9de298a0f967e47a842 Mon Sep 17 00:00:00 2001 From: Caique Reis Date: Fri, 23 Oct 2026 17:14:50 -0300 Subject: [PATCH 2/4] feat(settings): add the OpenRouter connection and budget panels The connection panel stores the management key in the Windows Credential Manager and can test it without leaving the settings page. The budget panel sets the cap amount and period, the monitored window and whether the cap or the credits deposit are shown in the widget. Themes get bindings for the daily and long-window rows as well as the account balance, so custom themes can render the same data. --- README.md | 6 + src/studio_app.rs | 7 + src/studio_app/studio_core.rs | 5 + src/studio_app/studio_settings.rs | 283 +++++++++++++++++++++++++++ src/studio_app/tests.rs | 5 + src/theme_engine.rs | 109 +++++++++++ src/theme_engine/tests.rs | 65 +++--- src/theme_engine/theme_expression.rs | 8 + src/theme_engine/theme_limits.rs | 10 + src/ui/components/number_field.rs | 5 + 10 files changed, 479 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8a62fce5..15efeac5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ In the default theme, left-click a provider tray icon to show or hide the widget | Grok Build | Run `grok login` in the Grok Build CLI, then enable Grok in **Providers**. The signed-in session is detected automatically. | | GitHub Copilot | Sign in with the Copilot CLI (`copilot`, then `/login`) or the GitHub CLI (`gh auth login`), then enable Copilot in **Providers**. The stored login is detected automatically. | +For OpenRouter, create a Management API key at `https://openrouter.ai/settings/management-keys`, +then paste it in **Settings → Providers → OpenRouter → Connection** and press **Test** to verify +the connection. The key is stored securely in Windows Credential Manager and never written to +`settings.json`. The environment variable `OPENROUTER_MANAGEMENT_KEY` still works as a fallback +if you prefer not to use the credential store. + For OpenCode Go, set `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`, or create `%APPDATA%\opencode-go\config.json`: ```json diff --git a/src/studio_app.rs b/src/studio_app.rs index 9372142e..e1fe5626 100644 --- a/src/studio_app.rs +++ b/src/studio_app.rs @@ -622,6 +622,13 @@ struct StudioApp { context_menu_dirty: bool, context_menu_selection: Option>, delete_context_menu_confirmation: Option<(PathBuf, String)>, + openrouter_key_buffer: String, + openrouter_connection_status: Option, + openrouter_testing: bool, + openrouter_test_pending: Option< + std::sync::Arc>>>, + >, + openrouter_test_label: Option, } mod studio_assets; diff --git a/src/studio_app/studio_core.rs b/src/studio_app/studio_core.rs index 997f5a92..6bf8eee0 100644 --- a/src/studio_app/studio_core.rs +++ b/src/studio_app/studio_core.rs @@ -172,6 +172,11 @@ impl StudioApp { context_menu_dirty: false, context_menu_selection: None, delete_context_menu_confirmation: None, + openrouter_key_buffer: String::new(), + openrouter_connection_status: None, + openrouter_testing: false, + openrouter_test_pending: None, + openrouter_test_label: None, } } diff --git a/src/studio_app/studio_settings.rs b/src/studio_app/studio_settings.rs index fec8218a..6eecbcc3 100644 --- a/src/studio_app/studio_settings.rs +++ b/src/studio_app/studio_settings.rs @@ -1,3 +1,5 @@ +use std::sync::{Arc, Mutex}; + use super::*; use crate::providers::ProviderId; @@ -5,6 +7,7 @@ impl StudioApp { pub(super) fn settings_page(&mut self, ui: &mut egui::Ui) { let language = self.language(); let mut changed = false; + let mut budget_changed = false; let mut requested_theme = None; let mut open_theme_studio = false; let themes = self.available_themes(); @@ -97,6 +100,283 @@ impl StudioApp { ); }); } + if descriptor.id == ProviderId::OpenRouter { + let provider_enabled = self.settings.provider_enabled(descriptor.id); + let key_stored = crate::secret_store::read_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ) + .ok() + .flatten() + .is_some(); + let mut connection_refresh = false; + egui::CollapsingHeader::new(language.text("Connection")) + .default_open(provider_enabled && !key_stored) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(language.text("Management key")); + ui.add( + egui::TextEdit::singleline( + &mut self.openrouter_key_buffer, + ) + .password(true) + .desired_width(200.0), + ); + if ui.button(language.text("Save")).clicked() { + match crate::secret_store::write_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + self.openrouter_key_buffer.trim(), + ) { + Ok(()) => { + self.openrouter_key_buffer.clear(); + connection_refresh = true; + self.openrouter_connection_status = Some( + language + .text("Credential stored in Windows Credential Manager") + .to_string(), + ); + } + Err(err) => { + self.openrouter_connection_status = + Some(err.to_string()); + } + } + } + if ui.button(language.text("Test")).clicked() { + let test_key = + if !self.openrouter_key_buffer.trim().is_empty() { + self.openrouter_key_buffer.trim().to_string() + } else { + crate::secret_store::read_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ) + .ok() + .flatten() + .unwrap_or_default() + }; + if !test_key.is_empty() { + let result_slot: Arc< + Mutex< + Option< + Result< + (f64, f64), + crate::poller::PollError, + >, + >, + >, + > = Arc::new(Mutex::new(None)); + self.openrouter_test_pending = + Some(result_slot.clone()); + self.openrouter_testing = true; + self.openrouter_test_label = None; + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + let outcome = crate::poller::openrouter::probe_management_key(&test_key); + if let Ok(mut guard) = result_slot.lock() { + *guard = Some(outcome); + } + ctx.request_repaint(); + }); + } + } + if ui.button(language.text("Remove stored key")).clicked() { + let _ = crate::secret_store::delete_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ); + connection_refresh = true; + self.openrouter_connection_status = None; + self.openrouter_test_label = None; + } + }); + + // Poll test result each frame. Clone the Arc so the + // temporary borrow of openrouter_test_pending ends + // before we write to it. + if self.openrouter_testing { + if let Some(arc) = self.openrouter_test_pending.clone() { + if let Ok(mut guard) = arc.try_lock() { + if let Some(result) = guard.take() { + drop(guard); + self.openrouter_testing = false; + self.openrouter_test_pending = None; + self.openrouter_test_label = + Some(match result { + Ok((credits, usage)) => format!( + "{} · {} ${:.2} · ${:.2} {}", + language.text("Connected"), + language.text("BALANCE"), + credits, + usage, + language.text("USED"), + ), + Err(err) => err.message(language), + }); + } + } + } + } + + if self.openrouter_testing { + ui.weak(language.text("Testing...")); + } else if let Some(ref label) = self.openrouter_test_label.clone() + { + ui.label(label); + } + + if key_stored { + ui.weak(language.text( + "Credential stored in Windows Credential Manager", + )); + } else { + ui.weak(language.text( + "No key configured. Create a Management API key on OpenRouter and paste it here.", + )); + } + + if ui + .button(language.text("Get OpenRouter management key")) + .clicked() + { + crate::native_interop::open_web_url( + None, + "https://openrouter.ai/settings/management-keys", + ); + } + }); + if connection_refresh { + self.request_refresh(); + } + + let period_key = |period: app_settings::BudgetPeriod| match period { + app_settings::BudgetPeriod::Daily => "Daily", + app_settings::BudgetPeriod::Weekly => "Weekly", + app_settings::BudgetPeriod::Monthly => "Monthly", + }; + egui::CollapsingHeader::new(language.text("Budget")) + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(language.text("Cap amount")); + let mut amount = + self.settings.openrouter_budget_amount.unwrap_or(0.0); + let response = NumberField::new(&mut amount) + .range(0.0..=1_000_000.0) + .speed(1.0) + .prefix("$") + .show(ui, 110.0); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.changed() { + self.settings.openrouter_budget_amount = + (amount > 0.0).then_some(amount); + changed = true; + } + // Enter (or ending a drag / leaving the field) recalculates + // the displayed cap right away with the current periods. + if response.drag_stopped() + || response.lost_focus() + || (enter && response.changed()) + { + budget_changed = true; + } + ui.weak(language.text("0 turns the spending cap off")); + }); + for (label, field) in [ + ("Cap period", &mut self.settings.openrouter_budget_period), + ("Monitor period", &mut self.settings.openrouter_monitor_period), + ] { + ui.horizontal(|ui| { + ui.label(language.text(label)); + let selected = language.text(period_key(*field)); + let salt = if label == "Cap period" { + "openrouter_budget_period" + } else { + "openrouter_monitor_period" + }; + let mut picked = false; + Dropdown::from_id_salt(salt) + .width(150.0) + .selected_text(selected) + .show_ui(ui, |ui| { + for period in [ + app_settings::BudgetPeriod::Daily, + app_settings::BudgetPeriod::Weekly, + app_settings::BudgetPeriod::Monthly, + ] { + picked |= dropdown_selectable_value( + ui, + field, + period, + language.text(period_key(period)), + ) + .changed(); + } + }); + if picked { + changed = true; + budget_changed = true; + } + }); + } + if ui + .checkbox( + &mut self.settings.openrouter_show_cap, + language.text("Show cap in the monitor"), + ) + .changed() + { + changed = true; + budget_changed = true; + } + ui.horizontal(|ui| { + ui.label(language.text("Monthly credits deposit")); + let mut deposit = + self.settings.openrouter_monthly_deposit.unwrap_or(0.0); + let response = NumberField::new(&mut deposit) + .range(0.0..=1_000_000.0) + .speed(1.0) + .prefix("$") + .show(ui, 110.0); + let enter = + ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.changed() { + self.settings.openrouter_monthly_deposit = + (deposit > 0.0).then_some(deposit); + changed = true; + } + if response.drag_stopped() + || response.lost_focus() + || (enter && response.changed()) + { + budget_changed = true; + } + ui.weak(language.text("0 uses the accumulated account balance")); + }); + if ui + .checkbox( + &mut self.settings.openrouter_show_deposit, + language.text("Show credits deposit in the monitor"), + ) + .changed() + { + changed = true; + budget_changed = true; + } + ui.add_space(4.0); + let reset_time = crate::theme_engine::local_time_of_utc_midnight(); + ui.add( + egui::Label::new( + egui::RichText::new( + language + .text( + "OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then.", + ) + .replace("{time}", &reset_time), + ) + .weak(), + ) + .wrap(), + ); + }); + } } }); section(ui, language.text("Display"), |ui| { @@ -198,6 +478,9 @@ impl StudioApp { self.preview_dirty = true; self.save_settings(); } + if budget_changed { + self.request_refresh(); + } if let Some(path) = requested_theme { self.request_activate_theme(path); } diff --git a/src/studio_app/tests.rs b/src/studio_app/tests.rs index 76b1e836..8257deb7 100644 --- a/src/studio_app/tests.rs +++ b/src/studio_app/tests.rs @@ -487,6 +487,11 @@ fn app_with_surfaces(surfaces: Vec) -> StudioApp { context_menu_dirty: false, context_menu_selection: None, delete_context_menu_confirmation: None, + openrouter_key_buffer: String::new(), + openrouter_connection_status: None, + openrouter_testing: false, + openrouter_test_pending: None, + openrouter_test_label: None, } } diff --git a/src/theme_engine.rs b/src/theme_engine.rs index d09e4bcf..0b0f1ef7 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1640,6 +1640,57 @@ impl DataContext { // that has been spent so warning thresholds keep working, while // `display` follows the countdown setting. self.insert_limits(name, usage, countdown); + // Daily / long-window spend for the two-line OpenRouter display. Always + // bound (0 when absent) so themes validate without live usage. + self.insert( + &format!("{name}.daily.display"), + limit_display(usage, "daily", countdown), + ); + self.insert( + &format!("{name}.daily.spent_usd"), + limit_scope_value(usage, "daily", "usage_usd"), + ); + self.insert( + &format!("{name}.daily.cap_usd"), + limit_scope_value(usage, "daily", "cap_usd"), + ); + self.insert( + &format!("{name}.long.display"), + limit_display(usage, "long", countdown), + ); + self.insert( + &format!("{name}.long.spent_usd"), + limit_scope_value(usage, "long", "usage_usd"), + ); + self.insert( + &format!("{name}.long.cap_usd"), + limit_scope_value(usage, "long", "cap_usd"), + ); + self.insert( + &format!("{name}.long.show_cap"), + limit_scope_value(usage, "long", "show_cap"), + ); + self.insert_string( + &format!("{name}.long.label"), + limit_label(usage, "long").as_deref().unwrap_or("30d"), + ); + // Account balance (line 1). Always bound so themes validate without usage. + self.insert( + &format!("{name}.account.remaining_pct"), + limit_scope_value(usage, "account", "remaining_pct"), + ); + self.insert( + &format!("{name}.account.balance_usd"), + limit_scope_value(usage, "account", "balance_usd"), + ); + self.insert( + &format!("{name}.account.total_usd"), + limit_scope_value(usage, "account", "total_usd"), + ); + self.insert( + &format!("{name}.account.show_deposit"), + limit_scope_value(usage, "account", "show_deposit"), + ); let display = |percentage: f64| { if countdown { 100.0 - percentage @@ -1764,6 +1815,12 @@ impl DataContext { &format!("{name}.credits.balance"), credits.map(|credits| credits.remaining).unwrap_or(0.0), ); + self.insert( + &format!("{name}.credits.spent"), + credits + .map(|credits| (credits.total - credits.remaining).max(0.0)) + .unwrap_or(0.0), + ); self.insert( &format!("{name}.credits.total"), credits.map(|credits| credits.total).unwrap_or(0.0), @@ -2028,6 +2085,58 @@ fn set_mouse_property_expression( } = expression; } +fn limit_display(usage: Option<&crate::models::UsageData>, key: &str, countdown: bool) -> f64 { + let percentage = usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .map(|limit| limit.usage.percentage) + .unwrap_or(0.0); + if countdown { + 100.0 - percentage + } else { + percentage + } +} + +fn limit_scope_value(usage: Option<&crate::models::UsageData>, key: &str, field: &str) -> f64 { + usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .and_then(|limit| limit.scope.as_ref()) + .and_then(|scope| scope.get(field)) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0) +} + +fn limit_label(usage: Option<&crate::models::UsageData>, key: &str) -> Option { + usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .map(|limit| limit.label.clone()) + .filter(|label| !label.is_empty()) +} + +/// Local clock time matching OpenRouter's 00:00 UTC daily reset (e.g. "21:00" +/// for UTC-3). Keeps the settings hint correct on any device timezone. +pub fn local_time_of_utc_midnight() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_secs_f64()) + .unwrap_or(0.0); + let (Some(local), Some(utc)) = ( + theme_datetime::timestamp_parts(now, true), + theme_datetime::timestamp_parts(now, false), + ) else { + return "00:00".into(); + }; + let mut offset = i32::from(local.hour) * 60 + i32::from(local.minute) + - (i32::from(utc.hour) * 60 + i32::from(utc.minute)); + if offset > 12 * 60 { + offset -= 24 * 60; + } else if offset < -12 * 60 { + offset += 24 * 60; + } + let reset = ((offset % (24 * 60)) + 24 * 60) % (24 * 60); + format!("{:02}:{:02}", reset / 60, reset % 60) +} + pub fn validate_mouse_action_script( source: &str, theme: &ThemeDocument, diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index a8bc40b9..5af618b7 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -892,14 +892,12 @@ fn starter_theme_round_trips_and_validates() { .collect::>(); // Classic contains separate light and dark progress layers so the // 1.4.9 palette follows the taskbar mode without runtime recolouring: - // five providers over two windows in two modes, plus Grok and Copilot, - // which fill only the long-window row, plus a credit overlay on that row - // for the three providers that report credits, plus the two eight-segment - // Fable layers. - assert_eq!( - segments, - [vec![10; 5 * 2 * 2 + 2 * 2 + 3 * 2], vec![8; 2]].concat() - ); + // five providers over two windows in two modes, plus Grok, Copilot and + // OpenRouter, which fill only the long-window row, plus a credit overlay + // on that row for the four providers that report credits, plus the two + // eight-segment Fable layers. + // The Fable layers sit just before OpenRouter now that OpenRouter is last. + assert_eq!(segments, [vec![10; 30], vec![8; 2], vec![10; 4]].concat()); assert!(theme.surfaces[0] .children .iter() @@ -1833,7 +1831,7 @@ fn starter_adapts_width_segments_and_collapsed_provider_rows() { ), ( ThemeRuntime::from_providers(ProviderSet::from_enabled(ProviderId::ALL)), - 725, + 827, 2, ), ] { @@ -2027,7 +2025,7 @@ fn classic_vertical_stacks_enabled_providers_and_preserves_tray_roots() { for host_width in [46, 62, 100] { let runtime = ThemeRuntime::from_providers(providers).with_host_dimensions(host_width, 1440); - let width = (host_width - 2).min(62); + let width = (host_width - 2).min(92); let provider_height = |provider| match provider { ProviderId::Grok | ProviderId::Copilot => 42, _ => 64, @@ -2161,19 +2159,36 @@ fn compact_fluent_quad_widget_respects_usage_direction() { UsageData { session: section.clone(), weekly: section.clone(), - limits: (provider == ProviderId::Claude) - .then(|| UsageLimit { - key: "weekly_scoped_fable".into(), - kind: "weekly_scoped".into(), - label: "Fable".into(), - model: Some("Fable".into()), - model_id: None, - scope: None, - is_active: true, - usage: section.clone(), - }) - .into_iter() - .collect(), + limits: { + let mut limits: Vec = (provider == ProviderId::Claude) + .then(|| UsageLimit { + key: "weekly_scoped_fable".into(), + kind: "weekly_scoped".into(), + label: "Fable".into(), + model: Some("Fable".into()), + model_id: None, + scope: None, + is_active: true, + usage: section.clone(), + }) + .into_iter() + .collect(); + // The OpenRouter column draws one line per window. + for key in ["daily", "long", "account"] { + limits.push(UsageLimit { + key: key.into(), + kind: key.into(), + label: "1d".into(), + model: None, + model_id: None, + scope: (key == "account") + .then(|| serde_json::json!({ "remaining_pct": 25.0 })), + is_active: true, + usage: section.clone(), + }); + } + limits + }, credits: Some(CreditsSection { percentage: 25.0, remaining: 24.1, @@ -2199,7 +2214,9 @@ fn compact_fluent_quad_widget_respects_usage_direction() { ); for object in &theme.surfaces[0].children { match &object.content { - SceneContent::Progress { value, .. } => { + // The balance bar intentionally shows the remaining fraction, + // so it does not follow the global count-up/count-down flip. + SceneContent::Progress { value, .. } if !value.0.contains("remaining_pct") => { assert_eq!( evaluate(&value.0, &context).unwrap(), expected, diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index d0841ff3..a07d71d2 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -523,6 +523,7 @@ pub(super) fn format_usage_line(base: &str, context: &DataContext) -> Option S if let Some(value) = format_timestamp(value, format, context) { return value; } + if format.eq_ignore_ascii_case("amount") { + return if value.fract().abs() < 1e-9 { + format!("{value:.0}") + } else { + format!("{value:.2}") + }; + } if format.eq_ignore_ascii_case("duration_short") { let seconds = value.max(0.0).round() as u64; let days = seconds / 86_400; diff --git a/src/theme_engine/theme_limits.rs b/src/theme_engine/theme_limits.rs index 1277ee11..fbcb18e2 100644 --- a/src/theme_engine/theme_limits.rs +++ b/src/theme_engine/theme_limits.rs @@ -74,6 +74,15 @@ impl DataContext { .map(serde_json::Value::to_string) .unwrap_or_default(), ); + // Dollar amounts and display flags carried in the scope become direct + // bindings so themes can print them (e.g. `openrouter.limits.long.usage_usd`). + if let Some(scope) = limit.scope.as_ref().and_then(|value| value.as_object()) { + for (field, value) in scope { + if let Some(value) = value.as_f64() { + self.insert(&format!("{base}.{field}"), value); + } + } + } let unix = limit .usage .resets_at @@ -118,6 +127,7 @@ impl DataContext { | "cursor" | "grok" | "copilot" + | "openrouter" | "active" ) { return None; diff --git a/src/ui/components/number_field.rs b/src/ui/components/number_field.rs index 8cee4b5a..6bd83d50 100644 --- a/src/ui/components/number_field.rs +++ b/src/ui/components/number_field.rs @@ -31,6 +31,11 @@ impl<'a> NumberField<'a> { self } + pub(crate) fn prefix(mut self, prefix: &'a str) -> Self { + self.widget = self.widget.prefix(prefix); + self + } + /// Shows the field in a row whose height cannot change when keyboard /// editing replaces the drag button with a text field. pub(crate) fn show(self, ui: &mut egui::Ui, width: f32) -> egui::Response { From cdddda77ae29a628611217f70c57eb08fcc7159b Mon Sep 17 00:00:00 2001 From: Caique Reis Date: Fri, 23 Oct 2026 17:14:50 -0300 Subject: [PATCH 3/4] feat(i18n): translate the OpenRouter strings in all 14 locales --- src/localization/locales/de.toml | 46 ++++++++++++++++++++++++ src/localization/locales/en.toml | 46 ++++++++++++++++++++++++ src/localization/locales/es.toml | 46 ++++++++++++++++++++++++ src/localization/locales/fr.toml | 46 ++++++++++++++++++++++++ src/localization/locales/ja.toml | 46 ++++++++++++++++++++++++ src/localization/locales/ko.toml | 46 ++++++++++++++++++++++++ src/localization/locales/nl.toml | 46 ++++++++++++++++++++++++ src/localization/locales/pl.toml | 46 ++++++++++++++++++++++++ src/localization/locales/pt-BR.toml | 56 ++++++++++++++++++++++++++--- src/localization/locales/ru.toml | 46 ++++++++++++++++++++++++ src/localization/locales/th.toml | 46 ++++++++++++++++++++++++ src/localization/locales/tr.toml | 46 ++++++++++++++++++++++++ src/localization/locales/zh-CN.toml | 46 ++++++++++++++++++++++++ src/localization/locales/zh-TW.toml | 46 ++++++++++++++++++++++++ 14 files changed, 649 insertions(+), 5 deletions(-) diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index df432b1e..53c5077d 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Collect usage from GitHub" = "Nutzungsdaten von GitHub abrufen" "Collect usage from Google" = "Nutzungsdaten von Google abrufen" "Collect usage from OpenAI" = "Nutzungsdaten von OpenAI abrufen" +"Collect usage from OpenRouter" = "Nutzungsdaten von OpenRouter abrufen" +"Show cap in the monitor" = "Limit im Monitor anzeigen" +"Monthly credits deposit" = "Monatliche Guthaben-Einzahlung" +"Show credits deposit in the monitor" = "Guthaben-Einzahlung im Monitor anzeigen" +"0 uses the accumulated account balance" = "0 nutzt das kumulierte Kontoguthaben" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter zählt Tagesausgaben in UTC: Der Tag wechselt um 00:00 UTC, was in Ihrer Zeitzone {time} entspricht; danach beginnt der Tageswert neu." +"Budget" = "Budget" +"Cap amount" = "Limitbetrag" +"Cap period" = "Limitzeitraum" +"Monitor period" = "Überwachungszeitraum" +"0 turns the spending cap off" = "0 deaktiviert die Ausgabenobergrenze" +"Daily" = "Täglich" +"Weekly" = "Wöchentlich" +"Monthly" = "Monatlich" "Collect usage from xAI" = "Nutzungsdaten von xAI abrufen" "Colour" = "Farbe" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Konten" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Designs verwenden das Standardkonto, sofern sie kein Konto angeben. Eigene Designs können mehrere Konten anzeigen." +"Default account" = "Standardkonto" +"Account name" = "Kontoname" +"Remove" = "Entfernen" +"Config directory" = "Konfigurationsverzeichnis" +"Custom credentials file" = "Eigene Anmeldedatendatei" +"Optional; overrides the config directory" = "Optional; überschreibt das Konfigurationsverzeichnis" +"Browse..." = "Durchsuchen..." +"Select credentials file" = "Anmeldedatendatei auswählen" +"Theme binding" = "Design-Bindung" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Verwendet CLAUDE_CONFIG_DIR, wenn gesetzt; sonst werden CLI-, Desktop- oder WSL-Anmeldedaten erkannt." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Verwendet CODEX_HOME, wenn gesetzt; sonst ~/.codex/auth.json." +"Monitoring disabled" = "Überwachung deaktiviert" +"Last known usage" = "Zuletzt bekannter Verbrauch" +"Session" = "Sitzung" +"Credits" = "Guthaben" +"Resets in" = "Zurücksetzung in" +"Waiting for usage" = "Warte auf Verbrauchsdaten" +"Add account" = "Konto hinzufügen" +"Connection" = "Verbindung" +"Management key" = "Management-Schlüssel" +"Test" = "Testen" +"Remove stored key" = "Gespeicherten Schlüssel entfernen" +"Credential stored in Windows Credential Manager" = "Anmeldedaten in der Windows-Anmeldeinformationsverwaltung gespeichert" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Kein Schlüssel konfiguriert. Erstellen Sie einen Management API key auf OpenRouter und fügen Sie ihn hier ein." +"Get OpenRouter management key" = "OpenRouter-Verwaltungsschlüssel abrufen" +"Testing..." = "Teste läuft..." +"Connected" = "Verbunden" +"BALANCE" = "GUTHABEN" +"USED" = "VERWENDET" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Fügen Sie einen Management-API-Schlüssel unter Einstellungen → Anbieter → OpenRouter → Verbindung hinzu und aktualisieren Sie diesen Monitor." diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 2273418e..6be2e4a6 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot Usage Monitor" "Collect usage from GitHub" = "Collect usage from GitHub" "Collect usage from Google" = "Collect usage from Google" "Collect usage from OpenAI" = "Collect usage from OpenAI" +"Collect usage from OpenRouter" = "Collect usage from OpenRouter" +"Show cap in the monitor" = "Show cap in the monitor" +"Monthly credits deposit" = "Monthly credits deposit" +"Show credits deposit in the monitor" = "Show credits deposit in the monitor" +"0 uses the accumulated account balance" = "0 uses the accumulated account balance" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." +"Budget" = "Budget" +"Cap amount" = "Cap amount" +"Cap period" = "Cap period" +"Monitor period" = "Monitor period" +"0 turns the spending cap off" = "0 turns the spending cap off" +"Daily" = "Daily" +"Weekly" = "Weekly" +"Monthly" = "Monthly" "Collect usage from xAI" = "Collect usage from xAI" "Colour" = "Colour" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot Usage Monitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Accounts" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Themes use the default account unless they specify an account. Custom themes can display multiple accounts." +"Default account" = "Default account" +"Account name" = "Account name" +"Remove" = "Remove" +"Config directory" = "Config directory" +"Custom credentials file" = "Custom credentials file" +"Optional; overrides the config directory" = "Optional; overrides the config directory" +"Browse..." = "Browse..." +"Select credentials file" = "Select credentials file" +"Theme binding" = "Theme binding" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." +"Monitoring disabled" = "Monitoring disabled" +"Last known usage" = "Last known usage" +"Session" = "Session" +"Credits" = "Credits" +"Resets in" = "Resets in" +"Waiting for usage" = "Waiting for usage" +"Add account" = "Add account" +"Connection" = "Connection" +"Management key" = "Management key" +"Test" = "Test" +"Remove stored key" = "Remove stored key" +"Credential stored in Windows Credential Manager" = "Credential stored in Windows Credential Manager" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "No key configured. Create a Management API key on OpenRouter and paste it here." +"Get OpenRouter management key" = "Get OpenRouter management key" +"Testing..." = "Testing..." +"Connected" = "Connected" +"BALANCE" = "BALANCE" +"USED" = "USED" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 41ac2ed6..6f9dd0be 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor de uso de Copilot" "Collect usage from GitHub" = "Recopilar uso de GitHub" "Collect usage from Google" = "Recopilar uso de Google" "Collect usage from OpenAI" = "Recopilar uso de OpenAI" +"Collect usage from OpenRouter" = "Recopilar uso de OpenRouter" +"Show cap in the monitor" = "Mostrar límite en el monitor" +"Monthly credits deposit" = "Depósito de créditos (mensual)" +"Show credits deposit in the monitor" = "Mostrar depósito de créditos en el monitor" +"0 uses the accumulated account balance" = "0 usa el saldo acumulado de la cuenta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter cuenta el gasto diario en UTC: el día cambia a las 00:00 UTC, que en tu zona horaria es {time}, así que el valor diario se reinicia a esa hora." +"Budget" = "Presupuesto" +"Cap amount" = "Monto del límite" +"Cap period" = "Período del límite" +"Monitor period" = "Período monitorizado" +"0 turns the spending cap off" = "0 desactiva el límite de gasto" +"Daily" = "Diario" +"Weekly" = "Semanal" +"Monthly" = "Mensual" "Collect usage from xAI" = "Recopilar uso de xAI" "Colour" = "Color" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Monitor de uso de Copilot" "Lock in taskbar" = "Fijar en la barra" "Keep the widget docked; never float automatically" = "Mantener el widget acoplado; no flotar automáticamente" "Locked" = "Fijado" +"Accounts" = "Cuentas" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Los temas usan la cuenta predeterminada a menos que especifiquen una cuenta. Los temas personalizados pueden mostrar varias cuentas." +"Default account" = "Cuenta predeterminada" +"Account name" = "Nombre de la cuenta" +"Remove" = "Quitar" +"Config directory" = "Directorio de configuración" +"Custom credentials file" = "Archivo de credenciales personalizado" +"Optional; overrides the config directory" = "Opcional; reemplaza el directorio de configuración" +"Browse..." = "Examinar..." +"Select credentials file" = "Seleccionar archivo de credenciales" +"Theme binding" = "Vinculación de tema" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Usa CLAUDE_CONFIG_DIR cuando está definido; de lo contrario, detecta credenciales de la CLI, el Escritorio o WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Usa CODEX_HOME cuando está definido; de lo contrario, ~/.codex/auth.json." +"Monitoring disabled" = "Supervisión desactivada" +"Last known usage" = "Último consumo conocido" +"Session" = "Sesión" +"Credits" = "Créditos" +"Resets in" = "Se restablece en" +"Waiting for usage" = "Esperando datos de uso" +"Add account" = "Agregar cuenta" +"Connection" = "Conexión" +"Management key" = "Clave de administración" +"Test" = "Probar" +"Remove stored key" = "Eliminar clave almacenada" +"Credential stored in Windows Credential Manager" = "Credencial almacenada en el Administrador de credenciales de Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Sin clave configurada. Crea una Management API key en OpenRouter y pégala aquí." +"Get OpenRouter management key" = "Obtener clave de gestión de OpenRouter" +"Testing..." = "Probando..." +"Connected" = "Conectado" +"BALANCE" = "SALDO" +"USED" = "USADO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Añade una Management API key en Ajustes → Proveedores → OpenRouter → Conexión y actualiza este monitor." diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 34545dcf..f358b12e 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -131,6 +131,20 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Collect usage from GitHub" = "Collecter l'utilisation depuis GitHub" "Collect usage from Google" = "Collecter l'utilisation depuis Google" "Collect usage from OpenAI" = "Collecter l'utilisation depuis OpenAI" +"Collect usage from OpenRouter" = "Collecter l'utilisation depuis OpenRouter" +"Show cap in the monitor" = "Afficher le plafond dans le moniteur" +"Monthly credits deposit" = "Dépôt de crédits (mensuel)" +"Show credits deposit in the monitor" = "Afficher le dépôt de crédits dans le moniteur" +"0 uses the accumulated account balance" = "0 utilise le solde cumulé du compte" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter compte les dépenses quotidiennes en UTC : le jour bascule à 00:00 UTC, soit {time} dans votre fuseau, le montant quotidien repart donc à ce moment-là." +"Budget" = "Budget" +"Cap amount" = "Montant du plafond" +"Cap period" = "Période du plafond" +"Monitor period" = "Période surveillée" +"0 turns the spending cap off" = "0 désactive le plafond de dépenses" +"Daily" = "Quotidien" +"Weekly" = "Hebdomadaire" +"Monthly" = "Mensuel" "Collect usage from xAI" = "Collecter l'utilisation depuis xAI" "Colour" = "Couleur" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Comptes" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Les thèmes utilisent le compte par défaut sauf s'ils spécifient un compte. Les thèmes personnalisés peuvent afficher plusieurs comptes." +"Default account" = "Compte par défaut" +"Account name" = "Nom du compte" +"Remove" = "Supprimer" +"Config directory" = "Répertoire de configuration" +"Custom credentials file" = "Fichier d'identifiants personnalisé" +"Optional; overrides the config directory" = "Facultatif ; remplace le répertoire de configuration" +"Browse..." = "Parcourir..." +"Select credentials file" = "Sélectionner le fichier d'identifiants" +"Theme binding" = "Liaison de thème" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Utilise CLAUDE_CONFIG_DIR lorsqu'il est défini ; sinon, détecte les identifiants de la CLI, du Bureau ou de WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Utilise CODEX_HOME lorsqu'il est défini ; sinon ~/.codex/auth.json." +"Monitoring disabled" = "Surveillance désactivée" +"Last known usage" = "Dernière consommation connue" +"Session" = "Session" +"Credits" = "Crédits" +"Resets in" = "Réinitialisation dans" +"Waiting for usage" = "En attente des données d'utilisation" +"Add account" = "Ajouter un compte" +"Connection" = "Connexion" +"Management key" = "Clé de gestion" +"Test" = "Tester" +"Remove stored key" = "Supprimer la clé enregistrée" +"Credential stored in Windows Credential Manager" = "Identifiant stocké dans le Gestionnaire d'informations d'identification Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Aucune clé configurée. Créez une Management API key sur OpenRouter et collez-la ici." +"Get OpenRouter management key" = "Obtenir la clé de gestion OpenRouter" +"Testing..." = "Test en cours..." +"Connected" = "Connecté" +"BALANCE" = "SOLDE" +"USED" = "UTILISÉ" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Ajoutez une Management API key dans Paramètres → Fournisseurs → OpenRouter → Connexion, puis actualisez ce moniteur." diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 6d6de298..a237ab0d 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量モニター" "Collect usage from GitHub" = "GitHub から使用量を取得" "Collect usage from Google" = "Google から使用量を取得" "Collect usage from OpenAI" = "OpenAI から使用量を取得" +"Collect usage from OpenRouter" = "OpenRouter から使用量を取得" +"Show cap in the monitor" = "モニターに上限を表示" +"Monthly credits deposit" = "クレジット入金(月次)" +"Show credits deposit in the monitor" = "モニターにクレジット入金を表示" +"0 uses the accumulated account balance" = "0 の場合は累計アカウント残高を使用" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter は日次の利用額を UTC で集計します。日付は 00:00 UTC(お使いのタイムゾーンでは {time})に切り替わるため、その時刻に日次値がリセットされます。" +"Budget" = "予算" +"Cap amount" = "上限額" +"Cap period" = "上限期間" +"Monitor period" = "監視期間" +"0 turns the spending cap off" = "0 で支出上限を無効化" +"Daily" = "日次" +"Weekly" = "週次" +"Monthly" = "月次" "Collect usage from xAI" = "xAI から使用量を取得" "Colour" = "色" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量モニター" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "アカウント" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "テーマはアカウントを指定しない限り既定のアカウントを使用します。カスタムテーマでは複数のアカウントを表示できます。" +"Default account" = "既定のアカウント" +"Account name" = "アカウント名" +"Remove" = "削除" +"Config directory" = "設定ディレクトリ" +"Custom credentials file" = "カスタム認証情報ファイル" +"Optional; overrides the config directory" = "任意。設定ディレクトリを上書きします" +"Browse..." = "参照..." +"Select credentials file" = "認証情報ファイルを選択" +"Theme binding" = "テーマの紐付け" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "CLAUDE_CONFIG_DIR が設定されていれば使用します。それ以外は CLI、デスクトップ、WSL の認証情報を検出します。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "CODEX_HOME が設定されていれば使用します。それ以外は ~/.codex/auth.json を使用します。" +"Monitoring disabled" = "監視は無効です" +"Last known usage" = "最後に確認した使用量" +"Session" = "セッション" +"Credits" = "クレジット" +"Resets in" = "リセットまで" +"Waiting for usage" = "使用状況を待機中" +"Add account" = "アカウントを追加" +"Connection" = "接続" +"Management key" = "マネジメントキー" +"Test" = "テスト" +"Remove stored key" = "保存済みキーを削除" +"Credential stored in Windows Credential Manager" = "Windows資格情報マネージャーに認証情報が保存されています" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "キーが設定されていません。OpenRouterでManagement APIキーを作成してここに貼り付けてください。" +"Get OpenRouter management key" = "OpenRouter の管理キーを取得" +"Testing..." = "テスト中..." +"Connected" = "接続済み" +"BALANCE" = "残高" +"USED" = "使用済み" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "設定 → プロバイダー → OpenRouter → 接続 で Management API key を追加し、このモニターを更新してください。" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 6d73478c..57793850 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 사용량 모니터" "Collect usage from GitHub" = "GitHub에서 사용량 수집" "Collect usage from Google" = "Google에서 사용량 수집" "Collect usage from OpenAI" = "OpenAI에서 사용량 수집" +"Collect usage from OpenRouter" = "OpenRouter에서 사용량 수집" +"Show cap in the monitor" = "모니터에 상한 표시" +"Monthly credits deposit" = "월간 크레딧 충전" +"Show credits deposit in the monitor" = "모니터에 크레딧 충전 표시" +"0 uses the accumulated account balance" = "0이면 누적 계정 잔액 사용" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter는 일일 사용량을 UTC로 계산합니다. 하루는 00:00 UTC(사용자의 시간대 기준 {time})에 바뀌므로 그 시점에 일일 값이 초기화됩니다." +"Budget" = "예산" +"Cap amount" = "한도 금액" +"Cap period" = "한도 기간" +"Monitor period" = "모니터링 기간" +"0 turns the spending cap off" = "0이면 지출 상한 해제" +"Daily" = "일간" +"Weekly" = "주간" +"Monthly" = "월간" "Collect usage from xAI" = "xAI에서 사용량 수집" "Colour" = "색상" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 사용량 모니터" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "계정" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "테마는 계정을 지정하지 않는 한 기본 계정을 사용합니다. 사용자 지정 테마는 여러 계정을 표시할 수 있습니다." +"Default account" = "기본 계정" +"Account name" = "계정 이름" +"Remove" = "제거" +"Config directory" = "구성 디렉터리" +"Custom credentials file" = "사용자 지정 자격 증명 파일" +"Optional; overrides the config directory" = "선택 사항. 구성 디렉터리를 재정의합니다" +"Browse..." = "찾아보기..." +"Select credentials file" = "자격 증명 파일 선택" +"Theme binding" = "테마 바인딩" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "CLAUDE_CONFIG_DIR이 설정되어 있으면 사용하며, 그렇지 않으면 CLI, 데스크톱 또는 WSL 자격 증명을 감지합니다." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "CODEX_HOME이 설정되어 있으면 사용하며, 그렇지 않으면 ~/.codex/auth.json을 사용합니다." +"Monitoring disabled" = "모니터링 비활성화됨" +"Last known usage" = "마지막으로 알려진 사용량" +"Session" = "세션" +"Credits" = "크레딧" +"Resets in" = "재설정까지" +"Waiting for usage" = "사용 데이터 대기 중" +"Add account" = "계정 추가" +"Connection" = "연결" +"Management key" = "관리 키" +"Test" = "테스트" +"Remove stored key" = "저장된 키 제거" +"Credential stored in Windows Credential Manager" = "Windows 자격 증명 관리자에 자격 증명이 저장되었습니다" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "키가 구성되지 않았습니다. OpenRouter에서 Management API 키를 만들고 여기에 붙여 넣으세요." +"Get OpenRouter management key" = "OpenRouter 관리 키 가져오기" +"Testing..." = "테스트 중..." +"Connected" = "연결됨" +"BALANCE" = "잔액" +"USED" = "사용됨" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "설정 → 공급자 → OpenRouter → 연결에서 Management API key를 추가한 뒤 이 모니터를 새로 고치세요." diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 50550807..55b70f8f 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Collect usage from GitHub" = "Gebruiksgegevens ophalen bij GitHub" "Collect usage from Google" = "Gebruiksgegevens ophalen bij Google" "Collect usage from OpenAI" = "Gebruiksgegevens ophalen bij OpenAI" +"Collect usage from OpenRouter" = "Gebruiksgegevens ophalen bij OpenRouter" +"Show cap in the monitor" = "Limiet in de monitor tonen" +"Monthly credits deposit" = "Maandelijkse kredietstorting" +"Show credits deposit in the monitor" = "Kredietstorting in de monitor tonen" +"0 uses the accumulated account balance" = "0 gebruikt het cumulatieve accountsaldo" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter rekent daguitgaven in UTC: de dag wisselt om 00:00 UTC (in uw tijdzone {time}), dus de dagwaarde begint dan opnieuw." +"Budget" = "Budget" +"Cap amount" = "Plafondbedrag" +"Cap period" = "Plafondperiode" +"Monitor period" = "Meetperiode" +"0 turns the spending cap off" = "0 schakelt de bestedingslimiet uit" +"Daily" = "Dagelijks" +"Weekly" = "Wekelijks" +"Monthly" = "Maandelijks" "Collect usage from xAI" = "Gebruiksgegevens ophalen bij xAI" "Colour" = "Kleur" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Accounts" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Thema's gebruiken het standaardaccount tenzij ze een account opgeven. Aangepaste thema's kunnen meerdere accounts tonen." +"Default account" = "Standaardaccount" +"Account name" = "Accountnaam" +"Remove" = "Verwijderen" +"Config directory" = "Configuratiemap" +"Custom credentials file" = "Aangepast inloggegevensbestand" +"Optional; overrides the config directory" = "Optioneel; overschrijft de configuratiemap" +"Browse..." = "Bladeren..." +"Select credentials file" = "Inloggegevensbestand selecteren" +"Theme binding" = "Themabinding" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Gebruikt CLAUDE_CONFIG_DIR indien ingesteld; anders worden CLI-, Desktop- of WSL-inloggegevens gedetecteerd." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Gebruikt CODEX_HOME indien ingesteld; anders ~/.codex/auth.json." +"Monitoring disabled" = "Bewaking uitgeschakeld" +"Last known usage" = "Laatst bekende verbruik" +"Session" = "Sessie" +"Credits" = "Tegoed" +"Resets in" = "Reset over" +"Waiting for usage" = "Wachten op verbruiksgegevens" +"Add account" = "Account toevoegen" +"Connection" = "Verbinding" +"Management key" = "Beheersleutel" +"Test" = "Testen" +"Remove stored key" = "Opgeslagen sleutel verwijderen" +"Credential stored in Windows Credential Manager" = "Referentie opgeslagen in Windows Referentiebeheer" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Geen sleutel geconfigureerd. Maak een Management API key aan op OpenRouter en plak deze hier." +"Get OpenRouter management key" = "OpenRouter-beheersleutel ophalen" +"Testing..." = "Testen..." +"Connected" = "Verbonden" +"BALANCE" = "SALDO" +"USED" = "GEBRUIKT" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Voeg een Management API key toe via Instellingen → Providers → OpenRouter → Verbinding en ververs deze monitor." diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index c743ce77..b30a0656 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor zużycia Copilot" "Collect usage from GitHub" = "Zbieraj dane o zużyciu z GitHuba" "Collect usage from Google" = "Zbieraj dane o zużyciu z Google" "Collect usage from OpenAI" = "Zbieraj dane o zużyciu z OpenAI" +"Collect usage from OpenRouter" = "Zbieraj dane o zużyciu z OpenRouter" +"Show cap in the monitor" = "Pokaż limit w monitorze" +"Monthly credits deposit" = "Miesięczna wpłata kredytów" +"Show credits deposit in the monitor" = "Pokaż wpłatę kredytów w monitorze" +"0 uses the accumulated account balance" = "0 oznacza skumulowane saldo konta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter liczy dzienne wydatki w UTC: doba zmienia się o 00:00 UTC (w Twojej strefie czasowej {time}), więc dzienna wartość zaczyna się wtedy od nowa." +"Budget" = "Budżet" +"Cap amount" = "Kwota limitu" +"Cap period" = "Okres limitu" +"Monitor period" = "Okres monitorowania" +"0 turns the spending cap off" = "0 wyłącza limit wydatków" +"Daily" = "Dziennie" +"Weekly" = "Tygodniowo" +"Monthly" = "Miesięcznie" "Collect usage from xAI" = "Zbieraj dane o zużyciu z xAI" "Colour" = "Kolor" "Column" = "Kolumna" @@ -457,3 +471,35 @@ copilot_window_title = "Monitor zużycia Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Konta" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Motywy używają konta domyślnego, chyba że określą konto. Motywy niestandardowe mogą wyświetlać wiele kont." +"Default account" = "Konto domyślne" +"Account name" = "Nazwa konta" +"Remove" = "Usuń" +"Config directory" = "Katalog konfiguracji" +"Custom credentials file" = "Niestandardowy plik poświadczeń" +"Optional; overrides the config directory" = "Opcjonalne; zastępuje katalog konfiguracji" +"Browse..." = "Przeglądaj..." +"Select credentials file" = "Wybierz plik poświadczeń" +"Theme binding" = "Powiązanie motywu" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Używa CLAUDE_CONFIG_DIR, jeśli jest ustawiona; w przeciwnym razie wykrywa poświadczenia CLI, Pulpitu lub WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Używa CODEX_HOME, jeśli jest ustawiona; w przeciwnym razie ~/.codex/auth.json." +"Monitoring disabled" = "Monitorowanie wyłączone" +"Last known usage" = "Ostatnie znane zużycie" +"Session" = "Sesja" +"Credits" = "Kredyty" +"Resets in" = "Reset za" +"Waiting for usage" = "Oczekiwanie na dane użycia" +"Add account" = "Dodaj konto" +"Connection" = "Połączenie" +"Management key" = "Klucz zarządzania" +"Test" = "Testuj" +"Remove stored key" = "Usuń zapisany klucz" +"Credential stored in Windows Credential Manager" = "Poświadczenia zapisane w Menedżerze poświadczeń systemu Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Brak skonfigurowanego klucza. Utwórz klucz Management API na OpenRouter i wklej go tutaj." +"Get OpenRouter management key" = "Pobierz klucz zarządzania OpenRouter" +"Testing..." = "Testowanie..." +"Connected" = "Połączono" +"BALANCE" = "SALDO" +"USED" = "UŻYTO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Dodaj Management API key w Ustawienia → Dostawcy → OpenRouter → Połączenie i odśwież ten monitor." diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 7719834b..a4745031 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor de uso do Copilot" "Collect usage from GitHub" = "Coletar uso do GitHub" "Collect usage from Google" = "Coletar uso do Google" "Collect usage from OpenAI" = "Coletar uso da OpenAI" +"Collect usage from OpenRouter" = "Coletar uso do OpenRouter" +"Show cap in the monitor" = "Mostrar teto no monitor" +"Monthly credits deposit" = "Depósito de créditos (mensal)" +"Show credits deposit in the monitor" = "Mostrar depósito de créditos no monitor" +"0 uses the accumulated account balance" = "0 usa o saldo acumulado da conta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "O OpenRouter conta o gasto diário em UTC: o dia vira à meia-noite UTC, que no seu fuso é {time}, então o valor diário zera nesse horário." +"Budget" = "Orçamento" +"Cap amount" = "Valor do teto" +"Cap period" = "Período do teto" +"Monitor period" = "Período monitorado" +"0 turns the spending cap off" = "0 desativa o teto de gastos" +"Daily" = "Diário" +"Weekly" = "Semanal" +"Monthly" = "Mensal" "Collect usage from xAI" = "Coletar uso da xAI" "Colour" = "Cor" "Column" = "Column" @@ -207,7 +221,7 @@ copilot_window_title = "Monitor de uso do Copilot" "False" = "Falso" "Fill" = "Preenchimento" "Fit" = "Ajuste" -"Floating" = "Floating" +"Floating" = "Flutuante" "Font" = "Fonte" "Format" = "Formato" "Freeform" = "Freeform" @@ -432,7 +446,7 @@ copilot_window_title = "Monitor de uso do Copilot" "Use asset" = "Usar recurso" "Use Builder" = "Usar Construtor" "Use the mouse wheel over the canvas to zoom" = "Use the mouse wheel over the canvas to zoom" -"Used" = "Used" +"Used" = "Usado" "Uses the same safe action language as layer mouse events." = "Uses the same safe action language as layer mouse events." "Valid actions" = "Ações válidas" "Valid expression" = "Expressão válida" @@ -454,6 +468,38 @@ copilot_window_title = "Monitor de uso do Copilot" "X" = "X" "Y offset" = "Deslocamento Y" "Y" = "Y" -"Lock in taskbar" = "Lock in taskbar" -"Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" -"Locked" = "Locked" +"Lock in taskbar" = "Fixar na barra de tarefas" +"Keep the widget docked; never float automatically" = "Manter o widget acoplado; nunca flutuar automaticamente" +"Locked" = "Fixo" +"Accounts" = "Contas" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Os temas usam a conta padrão, a menos que especifiquem uma conta. Temas personalizados podem exibir várias contas." +"Default account" = "Conta padrão" +"Account name" = "Nome da conta" +"Remove" = "Remover" +"Config directory" = "Diretório de configuração" +"Custom credentials file" = "Arquivo de credenciais personalizado" +"Optional; overrides the config directory" = "Opcional; substitui o diretório de configuração" +"Browse..." = "Procurar..." +"Select credentials file" = "Selecionar arquivo de credenciais" +"Theme binding" = "Vinculação de tema" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Usa CLAUDE_CONFIG_DIR quando definido; caso contrário, detecta credenciais da CLI, do Desktop ou do WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Usa CODEX_HOME quando definido; caso contrário, ~/.codex/auth.json." +"Monitoring disabled" = "Monitoramento desativado" +"Last known usage" = "Último consumo conhecido" +"Session" = "Sessão" +"Credits" = "Créditos" +"Resets in" = "Reinicia em" +"Waiting for usage" = "Aguardando dados de uso" +"Add account" = "Adicionar conta" +"Connection" = "Conexão" +"Management key" = "Chave de gerenciamento" +"Test" = "Testar" +"Remove stored key" = "Remover chave armazenada" +"Credential stored in Windows Credential Manager" = "Credencial armazenada no Gerenciador de Credenciais do Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Nenhuma chave configurada. Crie uma Management API key no OpenRouter e cole aqui." +"Get OpenRouter management key" = "Obter chave de gestão do OpenRouter" +"Testing..." = "Testando..." +"Connected" = "Conectado" +"BALANCE" = "SALDO" +"USED" = "USADO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Adicione uma Management API key em Configurações → Provedores → OpenRouter → Conexão e atualize este monitor." diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 19d8aa49..952a1501 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -131,6 +131,20 @@ copilot_window_title = "Монитор использования Copilot" "Collect usage from GitHub" = "Получать данные об использовании GitHub" "Collect usage from Google" = "Получать данные об использовании Google" "Collect usage from OpenAI" = "Получать данные об использовании OpenAI" +"Collect usage from OpenRouter" = "Получать данные об использовании OpenRouter" +"Show cap in the monitor" = "Показывать лимит в мониторе" +"Monthly credits deposit" = "Пополнение кредитов (ежемесячно)" +"Show credits deposit in the monitor" = "Показывать пополнение кредитов в мониторе" +"0 uses the accumulated account balance" = "0 — использовать накопленный баланс аккаунта" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter считает дневные расходы в UTC: сутки меняются в 00:00 UTC (в вашем часовом поясе это {time}), поэтому дневное значение начинается заново в это время." +"Budget" = "Бюджет" +"Cap amount" = "Сумма лимита" +"Cap period" = "Период лимита" +"Monitor period" = "Период мониторинга" +"0 turns the spending cap off" = "0 отключает лимит расходов" +"Daily" = "Ежедневно" +"Weekly" = "Еженедельно" +"Monthly" = "Ежемесячно" "Collect usage from xAI" = "Получать данные об использовании xAI" "Colour" = "Цвет" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Монитор использования Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Аккаунты" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Темы используют аккаунт по умолчанию, если не указан другой. Пользовательские темы могут показывать несколько аккаунтов." +"Default account" = "Аккаунт по умолчанию" +"Account name" = "Имя аккаунта" +"Remove" = "Удалить" +"Config directory" = "Каталог конфигурации" +"Custom credentials file" = "Пользовательский файл учётных данных" +"Optional; overrides the config directory" = "Необязательно; заменяет каталог конфигурации" +"Browse..." = "Обзор..." +"Select credentials file" = "Выбрать файл учётных данных" +"Theme binding" = "Привязка темы" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Использует CLAUDE_CONFIG_DIR, если задан; иначе определяет учётные данные CLI, Desktop или WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Использует CODEX_HOME, если задан; иначе ~/.codex/auth.json." +"Monitoring disabled" = "Отслеживание отключено" +"Last known usage" = "Последнее известное потребление" +"Session" = "Сессия" +"Credits" = "Кредиты" +"Resets in" = "Сброс через" +"Waiting for usage" = "Ожидание данных об использовании" +"Add account" = "Добавить аккаунт" +"Connection" = "Подключение" +"Management key" = "Ключ управления" +"Test" = "Тест" +"Remove stored key" = "Удалить сохранённый ключ" +"Credential stored in Windows Credential Manager" = "Учётные данные сохранены в Диспетчере учётных данных Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Ключ не настроен. Создайте Management API key на OpenRouter и вставьте его сюда." +"Get OpenRouter management key" = "Получить ключ управления OpenRouter" +"Testing..." = "Тестирование..." +"Connected" = "Подключено" +"BALANCE" = "БАЛАНС" +"USED" = "ИСПОЛЬЗОВАНО" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Добавьте Management API key в Настройки → Провайдеры → OpenRouter → Подключение и обновите этот монитор." diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index c2a936ee..a5d76a69 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -133,6 +133,20 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Collect usage from GitHub" = "ดึงข้อมูลการใช้งานจาก GitHub" "Collect usage from Google" = "ดึงข้อมูลการใช้งานจาก Google" "Collect usage from OpenAI" = "ดึงข้อมูลการใช้งานจาก OpenAI" +"Collect usage from OpenRouter" = "ดึงข้อมูลการใช้งานจาก OpenRouter" +"Show cap in the monitor" = "แสดงเพดานในตัวตรวจสอบ" +"Monthly credits deposit" = "เติมเครดิต (รายเดือน)" +"Show credits deposit in the monitor" = "แสดงการเติมเครดิตในตัวตรวจสอบ" +"0 uses the accumulated account balance" = "0 หมายถึงใช้ยอดคงเหลือสะสมในบัญชี" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter คำนวณการใช้จ่ายรายวันเป็น UTC: วันจะเปลี่ยนเมื่อ 00:00 UTC (ตามเขตเวลาของคุณคือ {time}) ดังนั้นค่ายอดรายวันจะเริ่มใหม่ ณ เวลานั้น" +"Budget" = "งบประมาณ" +"Cap amount" = "จำนวนเพดาน" +"Cap period" = "ช่วงเพดาน" +"Monitor period" = "ช่วงที่ตรวจสอบ" +"0 turns the spending cap off" = "0 หมายถึงปิดเพดานการใช้จ่าย" +"Daily" = "รายวัน" +"Weekly" = "รายสัปดาห์" +"Monthly" = "รายเดือน" "Collect usage from xAI" = "ดึงข้อมูลการใช้งานจาก xAI" "Colour" = "สี" "Column" = "คอลัมน์" @@ -459,3 +473,35 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "บัญชี" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "ธีมจะใช้บัญชีเริ่มต้นเว้นแต่จะระบุบัญชี ธีมที่กำหนดเองสามารถแสดงหลายบัญชีได้" +"Default account" = "บัญชีเริ่มต้น" +"Account name" = "ชื่อบัญชี" +"Remove" = "ลบ" +"Config directory" = "ไดเรกทอรีการกำหนดค่า" +"Custom credentials file" = "ไฟล์ข้อมูลรับรองที่กำหนดเอง" +"Optional; overrides the config directory" = "ไม่บังคับ; แทนที่ไดเรกทอรีการกำหนดค่า" +"Browse..." = "เรียกดู..." +"Select credentials file" = "เลือกไฟล์ข้อมูลรับรอง" +"Theme binding" = "การผูกธีม" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "ใช้ CLAUDE_CONFIG_DIR เมื่อมีการตั้งค่า; มิฉะนั้นจะตรวจหาข้อมูลรับรองของ CLI, Desktop หรือ WSL" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "ใช้ CODEX_HOME เมื่อมีการตั้งค่า; มิฉะนั้นใช้ ~/.codex/auth.json" +"Monitoring disabled" = "ปิดการเฝ้าติดตาม" +"Last known usage" = "การใช้งานล่าสุดที่ทราบ" +"Session" = "เซสชัน" +"Credits" = "เครดิต" +"Resets in" = "รีเซ็ตใน" +"Waiting for usage" = "กำลังรอข้อมูลการใช้งาน" +"Add account" = "เพิ่มบัญชี" +"Connection" = "การเชื่อมต่อ" +"Management key" = "คีย์การจัดการ" +"Test" = "ทดสอบ" +"Remove stored key" = "ลบคีย์ที่บันทึกไว้" +"Credential stored in Windows Credential Manager" = "ข้อมูลรับรองถูกจัดเก็บใน Windows Credential Manager" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "ยังไม่ได้ตั้งค่าคีย์ สร้าง Management API key บน OpenRouter แล้ววางที่นี่" +"Get OpenRouter management key" = "รับคีย์การจัดการ OpenRouter" +"Testing..." = "กำลังทดสอบ..." +"Connected" = "เชื่อมต่อแล้ว" +"BALANCE" = "ยอดเงิน" +"USED" = "ใช้แล้ว" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "เพิ่ม Management API key ใน การตั้งค่า → ผู้ให้บริการ → OpenRouter → การเชื่อมต่อ แล้วรีเฟรชมอนิเตอร์นี้" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index cf8706c8..caca868f 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Collect usage from GitHub" = "GitHub kullanımını topla" "Collect usage from Google" = "Google kullanımını topla" "Collect usage from OpenAI" = "OpenAI kullanımını topla" +"Collect usage from OpenRouter" = "OpenRouter kullanımını topla" +"Show cap in the monitor" = "Üst limiti izleyicide göster" +"Monthly credits deposit" = "Aylık kredi yüklemesi" +"Show credits deposit in the monitor" = "İzleyicide kredi yüklemesini göster" +"0 uses the accumulated account balance" = "0 birikmiş hesap bakiyesini kullanır" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter günlük harcamayı UTC ile hesaplar: gün 00:00 UTC'de (saat diliminizde {time}) değişir, bu yüzden günlük değer o saatte sıfırlanır." +"Budget" = "Bütçe" +"Cap amount" = "Limit tutarı" +"Cap period" = "Limit dönemi" +"Monitor period" = "İzleme dönemi" +"0 turns the spending cap off" = "0 harcama üst limitini kapatır" +"Daily" = "Günlük" +"Weekly" = "Haftalık" +"Monthly" = "Aylık" "Collect usage from xAI" = "xAI kullanımını topla" "Colour" = "Renk" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Hesaplar" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Temalar bir hesap belirtmediği sürece varsayılan hesabı kullanır. Özel temalar birden fazla hesap gösterebilir." +"Default account" = "Varsayılan hesap" +"Account name" = "Hesap adı" +"Remove" = "Kaldır" +"Config directory" = "Yapılandırma dizini" +"Custom credentials file" = "Özel kimlik bilgileri dosyası" +"Optional; overrides the config directory" = "İsteğe bağlı; yapılandırma dizinini geçersiz kılar" +"Browse..." = "Göz at..." +"Select credentials file" = "Kimlik bilgileri dosyasını seç" +"Theme binding" = "Tema bağlantısı" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Ayarlandığında CLAUDE_CONFIG_DIR kullanılır; aksi halde CLI, Masaüstü veya WSL kimlik bilgileri algılanır." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Ayarlandığında CODEX_HOME kullanılır; aksi halde ~/.codex/auth.json." +"Monitoring disabled" = "İzleme devre dışı" +"Last known usage" = "Bilinen son kullanım" +"Session" = "Oturum" +"Credits" = "Krediler" +"Resets in" = "Sıfırlanma" +"Waiting for usage" = "Kullanım verileri bekleniyor" +"Add account" = "Hesap ekle" +"Connection" = "Bağlantı" +"Management key" = "Yönetim anahtarı" +"Test" = "Test et" +"Remove stored key" = "Kayıtlı anahtarı kaldır" +"Credential stored in Windows Credential Manager" = "Kimlik bilgisi Windows Kimlik Bilgisi Yöneticisi'nde depolandı" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Anahtar yapılandırılmamış. OpenRouter'da bir Management API key oluşturun ve buraya yapıştırın." +"Get OpenRouter management key" = "OpenRouter yönetim anahtarını al" +"Testing..." = "Test ediliyor..." +"Connected" = "Bağlandı" +"BALANCE" = "BAKİYE" +"USED" = "KULLANILAN" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Ayarlar → Sağlayıcılar → OpenRouter → Bağlantı bölümünde bir Management API key ekleyin ve bu monitörü yenileyin." diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 82c94804..31e259aa 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量监控" "Collect usage from GitHub" = "从 GitHub 获取使用量" "Collect usage from Google" = "从 Google 获取使用量" "Collect usage from OpenAI" = "从 OpenAI 获取使用量" +"Collect usage from OpenRouter" = "从 OpenRouter 获取使用量" +"Show cap in the monitor" = "在监视器中显示上限" +"Monthly credits deposit" = "每月充值额度" +"Show credits deposit in the monitor" = "在监视器中显示充值额度" +"0 uses the accumulated account balance" = "0 表示使用账户累计余额" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter 以 UTC 计算每日用量:每天于 00:00 UTC(您的时区为 {time})换日,因此每日数值会在该时重置。" +"Budget" = "预算" +"Cap amount" = "上限金额" +"Cap period" = "上限周期" +"Monitor period" = "监控周期" +"0 turns the spending cap off" = "0 表示关闭支出上限" +"Daily" = "每日" +"Weekly" = "每周" +"Monthly" = "每月" "Collect usage from xAI" = "从 xAI 获取使用量" "Colour" = "颜色" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量监控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "账户" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "除非指定账户,否则主题使用默认账户。自定义主题可显示多个账户。" +"Default account" = "默认账户" +"Account name" = "账户名称" +"Remove" = "移除" +"Config directory" = "配置目录" +"Custom credentials file" = "自定义凭据文件" +"Optional; overrides the config directory" = "可选;覆盖配置目录" +"Browse..." = "浏览..." +"Select credentials file" = "选择凭据文件" +"Theme binding" = "主题绑定" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "若已设置则使用 CLAUDE_CONFIG_DIR;否则自动检测 CLI、桌面版或 WSL 凭据。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "若已设置则使用 CODEX_HOME;否则使用 ~/.codex/auth.json。" +"Monitoring disabled" = "监控已禁用" +"Last known usage" = "上次已知用量" +"Session" = "会话" +"Credits" = "额度" +"Resets in" = "重置于" +"Waiting for usage" = "正在等待用量数据" +"Add account" = "添加账户" +"Connection" = "连接" +"Management key" = "管理密钥" +"Test" = "测试" +"Remove stored key" = "删除已存储的密钥" +"Credential stored in Windows Credential Manager" = "凭据已存储在 Windows 凭据管理器中" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "未配置密钥。请在 OpenRouter 上创建 Management API key 并粘贴到此处。" +"Get OpenRouter management key" = "获取 OpenRouter 管理密钥" +"Testing..." = "测试中..." +"Connected" = "已连接" +"BALANCE" = "余额" +"USED" = "已使用" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "请在 设置 → 提供商 → OpenRouter → 连接 中添加 Management API key,然后刷新此监视器。" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index b0644822..20a1014c 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量監控" "Collect usage from GitHub" = "從 GitHub 取得使用量" "Collect usage from Google" = "從 Google 取得使用量" "Collect usage from OpenAI" = "從 OpenAI 取得使用量" +"Collect usage from OpenRouter" = "從 OpenRouter 取得使用量" +"Show cap in the monitor" = "在監視器中顯示上限" +"Monthly credits deposit" = "每月儲值額度" +"Show credits deposit in the monitor" = "在監視器中顯示儲值額度" +"0 uses the accumulated account balance" = "0 表示使用帳戶累計餘額" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter 以 UTC 計算每日用量:每天於 00:00 UTC(您的時區為 {time})換日,因此每日數值會在該時重置。" +"Budget" = "預算" +"Cap amount" = "上限金額" +"Cap period" = "上限週期" +"Monitor period" = "監控週期" +"0 turns the spending cap off" = "0 表示關閉支出上限" +"Daily" = "每日" +"Weekly" = "每週" +"Monthly" = "每月" "Collect usage from xAI" = "從 xAI 取得使用量" "Colour" = "色彩" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量監控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "帳戶" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "除非指定帳戶,否則主題使用預設帳戶。自訂主題可顯示多個帳戶。" +"Default account" = "預設帳戶" +"Account name" = "帳戶名稱" +"Remove" = "移除" +"Config directory" = "設定目錄" +"Custom credentials file" = "自訂認證檔案" +"Optional; overrides the config directory" = "選填;覆寫設定目錄" +"Browse..." = "瀏覽..." +"Select credentials file" = "選擇認證檔案" +"Theme binding" = "主題綁定" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "若已設定則使用 CLAUDE_CONFIG_DIR;否則自動偵測 CLI、桌面版或 WSL 認證。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "若已設定則使用 CODEX_HOME;否則使用 ~/.codex/auth.json。" +"Monitoring disabled" = "監控已停用" +"Last known usage" = "上次已知用量" +"Session" = "工作階段" +"Credits" = "額度" +"Resets in" = "重設於" +"Waiting for usage" = "正在等待用量資料" +"Add account" = "新增帳戶" +"Connection" = "連線" +"Management key" = "管理金鑰" +"Test" = "測試" +"Remove stored key" = "刪除已儲存的金鑰" +"Credential stored in Windows Credential Manager" = "憑證已儲存在 Windows 認證管理員中" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "未設定金鑰。請在 OpenRouter 上建立 Management API key 並貼到此處。" +"Get OpenRouter management key" = "取得 OpenRouter 管理金鑰" +"Testing..." = "測試中..." +"Connected" = "已連線" +"BALANCE" = "餘額" +"USED" = "已使用" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "請在 設定 → 提供者 → OpenRouter → 連線 中新增 Management API key,然後重新整理此監視器。" From d4061a30992b5a5f894794aab76442b93bdb7be7 Mon Sep 17 00:00:00 2001 From: Caique Reis Date: Fri, 23 Oct 2026 17:14:51 -0300 Subject: [PATCH 4/4] feat(themes): render OpenRouter in the bundled themes The compact, usage-widget and vertical themes draw the OpenRouter rows in their own visual style and keep OpenRouter last in the provider row. --- src/themes/classic-usage-widget.json | 426 ++++++++++++++++++++++++++- src/themes/classic-vertical.json | 310 ++++++++++++++++++- src/themes/compact-fluent-quad.json | 426 ++++++++++++++++++++++++++- 3 files changed, 1159 insertions(+), 3 deletions(-) diff --git a/src/themes/classic-usage-widget.json b/src/themes/classic-usage-widget.json index 3cde50a2..213d50f4 100644 --- a/src/themes/classic-usage-widget.json +++ b/src/themes/classic-usage-widget.json @@ -19,7 +19,7 @@ "offset_x": 0, "offset_y": 0 }, - "width": "42 + providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 3 + (providers.opencode.enabled + providers.cursor.enabled) * 28 + providers.claude.enabled * claude.model.fable.available * 93", + "width": "42 + providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 3 + (providers.opencode.enabled + providers.cursor.enabled) * 28 + providers.claude.enabled * claude.model.fable.available * 93 + providers.openrouter.enabled * (48 - (1 - openrouter.long.show_cap) * 36)", "height": "46", "mouse_events": { "right_click": "show_context_menu(\"classic-v1\")" @@ -1807,6 +1807,430 @@ "color": "#5B4B8AFF" } } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter provider", + "render": "providers.openrouter.enabled", + "visibility": "100", + "parent": "provider-row", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + (110 - (1 - openrouter.long.show_cap) * 36)", + "height": "46", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "openrouter-account-segments-dark", + "name": "dark OpenRouter balance segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "5", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-dark", + "name": "dark OpenRouter balance with deposit", + "render": "(system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-dark", + "name": "dark OpenRouter balance only", + "render": "(system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-segments-light", + "name": "light OpenRouter balance segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "5", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-light", + "name": "light OpenRouter balance with deposit", + "render": "(1 - system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-light", + "name": "light OpenRouter balance only", + "render": "(1 - system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-dark", + "name": "dark OpenRouter window segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "28", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "dark OpenRouter window value with cap", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "dark OpenRouter window value", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-light", + "name": "light OpenRouter window segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "28", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-light", + "name": "light OpenRouter window value with cap", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "light OpenRouter window value", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } } ] }, diff --git a/src/themes/classic-vertical.json b/src/themes/classic-vertical.json index 58a1f80b..8a30a70d 100644 --- a/src/themes/classic-vertical.json +++ b/src/themes/classic-vertical.json @@ -19,7 +19,7 @@ "offset_x": 0, "offset_y": -52 }, - "width": "min(62, max(1, host.width - 2))", + "width": "min(92, max(1, host.width - 2))", "height": "max(1, providers.count * 64 - (providers.grok.enabled + providers.copilot.enabled) * 22)", "mouse_events": { "right_click": "show_context_menu(\"classic-v1\")" @@ -1225,6 +1225,314 @@ }, "corner_radius": "1" } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter", + "parent": "provider-column", + "render": "providers.openrouter.enabled", + "width": "canvas.width", + "height": "64" + }, + { + "id": "openrouter-title", + "name": "openrouter-title", + "parent": "openrouter-provider", + "render": "system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "OpenRouter", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-title-light", + "name": "openrouter-title-light", + "parent": "openrouter-provider", + "render": "1 - system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "OpenRouter", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-account-value-dark", + "name": "OpenRouter balance (dark)", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "18", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}·${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-account-value-light", + "name": "OpenRouter balance (light)", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "18", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}·${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-account-bar", + "name": "OpenRouter balance bar", + "render": "1", + "visibility": "100", + "parent": "openrouter-provider", + "y": "33", + "x": "4", + "width": "max(1, canvas.width - 8)", + "height": "3", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84A" + }, + "track": { + "color": "#80808040" + }, + "corner_radius": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "OpenRouter window with cap (dark)", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}·${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "OpenRouter window (dark)", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-long-cap-light", + "name": "OpenRouter window with cap (light)", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}·${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "OpenRouter window (light)", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-long-bar", + "name": "OpenRouter window bar", + "render": "1", + "visibility": "100", + "parent": "openrouter-provider", + "y": "55", + "x": "4", + "width": "max(1, canvas.width - 8)", + "height": "3", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84A" + }, + "track": { + "color": "#80808040" + }, + "corner_radius": "1" + } } ] }, diff --git a/src/themes/compact-fluent-quad.json b/src/themes/compact-fluent-quad.json index 55250224..ff116940 100644 --- a/src/themes/compact-fluent-quad.json +++ b/src/themes/compact-fluent-quad.json @@ -21,7 +21,7 @@ "offset_x": 6, "offset_y": 0 }, - "width": "providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 6 + providers.opencode.enabled * 28 + providers.cursor.enabled * 18 + providers.claude.enabled * claude.model.fable.available * 96", + "width": "providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 6 + providers.opencode.enabled * 28 + providers.cursor.enabled * 18 + providers.claude.enabled * claude.model.fable.available * 96 + providers.openrouter.enabled * (48 - (1 - openrouter.long.show_cap) * 36)", "height": "46", "background": { "type": "none" @@ -2919,6 +2919,430 @@ "opacity": "1" } } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter provider", + "render": "providers.openrouter.enabled", + "visibility": "100", + "parent": "provider-row", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + (110 - (1 - openrouter.long.show_cap) * 36)", + "height": "46", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "openrouter-account-segments-dark", + "name": "dark OpenRouter balance segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "11", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-dark", + "name": "dark OpenRouter balance with deposit", + "render": "(system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-dark", + "name": "dark OpenRouter balance only", + "render": "(system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-segments-light", + "name": "light OpenRouter balance segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "11", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-light", + "name": "light OpenRouter balance with deposit", + "render": "(1 - system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-light", + "name": "light OpenRouter balance only", + "render": "(1 - system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-dark", + "name": "dark OpenRouter window segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "dark OpenRouter window value with cap", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "dark OpenRouter window value", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-light", + "name": "light OpenRouter window segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-light", + "name": "light OpenRouter window value with cap", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "light OpenRouter window value", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } } ] },