diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8b253f7..68a51383d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.29.2 — Unreleased ### Fixed +- Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029! - Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao! ## 0.29.1 — 2026-05-26 diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index a02cce3e0..e7b36cddc 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -1,6 +1,10 @@ import CodexBarCore import Foundation +enum CodexBarLocalizationOverride { + @TaskLocal static var appLanguage: String? +} + private func appLanguageDefaults() -> UserDefaults { if Bundle.main.bundleIdentifier != nil { return .standard @@ -37,7 +41,7 @@ func codexBarLocalizationResourceBundle( private func localizedBundle() -> Bundle { let resourceBundle = codexBarLocalizationResourceBundle() - let language = appLanguageDefaults().string(forKey: "appLanguage") ?? "" + let language = CodexBarLocalizationOverride.appLanguage ?? appLanguageDefaults().string(forKey: "appLanguage") ?? "" if !language.isEmpty { if let bundle = lprojBundle(named: language, in: resourceBundle) { return bundle diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ced07af15..ac3729881 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -421,6 +421,13 @@ "quota_warning_session_capitalized" = "Session"; "quota_warning_weekly" = "weekly"; "quota_warning_weekly_capitalized" = "Weekly"; +"quota_warning_notification_title" = "%1$@ %2$@ quota low"; +"quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; +"quota_warning_notification_body_with_account" = "Account %1$@. %2$@ left. Reached your %3$d%% %4$@ warning threshold."; +"session_depleted_notification_title" = "%@ session depleted"; +"session_depleted_notification_body" = "0% left. Will notify when it's available again."; +"session_restored_notification_title" = "%@ session restored"; +"session_restored_notification_body" = "Session quota is available again."; "quota_warning_warn_at" = "Warn at"; "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; "quota_warning_sound" = "Play notification sound"; @@ -455,6 +462,8 @@ "managed_login_already_running" = "A managed Codex login is already running. Wait for it to finish before adding or re-authenticating another account."; "managed_login_failed" = "Managed Codex login did not complete. Verify that `codex --version` works in Terminal. If macOS blocked or moved `codex` to Trash, remove stale duplicate installs, run `npm install -g --include=optional @openai/codex@latest`, then try again."; "managed_login_missing_email" = "Codex login completed, but no account email was available. Try again after confirming the account is fully signed in."; +"login_success_notification_title" = "%@ login successful"; +"login_success_notification_body" = "You can return to the app; authentication finished."; "workspace_selection_cancelled" = "CodexBar found multiple workspaces, but no workspace was selected."; "unsafe_managed_home" = "CodexBar refused to modify an unexpected managed home path: %@"; "menu_bar_metric_title" = "Menu bar metric"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 98f0d7f18..9c12ee79e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1,6 +1,6 @@ /* Chinese (Traditional) localization for CodexBar */ -" providers" = " 供應商"; +" providers" = " 提供者"; "(System)" = "(System)"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; @@ -13,87 +13,87 @@ "Account" = "帳號"; "Accounts" = "帳號"; "Accounts subtitle" = "帳號副標題"; -"Active" = "活躍"; +"Active" = "作用中"; "Add" = "新增"; "Add Workspace" = "新增工作區"; "Advanced" = "進階"; "All" = "全部"; -"Always allow prompts" = "始終允許提示"; +"Always allow prompts" = "一律允許提示"; "Animation pattern" = "動畫模式"; -"Antigravity login is managed in the app" = "Antigravity 登入由應用管理"; +"Antigravity login is managed in the app" = "Antigravity 登入由 App 管理"; "Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework OAuth 鑰匙圈讀取器。"; "Auth" = "認證"; "Auto" = "自動"; -"Auto falls back to the next source if the preferred one fails." = "如果首選來源失敗,自動回退到下一個來源。"; -"Auto uses API first, then falls back to CLI on auth failures." = "自動優先使用 API,認證失敗時回退到 CLI。"; -"Auto-detect" = "自動檢測"; +"Auto falls back to the next source if the preferred one fails." = "如果偏好的來源失敗,自動改用下一個來源。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動優先使用 API,認證失敗時改用 CLI。"; +"Auto-detect" = "自動偵測"; "Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; -"Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 超時:10 分鐘"; +"Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 逾時:10 分鐘"; "Automatic" = "自動"; "Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookie 和 WorkOS token。"; -"Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookie 和本地存儲 token。"; +"Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookie 和本機儲存 token。"; "Automatic imports browser cookies for dashboard extras." = "自動匯入用於儀表板附加功能的瀏覽器 Cookie。"; "Automatic imports browser cookies for the web API." = "自動匯入用於 Web API 的瀏覽器 Cookie。"; "Automatic imports browser cookies from Model Studio/Bailian." = "自動從 Model Studio/Bailian 匯入瀏覽器 Cookie。"; "Automatic imports browser cookies from admin.mistral.ai." = "自動從 admin.mistral.ai 匯入瀏覽器 Cookie。"; "Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookie。"; -"Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已存儲的會話。"; +"Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已儲存的工作階段。"; "Automatic imports browser cookies." = "自動匯入瀏覽器 Cookie。"; -"Automatically imports browser session cookie." = "自動匯入瀏覽器會話 Cookie。"; -"Automatically opens CodexBar when you start your Mac." = "啟動 Mac 時自動開啟 CodexBar。"; +"Automatically imports browser session cookie." = "自動匯入瀏覽器工作階段 Cookie。"; +"Automatically opens CodexBar when you start your Mac." = "登入 Mac 時自動開啟 CodexBar。"; "Automation" = "自動化"; "Average (\\(label1) + \\(label2))" = "平均(\\(label1) + \\(label2))"; "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均(\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; "Avoid Keychain prompts" = "避免鑰匙圈提示"; "Balance" = "餘額"; "Battery Saver" = "省電模式"; -"Bordered" = "帶邊框"; +"Bordered" = "有邊框"; "Build" = "建置"; "Built \\(buildTimestamp)" = "建置於 \\(buildTimestamp)"; "Buy Credits..." = "購買額度..."; "Buy Credits…" = "購買額度…"; "CLI paths" = "CLI 路徑"; -"CLI sessions" = "CLI 會話"; +"CLI sessions" = "CLI 工作階段"; "Caches" = "快取"; "Cancel" = "取消"; "Check for Updates…" = "檢查更新…"; "Check for updates automatically" = "自動檢查更新"; -"Check if you like your agents having some fun up there." = "看看你是否喜歡你的 Agent 在上面找點樂子。"; -"Check provider status" = "檢查供應商狀態"; +"Check if you like your agents having some fun up there." = "讓選單列上的 Agent 多一點變化。"; +"Check provider status" = "檢查提供者狀態"; "Choose Codex workspace" = "選擇 Codex 工作區"; "Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; "Choose up to " = "選擇最多 "; -"Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個供應商"; -"Choose up to \\(count) providers" = "選擇最多 \\(count) 個供應商"; -"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會顯示實際用量與預期的對比)。"; -"Choose which Codex account CodexBar should follow." = "選擇 CodexBar 要跟隨的 Codex 帳號。"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個提供者"; +"Choose up to \\(count) providers" = "選擇最多 \\(count) 個提供者"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; +"Choose which Codex account CodexBar should follow." = "選擇 CodexBar 要追蹤的 Codex 帳號。"; "Choose which window drives the menu bar percent." = "選擇用於驅動選單列百分比的時段。"; "Chrome" = "Chrome"; "Claude CLI not found" = "找不到 Claude CLI"; "Claude binary" = "Claude 二進位檔案"; "Claude cookies" = "Claude Cookie"; "Claude login failed" = "Claude 登入失敗"; -"Claude login timed out" = "Claude 登入超時"; +"Claude login timed out" = "Claude 登入逾時"; "Close" = "關閉"; -"Code review" = "代碼審查"; +"Code review" = "程式碼審查"; "Codex CLI not found" = "找不到 Codex CLI"; "Codex account login already running" = "Codex 帳號登入已在執行"; "Codex binary" = "Codex 二進位檔案"; "Codex login failed" = "Codex 登入失敗"; -"Codex login timed out" = "Codex 登入超時"; -"CodexBar Lifecycle Keepalive" = "CodexBar 生命週期保活"; -"CodexBar could not read managed account storage. " = "CodexBar 無法讀取託管帳號存儲。"; +"Codex login timed out" = "Codex 登入逾時"; +"CodexBar Lifecycle Keepalive" = "CodexBar 生命週期維持"; +"CodexBar could not read managed account storage. " = "CodexBar 無法讀取託管帳號儲存。"; "Configure…" = "設定…"; -"Connected" = "已連接"; -"Controls how much detail is logged." = "控制日誌記錄的詳細程度。"; +"Connected" = "已連線"; +"Controls how much detail is logged." = "控制記錄詳細程度。"; "Cookie header" = "Cookie 標頭"; "Cookie source" = "Cookie 來源"; "Cookie: ..." = "Cookie:..."; -"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或貼上來自 Abacus AI 儀表板的 cURL 捕獲內容"; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或貼上來自 Abacus AI 儀表板的 cURL 擷取內容"; "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 __Secure-next-auth.session-token 的值"; "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 kimi-auth token 值"; "Cookie: …" = "Cookie:…"; -"CopilotDeviceFlow" = "Copilot 設備流程"; +"CopilotDeviceFlow" = "Copilot 裝置流程"; "Cost" = "費用"; "Could not add Codex account" = "無法新增 Codex 帳號"; "Could not open Terminal for Gemini" = "無法為 Gemini 開啟終端"; @@ -103,98 +103,98 @@ "Credits" = "額度"; "Credits history" = "額度歷史"; "Cursor login failed" = "Cursor 登入失敗"; -"Custom" = "自定義"; -"Custom Path" = "自定義路徑"; -"Daily Routines" = "日常例程"; +"Custom" = "自訂"; +"Custom Path" = "自訂路徑"; +"Daily Routines" = "每日例行工作"; "Debug" = "除錯"; "Default" = "預設"; "Designs" = "設計"; -"Disable Keychain access" = "禁用鑰匙圈存取"; -"Disabled" = "已禁用"; -"Disabled — no recent data" = "已禁用 — 無近期資料"; -"Disconnected" = "已斷開連接"; +"Disable Keychain access" = "停用鑰匙圈存取"; +"Disabled" = "已停用"; +"Disabled — no recent data" = "已停用 — 無近期資料"; +"Disconnected" = "已中斷連線"; "Display" = "顯示"; "Display mode" = "顯示模式"; -"Display reset times as absolute clock values instead of countdowns." = "將重置時間顯示為絕對時鐘值,而不是倒計時。"; +"Display reset times as absolute clock values instead of countdowns." = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; "Done" = "完成"; "Effective PATH" = "有效 PATH"; "Email" = "電子郵件"; -"Enable Merge Icons to configure Overview tab providers." = "啟用「合併圖示」以設定「概覽」標籤中的供應商。"; -"Enable file logging" = "啟用檔案日誌"; +"Enable Merge Icons to configure Overview tab providers." = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; +"Enable file logging" = "啟用檔案記錄"; "Enabled" = "已啟用"; "Error" = "錯誤"; "Error simulation" = "錯誤模擬"; -"Expose troubleshooting tools in the Debug tab." = "在「除錯」標籤中顯示故障排除工具。"; +"Expose troubleshooting tools in the Debug tab." = "在「除錯」標籤中顯示疑難排解工具。"; "Failed" = "失敗"; "False" = "假"; -"Fetch strategy attempts" = "獲取策略嘗試"; -"Fetching" = "獲取中"; +"Fetch strategy attempts" = "取得策略嘗試"; +"Fetching" = "取得中"; "Field" = "欄位"; "Field subtitle" = "欄位副標題"; "Finish the current managed account change before switching the system account." = "請先完成目前託管帳號變更,再切換系統帳號。"; "Force animation on next refresh" = "下次重新整理時強制動畫"; -"Gateway region" = "網關區域"; +"Gateway region" = "閘道區域"; "Gemini CLI not found" = "找不到 Gemini CLI"; -"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,並在圖示和選單中顯示故障事件。"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,並在圖示和選單中顯示服務異常。"; "General" = "一般"; "GitHub" = "GitHub"; "GitHub Copilot Login" = "GitHub Copilot 登入"; "GitHub Login" = "GitHub 登入"; -"Hide details" = "隱藏詳情"; +"Hide details" = "隱藏詳細資訊"; "Hide personal information" = "隱藏個人資訊"; "Historical tracking" = "歷史追蹤"; -"How often CodexBar polls providers in the background." = "CodexBar 在背景輪詢供應商的頻率。"; -"Inactive" = "非活躍"; +"How often CodexBar polls providers in the background." = "CodexBar 在背景輪詢提供者的頻率。"; +"Inactive" = "非作用中"; "Install CLI" = "安裝 CLI"; "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後重試。"; "Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後重試。"; "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後重試。"; "JetBrains AI is ready" = "JetBrains AI 已就緒"; "JetBrains IDE" = "JetBrains IDE"; -"Keep CLI sessions alive" = "保持 CLI 會話存活"; -"Keyboard shortcut" = "快捷鍵"; +"Keep CLI sessions alive" = "保持 CLI 工作階段存活"; +"Keyboard shortcut" = "快速鍵"; "Keychain access" = "鑰匙圈存取"; "Keychain prompt policy" = "鑰匙圈提示策略"; -"Last \\(name) fetch failed:" = "上次獲取 \\(name) 失敗:"; -"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次獲取 \\(self.store.metadata(for: self.provider).displayName) 失敗:"; +"Last \\(name) fetch failed:" = "上次取得 \\(name) 失敗:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次取得 \\(self.store.metadata(for: self.provider).displayName) 失敗:"; "Last attempt" = "上次嘗試"; -"Limits not available" = "限制不可用"; +"Limits not available" = "無法取得限制資料"; "Link" = "連結"; "Loading animations" = "載入動畫"; "Loading…" = "載入中…"; -"Local" = "本地"; -"Logging" = "日誌"; +"Local" = "本機"; +"Logging" = "記錄"; "Login failed" = "登入失敗"; -"Login shell PATH (startup capture)" = "登入 shell PATH(啟動時捕獲)"; -"Login timed out" = "登入超時"; -"MCP details" = "MCP 詳情"; -"Managed Codex accounts unavailable" = "託管 Codex 帳號不可用"; -"Managed account storage is unreadable. Live account access is still available, " = "託管帳號存儲不可讀。實時帳號存取仍可用,"; +"Login shell PATH (startup capture)" = "登入 shell PATH(啟動時擷取)"; +"Login timed out" = "登入逾時"; +"MCP details" = "MCP 詳細資訊"; +"Managed Codex accounts unavailable" = "無法使用託管 Codex 帳號"; +"Managed account storage is unreadable. Live account access is still available, " = "託管帳號儲存區無法讀取。即時帳號仍可存取,"; "Manual" = "手動"; -"May your tokens never run out—keep agent limits in view." = "願你的 token 永不耗盡,隨時關注 Agent 限制。"; +"May your tokens never run out—keep agent limits in view." = "願你的 token 永不用完,隨時掌握 Agent 限制。"; "Menu bar" = "選單列"; -"Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的供應商。"; +"Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的提供者。"; "Menu bar metric" = "選單列指標"; "Menu bar shows percent" = "選單列顯示百分比"; "Menu content" = "選單內容"; "Merge Icons" = "合併圖示"; -"Never prompt" = "從不提示"; +"Never prompt" = "永不提示"; "No" = "否"; -"No Codex accounts detected yet." = "未檢測到 Codex 帳號。"; -"No JetBrains IDE detected" = "未檢測到 JetBrains IDE"; +"No Codex accounts detected yet." = "未偵測到 Codex 帳號。"; +"No JetBrains IDE detected" = "未偵測到 JetBrains IDE"; "No cost history data." = "尚無費用歷史資料。"; -"No usage yet" = "尚無用量"; -"Not fetched yet" = "尚未獲取"; +"No usage yet" = "尚無使用量"; +"Not fetched yet" = "尚未取得"; "No credits history data." = "尚無額度歷史資料。"; -"No data available" = "無可用資料"; +"No data available" = "沒有可用資料"; "No data yet" = "尚無資料"; -"No enabled providers available for Overview." = "「概覽」中沒有可用的已啟用供應商。"; -"No providers selected" = "未選擇供應商"; +"No enabled providers available for Overview." = "「概覽」中沒有可用的已啟用提供者。"; +"No providers selected" = "未選擇提供者"; "No token accounts yet." = "尚無 token 帳號。"; -"No usage breakdown data." = "尚無用量明細資料。"; +"No usage breakdown data." = "尚無使用量明細資料。"; "None" = "無"; "Notifications" = "通知"; -"Notifies when the 5-hour session quota hits 0% and when it becomes " = "當 5 小時會話配額降至 0% 以及重新"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "當 5 小時工作階段配額降至 0% 或"; "OK" = "好"; "Obscure email addresses in the menu bar and menu UI." = "在選單列和選單介面中隱藏電子郵件地址。"; "Off" = "關閉"; @@ -208,12 +208,12 @@ "Open Antigravity to sign in, then refresh CodexBar." = "開啟 Antigravity 登入,然後重新整理 CodexBar。"; "Open Browser" = "開啟瀏覽器"; "Open Coding Plan" = "開啟 Coding Plan"; -"Open Console" = "開啟控制台"; +"Open Console" = "開啟主控台"; "Open Dashboard" = "開啟儀表板"; -"Open Mistral Admin" = "開啟 Mistral 管理背景"; +"Open Mistral Admin" = "開啟 Mistral 管理頁面"; "Open Ollama Settings" = "開啟 Ollama 設定"; "Open Terminal" = "開啟終端"; -"Open Usage Page" = "開啟用量頁面"; +"Open Usage Page" = "開啟使用量頁面"; "Open Warp API Key Guide" = "開啟 Warp API 金鑰指南"; "Open menu" = "開啟選單"; "Open token file" = "開啟 token 檔案"; @@ -221,12 +221,12 @@ "OpenAI web extras" = "OpenAI Web 附加功能"; "Option A" = "選項 A"; "Option B" = "選項 B"; -"Optional override if workspace lookup fails." = "工作區尋找失敗時可選的覆蓋項。"; +"Optional override if workspace lookup fails." = "找不到工作區時可選的覆寫值。"; "Options" = "選項"; -"Override auto-detection with a custom IDE base path" = "使用自定義 IDE 基礎路徑覆蓋自動檢測"; +"Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基礎路徑覆蓋自動偵測"; "Overview" = "概覽"; -"Overview rows always follow provider order." = "概覽行始終遵循供應商順序。"; -"Overview tab providers" = "概覽標籤供應商"; +"Overview rows always follow provider order." = "概覽列一律依提供者順序排列。"; +"Overview tab providers" = "概覽標籤提供者"; "Paste API key…" = "貼上 API 金鑰…"; "Paste API token…" = "貼上 API token…"; "Paste key…" = "貼上金鑰…"; @@ -236,79 +236,79 @@ "Personal" = "個人"; "Picker" = "選擇器"; "Picker subtitle" = "選擇器副標題"; -"Placeholder" = "占位符"; +"Placeholder" = "預留位置"; "Plan" = "方案"; -"Play full-screen confetti when weekly usage resets." = "當每週用量重置時播放全螢幕彩紙。"; +"Play full-screen confetti when weekly usage resets." = "每週使用量重置時播放全螢幕慶祝動畫。"; "Polls OpenAI/Claude status pages and Google Workspace for " = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace,以檢查"; "Prevents any Keychain access while enabled." = "啟用時封鎖任何鑰匙圈存取。"; "Primary (API key limit)" = "主要(API 金鑰限制)"; "Primary (\\(label))" = "主要(\\(label))"; "Primary (\\(metadata.sessionLabel))" = "主要(\\(metadata.sessionLabel))"; -"Probe logs" = "探測日誌"; -"Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨配額消耗而填充(而不是顯示剩餘量)。"; -"Provider" = "供應商"; -"Providers" = "供應商"; +"Probe logs" = "探測記錄"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; +"Provider" = "提供者"; +"Providers" = "提供者"; "Quit CodexBar" = "結束 CodexBar"; "Random (default)" = "隨機(預設)"; -"Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本地用量日誌。在選單中顯示今天及所選歷史時段的費用。"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; "Refresh" = "重新整理"; "Refreshing" = "正在重新整理"; "Refresh cadence" = "重新整理頻率"; -"Remote" = "遠程"; +"Remote" = "遠端"; "Remove" = "移除"; "Remove Codex account?" = "移除 Codex 帳號?"; "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(account.email) 嗎?其託管的 Codex 主目錄將被刪除。"; "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(email) 嗎?其託管的 Codex 主目錄將被刪除。"; "Remove selected account" = "移除所選帳號"; -"Replace critter bars with provider branding icons and a percentage." = "將小動物進度條替換為供應商品牌圖示和百分比。"; -"Replay selected animation" = "重放選中的動畫"; -"Requires authentication via GitHub Device Flow." = "需要通過 GitHub 設備流程進行認證。"; +"Replace critter bars with provider branding icons and a percentage." = "將小動物進度條替換為提供者品牌圖示和百分比。"; +"Replay selected animation" = "重播選取的動畫"; +"Requires authentication via GitHub Device Flow." = "需要透過 GitHub 裝置流程進行認證。"; "Resets: \\(reset)" = "重置:\\(reset)"; -"Rolling five-hour limit" = "滾動 5 小時限制"; -"Search hourly" = "每小時搜索"; +"Rolling five-hour limit" = "滾動式 5 小時限制"; +"Search hourly" = "每小時搜尋"; "Secondary (\\(label))" = "次要(\\(label))"; "Secondary (\\(metadata.weeklyLabel))" = "次要(\\(metadata.weeklyLabel))"; -"Select a provider" = "選擇一個供應商"; +"Select a provider" = "選擇提供者"; "Select the IDE to monitor" = "選擇要監控的 IDE"; -"Session" = "會話"; -"Session quota notifications" = "會話配額通知"; -"Session tokens" = "會話 token"; +"Session" = "工作階段"; +"Session quota notifications" = "工作階段配額通知"; +"Session tokens" = "工作階段 token"; "Settings" = "設定"; -"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外用量部分。"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; "Show Debug Settings" = "顯示除錯設定"; "Show all token accounts" = "顯示所有 token 帳號"; "Show cost summary" = "顯示費用摘要"; -"Show credits + extra usage" = "顯示額度 + 額外用量"; -"Show details" = "顯示詳情"; -"Show most-used provider" = "顯示用量最高的供應商"; -"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示供應商圖示(否則顯示每週進度線)。"; -"Show reset time as clock" = "將重置時間顯示為時鐘"; -"Show usage as used" = "顯示已使用用量"; -"Sign in via button below" = "通過下方按鈕登入"; +"Show credits + extra usage" = "顯示額度 + 額外使用量"; +"Show details" = "顯示詳細資訊"; +"Show most-used provider" = "顯示使用量最高的提供者"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; +"Show reset time as clock" = "以時鐘時間顯示重置時間"; +"Show usage as used" = "以已用量顯示"; +"Sign in via button below" = "透過下方按鈕登入"; "Skip teardown between probes (debug-only)." = "探測之間跳過清理(僅限除錯)。"; "Source" = "來源"; "Stack token accounts in the menu (otherwise show an account switcher bar)." = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; -"Start at Login" = "開機啟動"; +"Start at Login" = "登入時啟動"; "State" = "狀態"; "Status" = "狀態"; -"Store Claude sessionKey cookies or OAuth access tokens." = "存儲 Claude sessionKey Cookie 或 OAuth 存取 token。"; -"Store multiple Abacus AI Cookie headers." = "存儲多個 Abacus AI Cookie 標頭。"; -"Store multiple Augment Cookie headers." = "存儲多個 Augment Cookie 標頭。"; -"Store multiple Cursor Cookie headers." = "存儲多個 Cursor Cookie 標頭。"; -"Store multiple Factory Cookie headers." = "存儲多個 Factory Cookie 標頭。"; -"Store multiple MiniMax Cookie headers." = "存儲多個 MiniMax Cookie 標頭。"; -"Store multiple Mistral Cookie headers." = "存儲多個 Mistral Cookie 標頭。"; -"Store multiple Ollama Cookie headers." = "存儲多個 Ollama Cookie 標頭。"; -"Store multiple OpenCode Cookie headers." = "存儲多個 OpenCode Cookie 標頭。"; -"Store multiple OpenCode Go Cookie headers." = "存儲多個 OpenCode Go Cookie 標頭。"; -"Stored in the CodexBar config file." = "存儲在 CodexBar 設定檔中。"; -"Stored in ~/.codexbar/config.json. " = "存儲在 ~/.codexbar/config.json 中。"; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "存儲在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 生成。"; -"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "存儲在 ~/.codexbar/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; -"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "存儲在 ~/.codexbar/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; -"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存儲在 ~/.codexbar/config.json 中。請貼上你的 MiniMax API 金鑰。"; -"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "存儲在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; -"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "存儲本地 Codex 用量歷史(8 週),用於個性化進度預測。"; +"Store Claude sessionKey cookies or OAuth access tokens." = "儲存 Claude sessionKey Cookie 或 OAuth 存取 token。"; +"Store multiple Abacus AI Cookie headers." = "儲存多個 Abacus AI Cookie 標頭。"; +"Store multiple Augment Cookie headers." = "儲存多個 Augment Cookie 標頭。"; +"Store multiple Cursor Cookie headers." = "儲存多個 Cursor Cookie 標頭。"; +"Store multiple Factory Cookie headers." = "儲存多個 Factory Cookie 標頭。"; +"Store multiple MiniMax Cookie headers." = "儲存多個 MiniMax Cookie 標頭。"; +"Store multiple Mistral Cookie headers." = "儲存多個 Mistral Cookie 標頭。"; +"Store multiple Ollama Cookie headers." = "儲存多個 Ollama Cookie 標頭。"; +"Store multiple OpenCode Cookie headers." = "儲存多個 OpenCode Cookie 標頭。"; +"Store multiple OpenCode Go Cookie headers." = "儲存多個 OpenCode Go Cookie 標頭。"; +"Stored in the CodexBar config file." = "儲存在 CodexBar 設定檔中。"; +"Stored in ~/.codexbar/config.json. " = "儲存在 ~/.codexbar/config.json 中。"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 產生。"; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存在 ~/.codexbar/config.json 中。請貼上你的 MiniMax API 金鑰。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "儲存在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 使用量歷史(8 週),用於個人化進度預測。"; "Subscription Utilization" = "訂閱使用率"; "Surprise me" = "給我驚喜"; "Switcher shows icons" = "切換器顯示圖示"; @@ -321,21 +321,21 @@ "Toggle" = "切換"; "Toggle subtitle" = "切換副標題"; "Token" = "token"; -"Trigger the menu bar menu from anywhere." = "從任意位置觸發選單列選單。"; +"Trigger the menu bar menu from anywhere." = "可從任何位置開啟選單列選單。"; "True" = "真"; "Twitter" = "Twitter"; -"Unsupported" = "不支持"; -"Unavailable" = "不可用"; +"Unsupported" = "不支援"; +"Unavailable" = "無法使用"; "Update Channel" = "更新頻道"; "Updated" = "已更新"; -"Updates unavailable in this build." = "此建置中更新不可用。"; -"Usage" = "用量"; -"Usage breakdown" = "用量明細"; -"Usage history (30 days)" = "用量歷史"; -"Usage source" = "用量來源"; +"Updates unavailable in this build." = "此建置無法使用更新功能。"; +"Usage" = "使用量"; +"Usage breakdown" = "使用量明細"; +"Usage history (30 days)" = "使用量歷史"; +"Usage source" = "使用量來源"; "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中國大陸端點使用 BigModel(open.bigmodel.cn)。"; -"Use a single menu bar icon with a provider switcher." = "使用單個選單列圖示並帶供應商切換器。"; -"Use international or China mainland console gateways for quota fetches." = "使用國際或中國大陸控制台網關獲取配額。"; +"Use a single menu bar icon with a provider switcher." = "使用單一選單列圖示並帶提供者切換器。"; +"Use international or China mainland console gateways for quota fetches." = "使用國際或中國大陸主控台閘道取得配額資料。"; "Version" = "版本"; "Version \\(self.versionString)" = "版本 \\(self.versionString)"; "Version \\(version)" = "版本 \\(version)"; @@ -345,41 +345,41 @@ "Waiting for Authentication..." = "等待認證…"; "Website" = "網站"; "Weekly" = "每週"; -"Weekly limit confetti" = "每週限制彩紙"; +"Weekly limit confetti" = "每週重置慶祝動畫"; "Weekly token limit" = "每週 token 限制"; -"Weekly usage" = "每週用量"; -"Weekly usage unavailable for this account." = "此帳號的每週用量不可用。"; +"Weekly usage" = "每週使用量"; +"Weekly usage unavailable for this account." = "此帳號無法取得每週使用量。"; "Window: \\(window)" = "時段:\\(window)"; -"Write logs to \\(self.fileLogPath) for debugging." = "將日誌寫入 \\(self.fileLogPath) 以進行除錯。"; +"Write logs to \\(self.fileLogPath) for debugging." = "將記錄寫入 \\(self.fileLogPath) 以進行除錯。"; "Yes" = "是"; -"not detected" = "未檢測到"; +"not detected" = "未偵測到"; "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode):\\(usage)"; "\\(name): \\(truncated)" = "\\(name):\\(truncated)"; "\\(name): \\(updated) · 30d \\(cost)" = "\\(name):\\(updated) · 30 天 \\(cost)"; -"\\(name): fetching…\\(elapsed)" = "\\(name):獲取中…\\(elapsed)"; +"\\(name): fetching…\\(elapsed)" = "\\(name):取得中…\\(elapsed)"; "\\(name): last attempt \\(when)" = "\\(name):上次嘗試 \\(when)"; "\\(name): no data yet" = "\\(name):尚無資料"; -"\\(name): unsupported" = "\\(name):不支持"; +"\\(name): unsupported" = "\\(name):不支援"; "all browsers" = "所有瀏覽器"; -"available again." = "可用時發送通知。"; +"available again." = "恢復可用時傳送通知。"; "built_format" = "建置於 %@"; "copilot_complete_in_browser" = "請在瀏覽器中完成登入。"; -"copilot_device_code_copied" = "設備代碼已複製。"; +"copilot_device_code_copied" = "裝置代碼已複製。"; "copilot_verify_at" = "請在 %@ 驗證"; "copilot_window_closes_auto" = "登入完成後,此視窗會自動關閉。"; "cost_status_error" = "%1$@:%2$@"; -"cost_status_fetching" = "%1$@:獲取中… %2$@"; +"cost_status_fetching" = "%1$@:取得中… %2$@"; "cost_status_last_attempt" = "%1$@:上次嘗試 %2$@"; "cost_status_no_data" = "%@:尚無資料"; "cost_status_snapshot" = "%1$@:%2$@ · %3$@ %4$@"; -"cost_status_unsupported" = "%@:不支持"; +"cost_status_unsupported" = "%@:不支援"; "credits_remaining" = "額度:%@"; -"cursor_on_demand" = "按需計費:%@"; -"cursor_on_demand_with_limit" = "按需計費:%1$@ / %2$@"; -"extra_usage_format" = "額外用量:%1$@ / %2$@"; -"jetbrains_detected_generate" = "檢測到:%@。使用一次 AI 助手以生成配額資料,然後重新整理 CodexBar。"; -"jetbrains_detected_select" = "檢測到:%@。在設定中選擇你偏好的 IDE,然後重新整理 CodexBar。"; -"last_fetch_failed_with_provider" = "上次獲取 %@ 失敗:"; +"cursor_on_demand" = "隨用隨付:%@"; +"cursor_on_demand_with_limit" = "隨用隨付:%1$@ / %2$@"; +"extra_usage_format" = "額外使用量:%1$@ / %2$@"; +"jetbrains_detected_generate" = "偵測到:%@。使用一次 AI 助手以產生配額資料,然後重新整理 CodexBar。"; +"jetbrains_detected_select" = "偵測到:%@。在設定中選擇你偏好的 IDE,然後重新整理 CodexBar。"; +"last_fetch_failed_with_provider" = "上次取得 %@ 失敗:"; "last_spend" = "上次支出:%@"; "mcp_model_usage" = "%1$@:%2$@"; "mcp_resets" = "重置:%@"; @@ -390,73 +390,82 @@ "metric_tertiary" = "第三(%@)"; "multiple_workspaces_found" = "CodexBar 發現 %@ 有多個工作區。請選擇要新增的工作區。"; "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; -"overview_choose_providers" = "最多選擇 %@ 個供應商"; +"overview_choose_providers" = "最多選擇 %@ 個提供者"; "remove_account_message" = "要從 CodexBar 中移除 %@ 嗎?其託管的 Codex 主目錄將被刪除。"; "version_format" = "版本 %@"; -"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已設定 workspaceID,但只有 opencode、opencodego 和 deepgram 支持 workspaceID。"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已設定 workspaceID,但只有 opencode、opencodego 和 deepgram 支援 workspaceID。"; "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 許可證。"; "section_system" = "系統"; -"section_usage" = "用量"; +"section_usage" = "使用量"; "section_automation" = "自動化"; "language_title" = "語言"; -"language_subtitle" = "更改顯示語言。需要重啟應用才能完全生效。"; -"language_system" = "跟隨系統"; +"language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; +"language_system" = "依照系統"; "language_english" = "English"; "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; -"start_at_login_title" = "開機啟動"; -"start_at_login_subtitle" = "啟動 Mac 時自動開啟 CodexBar。"; +"start_at_login_title" = "登入時啟動"; +"start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; "show_cost_summary" = "顯示費用摘要"; -"show_cost_summary_subtitle" = "讀取本地用量日誌。在選單中顯示今天及所選歷史時段的費用。"; +"show_cost_summary_subtitle" = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; "cost_history_days_title" = "歷史時段:%d 天"; -"cost_auto_refresh_info" = "自動重新整理:每小時 · 超時:10 分鐘"; +"cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; "refresh_cadence_title" = "重新整理頻率"; -"refresh_cadence_subtitle" = "CodexBar 在背景輪詢供應商的頻率。"; +"refresh_cadence_subtitle" = "CodexBar 在背景輪詢提供者的頻率。"; "manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; -"check_provider_status_title" = "檢查供應商狀態"; -"check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,在圖示和選單中顯示故障資訊。"; -"session_quota_notifications_title" = "會話配額通知"; -"session_quota_notifications_subtitle" = "當 5 小時會話配額用完及恢復時發送通知。"; -"quota_warning_notifications_title" = "配額預警通知"; -"quota_warning_notifications_subtitle" = "當會話或每週剩餘配額低於設定閾值時提醒。"; -"quota_warnings_title" = "配額預警"; -"quota_warning_session" = "會話"; -"quota_warning_session_capitalized" = "會話"; +"check_provider_status_title" = "檢查提供者狀態"; +"check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,並在圖示和選單中顯示服務異常資訊。"; +"session_quota_notifications_title" = "工作階段配額通知"; +"session_quota_notifications_subtitle" = "當 5 小時工作階段配額用完或恢復可用時傳送通知。"; +"quota_warning_notifications_title" = "配額提醒通知"; +"quota_warning_notifications_subtitle" = "當工作階段或每週剩餘配額達到設定門檻時提醒。"; +"quota_warnings_title" = "配額提醒"; +"quota_warning_session" = "工作階段"; +"quota_warning_session_capitalized" = "工作階段"; "quota_warning_weekly" = "每週"; "quota_warning_weekly_capitalized" = "每週"; -"quota_warning_warn_at" = "預警閾值"; -"quota_warning_global_threshold_subtitle" = "會話和每週時段的剩餘百分比,除非供應商單獨覆蓋。"; +"quota_warning_notification_title" = "%1$@ %2$@配額偏低"; +"quota_warning_notification_body" = "剩餘 %1$@。已達到 %2$d%% %3$@提醒門檻。"; +"quota_warning_notification_body_with_account" = "帳號 %1$@。剩餘 %2$@。已達到 %3$d%% %4$@提醒門檻。"; +"session_depleted_notification_title" = "%@ 工作階段已用完"; +"session_depleted_notification_body" = "剩餘 0%。恢復可用時會再通知。"; +"session_restored_notification_title" = "%@ 工作階段已恢復"; +"session_restored_notification_body" = "工作階段配額已恢復可用。"; +"quota_warning_warn_at" = "提醒門檻"; +"quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; "quota_warning_sound" = "播放通知音效"; -"quota_warning_provider_inherits" = "預設使用全局配額預警設定,除非在這裡自定義時段。"; -"quota_warning_customize_thresholds" = "自定義 %@ 閾值"; -"quota_warning_enable_warnings" = "啟用 %@ 預警"; -"quota_warning_window_warn_at" = "%@ 預警閾值"; +"quota_warning_provider_inherits" = "預設使用全域配額提醒設定,除非在此自訂時段。"; +"quota_warning_customize_thresholds" = "自訂 %@ 門檻"; +"quota_warning_enable_warnings" = "啟用 %@ 提醒"; +"quota_warning_window_warn_at" = "%@ 提醒門檻"; "quota_warning_off" = "關閉"; "quota_warning_inherited" = "繼承:%@"; -"quota_warning_depleted_only" = "僅耗盡時"; +"quota_warning_depleted_only" = "僅用完時"; "quota_warning_upper" = "上限"; "quota_warning_lower" = "下限"; "apply" = "套用"; "quit_app" = "結束 CodexBar"; "tab_general" = "一般"; -"tab_providers" = "供應商"; +"tab_providers" = "提供者"; "tab_display" = "顯示"; "tab_advanced" = "進階"; "tab_about" = "關於"; "tab_debug" = "除錯"; -"select_a_provider" = "選擇一個供應商"; +"select_a_provider" = "選擇提供者"; "cancel" = "取消"; -"last_fetch_failed" = "上次獲取失敗"; -"usage_not_fetched_yet" = "尚未獲取用量"; -"managed_account_storage_unreadable" = "託管帳號存儲不可讀。實時帳號存取仍可用,但託管新增、重新認證和移除操作已被禁用,直到存儲恢復。"; +"last_fetch_failed" = "上次取得失敗"; +"usage_not_fetched_yet" = "尚未取得使用量"; +"managed_account_storage_unreadable" = "託管帳號儲存區無法讀取。即時帳號仍可存取,但託管新增、重新認證和移除操作已被停用,直到儲存區恢復。"; "remove_codex_account_title" = "移除 Codex 帳號?"; "remove" = "移除"; "managed_login_already_running" = "託管 Codex 登入已在執行。請等待完成後再新增或重新認證其他帳號。"; "managed_login_failed" = "託管 Codex 登入未完成。請先在終端確認 `codex --version` 可以執行。如果 macOS 封鎖了 `codex` 或將它移到垃圾桶,請移除舊的重複安裝,執行 `npm install -g --include=optional @openai/codex@latest`,然後重試。"; -"managed_login_missing_email" = "Codex 登入已完成,但無法獲取帳號電子郵件。請在確認帳號已完全登入後重試。"; +"managed_login_missing_email" = "Codex 登入已完成,但無法取得帳號電子郵件。請在確認帳號已完全登入後重試。"; +"login_success_notification_title" = "%@ 登入成功"; +"login_success_notification_body" = "你可以回到 App;認證已完成。"; "workspace_selection_cancelled" = "CodexBar 發現多個工作區,但未選擇任何工作區。"; "unsafe_managed_home" = "CodexBar 拒絕修改意外的託管主目錄路徑:%@"; "menu_bar_metric_title" = "選單列指標"; @@ -469,41 +478,41 @@ "primary_api_key_limit" = "主要(API 金鑰限制)"; "section_menu_bar" = "選單列"; "merge_icons_title" = "合併圖示"; -"merge_icons_subtitle" = "使用單個選單列圖示並帶供應商切換器。"; +"merge_icons_subtitle" = "使用單一選單列圖示並帶提供者切換器。"; "switcher_shows_icons_title" = "切換器顯示圖示"; -"switcher_shows_icons_subtitle" = "在切換器中顯示供應商圖示(否則顯示每週進度線)。"; -"show_most_used_provider_title" = "顯示用量最高的供應商"; -"show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的供應商。"; +"switcher_shows_icons_subtitle" = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; +"show_most_used_provider_title" = "顯示使用量最高的提供者"; +"show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的提供者。"; "menu_bar_shows_percent_title" = "選單列顯示百分比"; -"menu_bar_shows_percent_subtitle" = "將小動物進度條替換為供應商品牌圖示和百分比。"; +"menu_bar_shows_percent_subtitle" = "將小動物進度條替換為提供者品牌圖示和百分比。"; "display_mode_title" = "顯示模式"; -"display_mode_subtitle" = "選擇選單列中顯示的內容(進度會顯示實際用量與預期的對比)。"; +"display_mode_subtitle" = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; "section_menu_content" = "選單內容"; -"show_usage_as_used_title" = "顯示已使用用量"; -"show_usage_as_used_subtitle" = "進度條會隨配額消耗而填充(而不是顯示剩餘量)。"; -"show_quota_warning_markers_title" = "顯示配額預警標記"; -"show_quota_warning_markers_subtitle" = "設定配額預警後,在用量條上繪製閾值刻度標記。"; -"weekly_progress_work_days_title" = "每週進度工作日"; -"weekly_progress_work_days_subtitle" = "在每週用量條上繪製日期邊界刻度標記。"; -"show_reset_time_as_clock_title" = "將重置時間顯示為時鐘"; -"show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒計時。"; -"show_provider_changelog_links_title" = "顯示供應商變更日誌連結"; -"show_provider_changelog_links_subtitle" = "在選單中為支持的 CLI 供應商新增發布說明連結。"; -"show_credits_extra_usage_title" = "顯示額度 + 額外用量"; -"show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外用量部分。"; +"show_usage_as_used_title" = "以已用量顯示"; +"show_usage_as_used_subtitle" = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; +"show_quota_warning_markers_title" = "顯示配額提醒標記"; +"show_quota_warning_markers_subtitle" = "設定配額提醒後,在使用量條上繪製門檻刻度標記。"; +"weekly_progress_work_days_title" = "每週進度工作日標記"; +"weekly_progress_work_days_subtitle" = "在每週使用量條上繪製日期邊界刻度標記。"; +"show_reset_time_as_clock_title" = "以時鐘時間顯示重置時間"; +"show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; +"show_provider_changelog_links_title" = "顯示提供者版本資訊連結"; +"show_provider_changelog_links_subtitle" = "在選單中為支援的 CLI 提供者新增發行說明連結。"; +"show_credits_extra_usage_title" = "顯示額度 + 額外使用量"; +"show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; "show_all_token_accounts_title" = "顯示所有 token 帳號"; "show_all_token_accounts_subtitle" = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; -"multi_account_layout_title" = "多帳號布局"; +"multi_account_layout_title" = "多帳號版面配置"; "multi_account_layout_subtitle" = "選擇分段帳號切換或堆疊帳號卡片。"; "multi_account_layout_segmented" = "分段"; "multi_account_layout_stacked" = "堆疊"; -"overview_tab_providers_title" = "概覽標籤供應商"; +"overview_tab_providers_title" = "概覽標籤提供者"; "configure" = "設定…"; -"overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的供應商。"; -"overview_no_providers_hint" = "「概覽」中沒有可用的已啟用供應商。"; -"overview_rows_follow_order" = "概覽行始終遵循供應商順序。"; -"overview_no_providers_selected" = "未選擇供應商"; -"section_keyboard_shortcut" = "快捷鍵"; +"overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; +"overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; +"overview_rows_follow_order" = "概覽列一律依提供者順序排列。"; +"overview_no_providers_selected" = "未選擇提供者"; +"section_keyboard_shortcut" = "快速鍵"; "open_menu_shortcut_title" = "開啟選單"; "open_menu_shortcut_subtitle" = "從任意位置觸發選單列選單。"; "install_cli" = "安裝 CLI"; @@ -511,20 +520,20 @@ "cli_not_found" = "在 App 套件中找不到 CodexBarCLI。"; "no_writable_bin_dirs" = "找不到可寫的 bin 目錄。"; "show_debug_settings_title" = "顯示除錯設定"; -"show_debug_settings_subtitle" = "在「除錯」標籤中顯示故障排除工具。"; +"show_debug_settings_subtitle" = "在「除錯」標籤中顯示疑難排解工具。"; "surprise_me_title" = "給我驚喜"; -"surprise_me_subtitle" = "看看你是否喜歡你的 Agent 在上面找點樂子。"; -"weekly_limit_confetti_title" = "每週限制彩紙"; -"weekly_limit_confetti_subtitle" = "當每週用量重置時播放全螢幕彩紙。"; +"surprise_me_subtitle" = "讓選單列上的 Agent 多一點變化。"; +"weekly_limit_confetti_title" = "每週重置慶祝動畫"; +"weekly_limit_confetti_subtitle" = "每週使用量重置時播放全螢幕慶祝動畫。"; "hide_personal_info_title" = "隱藏個人資訊"; "hide_personal_info_subtitle" = "在選單列和選單介面中隱藏電子郵件地址。"; -"show_provider_storage_usage_title" = "顯示供應商存儲用量"; -"show_provider_storage_usage_subtitle" = "在選單中顯示本地磁碟用量。會在背景掃描已知的供應商自有路徑。"; +"show_provider_storage_usage_title" = "顯示提供者儲存使用量"; +"show_provider_storage_usage_subtitle" = "在選單中顯示本機磁碟使用量。會在背景掃描已知的提供者自有路徑。"; "section_keychain_access" = "鑰匙圈存取"; -"keychain_access_caption" = "禁用所有鑰匙圈讀寫。瀏覽器 Cookie 匯入不可用;請在「供應商」中手動貼上 Cookie 標頭。"; -"disable_keychain_access_title" = "禁用鑰匙圈存取"; +"keychain_access_caption" = "停用所有鑰匙圈讀寫。如果 macOS 在你按下一律允許後仍持續要求存取「Chrome/Brave/Edge Safe Storage」,可使用此選項。啟用時無法匯入瀏覽器 Cookie;請在「提供者」中手動貼上 Cookie 標頭。透過 CLI 的 Claude/Codex OAuth 仍可使用。"; +"disable_keychain_access_title" = "停用鑰匙圈存取"; "disable_keychain_access_subtitle" = "啟用時封鎖任何鑰匙圈存取。"; -"about_tagline" = "願你的 token 永不耗盡,隨時關注 Agent 限制。"; +"about_tagline" = "願你的 token 永不用完,隨時掌握 Agent 限制。"; "link_github" = "GitHub"; "link_website" = "網站"; "link_twitter" = "Twitter"; @@ -532,68 +541,68 @@ "check_updates_auto" = "自動檢查更新"; "update_channel" = "更新頻道"; "check_for_updates" = "檢查更新…"; -"updates_unavailable" = "此建置中更新不可用。"; +"updates_unavailable" = "此建置無法使用更新功能。"; "copyright" = "© 2026 Peter Steinberger。MIT 許可證。"; -"section_logging" = "日誌"; -"enable_file_logging" = "啟用檔案日誌"; -"enable_file_logging_subtitle" = "將日誌寫入 %@ 以進行除錯。"; +"section_logging" = "記錄"; +"enable_file_logging" = "啟用檔案記錄"; +"enable_file_logging_subtitle" = "將記錄寫入 %@ 以進行除錯。"; "verbosity_title" = "詳細程度"; -"verbosity_subtitle" = "控制日誌記錄的詳細程度。"; -"open_log_file" = "開啟日誌檔案"; +"verbosity_subtitle" = "控制記錄詳細程度。"; +"open_log_file" = "開啟記錄檔"; "force_animation_next_refresh" = "下次重新整理時強制動畫"; "force_animation_next_refresh_subtitle" = "下次重新整理後暫時顯示載入動畫。"; "section_loading_animations" = "載入動畫"; -"loading_animations_caption" = "選擇一個模式並在選單列中重放。「隨機」保持現有行為。"; +"loading_animations_caption" = "選擇一個模式並在選單列中重播。「隨機」保持現有行為。"; "animation_random_default" = "隨機(預設)"; -"replay_selected_animation" = "重放選中的動畫"; +"replay_selected_animation" = "重播選取的動畫"; "blink_now" = "立即閃爍"; -"section_probe_logs" = "探測日誌"; -"probe_logs_caption" = "獲取最新的探測輸出以進行除錯;複製會保留完整文本。"; -"fetch_log" = "獲取日誌"; +"section_probe_logs" = "探測記錄"; +"probe_logs_caption" = "取得最新的探測輸出以進行除錯;複製會保留完整文字。"; +"fetch_log" = "取得記錄"; "copy" = "複製"; -"save_to_file" = "保存到檔案"; -"load_parse_dump" = "載入解析轉儲"; -"rerun_provider_autodetect" = "重新執行供應商自動檢測"; +"save_to_file" = "儲存到檔案"; +"load_parse_dump" = "載入解析 dump"; +"rerun_provider_autodetect" = "重新執行提供者自動偵測"; "loading" = "載入中…"; -"no_log_yet_fetch" = "尚無日誌。獲取後載入。"; -"section_fetch_strategy" = "獲取策略嘗試"; -"fetch_strategy_caption" = "供應商上次獲取流程中的決策和錯誤。"; +"no_log_yet_fetch" = "尚無記錄。取得後載入。"; +"section_fetch_strategy" = "取得策略嘗試"; +"fetch_strategy_caption" = "提供者上次取得流程中的決策和錯誤。"; "section_openai_cookies" = "OpenAI Cookie"; -"openai_cookies_caption" = "上次 OpenAI Cookie 嘗試中的 Cookie 匯入和 WebKit 抓取日誌。"; -"no_log_yet" = "尚無日誌。請在「供應商」→「Codex」中更新 OpenAI Cookie 以執行匯入。"; +"openai_cookies_caption" = "上次 OpenAI Cookie 嘗試中的 Cookie 匯入和 WebKit 抓取記錄。"; +"no_log_yet" = "尚無記錄。請在「提供者」→「Codex」中更新 OpenAI Cookie 以執行匯入。"; "section_caches" = "快取"; "caches_caption" = "清除快取的費用掃描結果或瀏覽器 Cookie 快取。"; "clear_cookie_cache" = "清除 Cookie 快取"; "clear_cost_cache" = "清除費用快取"; "section_notifications" = "通知"; -"notifications_caption" = "觸發 5 小時工作階段時段的測試通知(耗盡/恢復)。"; -"post_depleted" = "發布耗盡通知"; -"post_restored" = "發布恢復通知"; -"section_cli_sessions" = "CLI 會話"; -"cli_sessions_caption" = "探測後保持 Codex/Claude CLI 會話存活。預設在捕獲資料後結束。"; -"keep_cli_sessions_alive" = "保持 CLI 會話存活"; -"keep_cli_sessions_alive_subtitle" = "探測之間跳過清理(僅限除錯)。"; -"reset_cli_sessions" = "重置 CLI 會話"; +"notifications_caption" = "觸發 5 小時工作階段時段的測試通知(用完/恢復)。"; +"post_depleted" = "傳送用完通知"; +"post_restored" = "傳送恢復通知"; +"section_cli_sessions" = "CLI 工作階段"; +"cli_sessions_caption" = "探測後保持 Codex/Claude CLI 工作階段存活。預設在擷取資料後結束。"; +"keep_cli_sessions_alive" = "保持 CLI 工作階段存活"; +"keep_cli_sessions_alive_subtitle" = "探測之間跳過關閉流程(僅限除錯)。"; +"reset_cli_sessions" = "重置 CLI 工作階段"; "section_error_simulation" = "錯誤模擬"; -"error_simulation_caption" = "將模擬錯誤消息注入選單卡片以進行布局測試。"; +"error_simulation_caption" = "將模擬錯誤訊息注入選單卡片以進行版面配置測試。"; "set_menu_error" = "設定選單錯誤"; "clear_menu_error" = "清除選單錯誤"; "set_cost_error" = "設定費用錯誤"; "clear_cost_error" = "清除費用錯誤"; "section_cli_paths" = "CLI 路徑"; -"cli_paths_caption" = "解析到的 Codex 二進位檔案和 PATH 層;啟動時捕獲登入 PATH(短超時)。"; +"cli_paths_caption" = "解析到的 Codex 二進位檔案和 PATH 層;啟動時擷取登入 PATH(短逾時)。"; "codex_binary" = "Codex 二進位檔案"; "claude_binary" = "Claude 二進位檔案"; "effective_path" = "有效 PATH"; -"unavailable" = "不可用"; -"login_shell_path" = "登入 shell PATH(啟動時捕獲)"; +"unavailable" = "無法使用"; +"login_shell_path" = "登入 shell PATH(啟動時擷取)"; "cleared" = "已清除。"; -"no_fetch_attempts" = "尚無獲取嘗試。"; +"no_fetch_attempts" = "尚無取得嘗試。"; "metric_pref_automatic" = "自動"; "metric_pref_primary" = "主要"; "metric_pref_secondary" = "次要"; "metric_pref_tertiary" = "第三"; -"metric_pref_extra_usage" = "額外用量"; +"metric_pref_extra_usage" = "額外使用量"; "metric_pref_average" = "平均"; "display_mode_percent" = "百分比"; "display_mode_pace" = "進度"; @@ -601,9 +610,9 @@ "display_mode_percent_desc" = "顯示剩餘/已使用百分比(例如 45%)"; "display_mode_pace_desc" = "顯示進度指示器(例如 +5%)"; "display_mode_both_desc" = "同時顯示百分比和進度(例如 45% · +5%)"; -"status_operational" = "正常執行"; -"status_partial_outage" = "部分中斷"; -"status_major_outage" = "重大中斷"; +"status_operational" = "運作正常"; +"status_partial_outage" = "部分服務中斷"; +"status_major_outage" = "重大服務中斷"; "status_critical_issue" = "嚴重問題"; "status_maintenance" = "維護中"; "status_unknown" = "狀態未知"; @@ -617,9 +626,9 @@ "CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示"; "Dismiss" = "關閉"; "Open Menu Bar Settings" = "開啟選單列設定"; -"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列應用。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; "cost_header_estimated" = "費用(估算)"; -"cost_estimate_hint" = "根據本地日誌估算 · 可能與帳單不同"; -"copilot_device_code" = "設備代碼已複製到剪貼板:%1$@\n\n請在以下地址驗證:%2$@"; +"cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; +"copilot_device_code" = "裝置代碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; -"vertex_ai_login_instructions" = "要追蹤 Vertex AI 用量,請通過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 按照瀏覽器提示登入\n4. 設定你的項目:gcloud config set project PROJECT_ID\n\n是否現在開啟終端?"; +"vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 5d295b7fc..3ea0ada27 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -47,6 +47,24 @@ enum SessionQuotaNotificationLogic { if wasDepleted, !isDepleted { return .restored } return .none } + + static func notificationCopy( + transition: SessionQuotaTransition, + providerName: String) -> (title: String, body: String) + { + switch transition { + case .none: + ("", "") + case .depleted: + ( + L("session_depleted_notification_title", providerName), + L("session_depleted_notification_body")) + case .restored: + ( + L("session_restored_notification_title", providerName), + L("session_restored_notification_body")) + } + } } enum QuotaWarningNotificationLogic { @@ -57,13 +75,24 @@ enum QuotaWarningNotificationLogic { currentRemaining: Double, accountDisplayName: String? = nil) -> (title: String, body: String) { - let windowLabel = window.displayName + let windowLabel = window.localizedNotificationDisplayName let remainingText = Self.percentText(currentRemaining) - let accountPrefix = accountDisplayName - .map { "Account \($0). " } ?? "" - return ( - "\(providerName) \(windowLabel) quota low", - "\(accountPrefix)\(remainingText) left. Reached your \(threshold)% \(windowLabel) warning threshold.") + let title = L("quota_warning_notification_title", providerName, windowLabel) + let body = if let accountDisplayName { + L( + "quota_warning_notification_body_with_account", + accountDisplayName, + remainingText, + threshold, + windowLabel) + } else { + L( + "quota_warning_notification_body", + remainingText, + threshold, + windowLabel) + } + return (title, body) } static func crossedThreshold( @@ -116,14 +145,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let (title, body) = switch transition { - case .none: - ("", "") - case .depleted: - ("\(providerName) session depleted", "0% left. Will notify when it's available again.") - case .restored: - ("\(providerName) session restored", "Session quota is available again.") - } + let (title, body) = SessionQuotaNotificationLogic.notificationCopy( + transition: transition, + providerName: providerName) let providerText = provider.rawValue let transitionText = String(describing: transition) @@ -156,3 +180,12 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) } } + +extension QuotaWarningWindow { + fileprivate var localizedNotificationDisplayName: String { + switch self { + case .session: L("quota_warning_session") + case .weekly: L("quota_warning_weekly") + } + } +} diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index db2010e88..a02500809 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,6 +1,14 @@ import AppKit import CodexBarCore +enum LoginNotificationLogic { + static func notificationCopy(providerName: String) -> (title: String, body: String) { + ( + L("login_success_notification_title", providerName), + L("login_success_notification_body")) + } +} + extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus @@ -528,8 +536,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { func postLoginNotification(for provider: UsageProvider) { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let title = "\(name) login successful" - let body = "You can return to the app; authentication finished." + let (title, body) = LoginNotificationLogic.notificationCopy(providerName: name) AppNotifications.shared.post(idPrefix: "login-\(provider.rawValue)", title: title, body: body) } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 2a8c612f7..a61a5c87f 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -63,7 +63,14 @@ struct CodexManagedOpenAIWebRefreshTests { await completion.markCompleted() } - let completed = await completion.waitUntilCompleted(timeout: .seconds(30)) + let didStart = await blocker.waitUntilStartedWithin(count: 1, timeout: .seconds(60)) + #expect(didStart == true) + if !didStart { + refreshTask.cancel() + return + } + + let completed = await completion.waitUntilCompleted(timeout: .seconds(2)) #expect(completed == true) if !completed { refreshTask.cancel() @@ -73,12 +80,6 @@ struct CodexManagedOpenAIWebRefreshTests { await refreshTask.value let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) - let didStart = await blocker.waitUntilStartedWithin(count: 1, timeout: .seconds(30)) - #expect(didStart == true) - if !didStart { - backgroundTask.cancel() - return - } #expect(await blocker.startedCount() == 1) await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( diff --git a/Tests/CodexBarTests/LoginNotificationLogicTests.swift b/Tests/CodexBarTests/LoginNotificationLogicTests.swift new file mode 100644 index 000000000..68f4ab911 --- /dev/null +++ b/Tests/CodexBarTests/LoginNotificationLogicTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct LoginNotificationLogicTests { + @Test + func `login success notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = LoginNotificationLogic.notificationCopy(providerName: "Codex") + + #expect(copy.title == "Codex 登入成功") + #expect(copy.body == "你可以回到 App;認證已完成。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } +} diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 6dea64ee3..411ed124b 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -378,10 +378,12 @@ struct MiMoProviderTests { @Test @MainActor func `provider detail plan row formats mimo as balance`() { - let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") - #expect(row?.label == "Balance") - #expect(row?.value == "$25.51") + #expect(row?.label == "Balance") + #expect(row?.value == "$25.51") + } } @Test(arguments: [UsageProvider.openrouter, .mimo]) diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index ee60e2217..b2629ecea 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -29,71 +29,81 @@ struct ProvidersPaneCoverageTests { @Test func `open router menu bar metric picker shows only automatic and primary`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .openrouter) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - MenuBarMetricPreference.primary.rawValue, - ]) - #expect(picker?.options.map(\.title) == [ - "Automatic", - "Primary (API key limit)", - ]) + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .openrouter) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (API key limit)", + ]) + } } @Test func `deepseek menu bar metric picker shows balance only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .deepseek) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .deepseek) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + } } @Test func `moonshot menu bar metric picker shows balance only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .moonshot) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .moonshot) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + } } @Test func `mistral menu bar metric picker shows spend only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .mistral) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .mistral) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + } } @Test func `kimi k2 menu bar metric picker shows credits only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .kimik2) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .kimik2) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + } } @Test @@ -109,22 +119,24 @@ struct ProvidersPaneCoverageTests { @Test func `cursor menu bar metric picker includes tertiary api lane when snapshot has api metric`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date()), - provider: .cursor) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .cursor) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) - let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } - #expect(tertiaryOption?.title == "Tertiary (API)") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (API)") + } } @Test @@ -146,27 +158,29 @@ struct ProvidersPaneCoverageTests { @Test func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - providerCost: ProviderCostSnapshot( - used: 15, - limit: 100, - currencyCode: "USD", + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), updatedAt: Date()), - updatedAt: Date()), - provider: .cursor) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .cursor) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) - let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } - #expect(option?.title == "Extra usage") + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } } @Test @@ -209,22 +223,24 @@ struct ProvidersPaneCoverageTests { @Test func `zai menu bar metric picker includes tertiary 5-hour lane when snapshot has it`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - updatedAt: Date()), - provider: .zai) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .zai) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) - let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } - #expect(tertiaryOption?.title == "Tertiary (5-hour)") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .zai) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (5-hour)") + } } @Test @@ -240,26 +256,32 @@ struct ProvidersPaneCoverageTests { @Test func `provider detail plan row formats open router as balance`() { - let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") - #expect(row?.label == "Balance") - #expect(row?.value == "$4.61") + #expect(row?.label == "Balance") + #expect(row?.value == "$4.61") + } } @Test func `provider detail plan row formats moonshot as balance`() { - let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") - #expect(row?.label == "Balance") - #expect(row?.value == "$49.58") + #expect(row?.label == "Balance") + #expect(row?.value == "$49.58") + } } @Test func `provider detail plan row keeps plan label for non open router`() { - let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") - #expect(row?.label == "Plan") - #expect(row?.value == "Pro") + #expect(row?.label == "Plan") + #expect(row?.value == "Pro") + } } @Test @@ -370,6 +392,10 @@ struct ProvidersPaneCoverageTests { settings: settings) } + private static func withEnglishLocalization(perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue("en", operation: body) + } + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) let auth = [ diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift index 2f7f2b3b2..b0bd32c7f 100644 --- a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -1,42 +1,64 @@ import Testing @testable import CodexBar +@Suite(.serialized) struct QuotaWarningNotificationLogicTests { @Test func `quota warning copy includes current remaining and threshold`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .session, - threshold: 20, - currentRemaining: 12.4) - - #expect(copy.title == "Codex session quota low") - #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 20, + currentRemaining: 12.4) + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + } } @Test func `quota warning copy clamps current remaining`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .weekly, - threshold: 50, - currentRemaining: -3) - - #expect(copy.title == "Codex weekly quota low") - #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .weekly, + threshold: 50, + currentRemaining: -3) + + #expect(copy.title == "Codex weekly quota low") + #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + } } @Test func `quota warning copy includes account when provided`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .session, - threshold: 50, - currentRemaining: 45, - accountDisplayName: "person@example.com") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + } + } - #expect(copy.title == "Codex session quota low") - #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + @Test + func `quota warning copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex 工作階段配額偏低") + #expect(copy.body == "帳號 person@example.com。剩餘 45%。已達到 50% 工作階段提醒門檻。") + } } @Test @@ -123,4 +145,8 @@ struct QuotaWarningNotificationLogicTests { #expect(crossed == nil) #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift index 5b0024fac..2c43d3179 100644 --- a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift +++ b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift @@ -1,6 +1,7 @@ import Testing @testable import CodexBar +@Suite(.serialized) struct SessionQuotaNotificationLogicTests { @Test func `does nothing without previous value`() { @@ -32,4 +33,32 @@ struct SessionQuotaNotificationLogicTests { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0.00001) #expect(transition == .none) } + + @Test + func `depleted notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .depleted, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已用完") + #expect(copy.body == "剩餘 0%。恢復可用時會再通知。") + } + } + + @Test + func `restored notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .restored, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已恢復") + #expect(copy.body == "工作階段配額已恢復可用。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 5b8c641dd..b80ea7522 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -388,7 +388,9 @@ struct UsageStoreCoverageTests { func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) - #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + } var gate = ConsecutiveFailureGate() let first = gate.shouldSurfaceError(onFailureWithPriorData: true)