diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2aae9f1b85..1be6a540b9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -28,6 +28,23 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + CA100003 /* ClaudeUsageFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100002 /* ClaudeUsageFetcher.swift */; }; + CA100005 /* StatuspageIOFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100004 /* StatuspageIOFetcher.swift */; }; + CA100009 /* ProviderAccountsFooterPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100008 /* ProviderAccountsFooterPanel.swift */; }; + CA100091 /* ProviderAccountsSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100090 /* ProviderAccountsSettingsSection.swift */; }; + CA100095 /* ProviderAccountsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100094 /* ProviderAccountsPopover.swift */; }; + CA10000B /* ProviderUsageColorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10000A /* ProviderUsageColorSettings.swift */; }; + CA10000D /* ProviderAccountEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10000C /* ProviderAccountEditorSheet.swift */; }; + CA100011 /* UsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100010 /* UsageProvider.swift */; }; + CA100013 /* ProviderISO8601DateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100012 /* ProviderISO8601DateParser.swift */; }; + CA100093 /* ProviderHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100092 /* ProviderHTTP.swift */; }; + CA100015 /* ProviderAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100014 /* ProviderAccount.swift */; }; + CA100017 /* ProviderAccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100016 /* ProviderAccountStore.swift */; }; + CA100019 /* ProviderAccountsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100018 /* ProviderAccountsController.swift */; }; + CA10001B /* ProviderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10001A /* ProviderRegistry.swift */; }; + CA10001D /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10001C /* ClaudeProvider.swift */; }; + CA10001F /* CodexProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10001E /* CodexProvider.swift */; }; + CA100021 /* CodexUsageFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100020 /* CodexUsageFetcher.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; @@ -118,6 +135,7 @@ F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; + DA7A20CA00000001 /* ProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A20CA00000002 /* ProviderTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; @@ -239,6 +257,23 @@ A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = ""; }; + CA100002 /* ClaudeUsageFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ClaudeUsageFetcher.swift; sourceTree = ""; }; + CA100004 /* StatuspageIOFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/StatuspageIOFetcher.swift; sourceTree = ""; }; + CA100008 /* ProviderAccountsFooterPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsFooterPanel.swift; sourceTree = ""; }; + CA100090 /* ProviderAccountsSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsSettingsSection.swift; sourceTree = ""; }; + CA100094 /* ProviderAccountsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsPopover.swift; sourceTree = ""; }; + CA10000A /* ProviderUsageColorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderUsageColorSettings.swift; sourceTree = ""; }; + CA10000C /* ProviderAccountEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountEditorSheet.swift; sourceTree = ""; }; + CA100010 /* UsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/UsageProvider.swift; sourceTree = ""; }; + CA100012 /* ProviderISO8601DateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderISO8601DateParser.swift; sourceTree = ""; }; + CA100092 /* ProviderHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderHTTP.swift; sourceTree = ""; }; + CA100014 /* ProviderAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccount.swift; sourceTree = ""; }; + CA100016 /* ProviderAccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccountStore.swift; sourceTree = ""; }; + CA100018 /* ProviderAccountsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccountsController.swift; sourceTree = ""; }; + CA10001A /* ProviderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderRegistry.swift; sourceTree = ""; }; + CA10001C /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ClaudeProvider.swift; sourceTree = ""; }; + CA10001E /* CodexProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/CodexProvider.swift; sourceTree = ""; }; + CA100020 /* CodexUsageFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/CodexUsageFetcher.swift; sourceTree = ""; }; A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; @@ -320,6 +355,7 @@ F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; + DA7A20CA00000002 /* ProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -486,6 +522,23 @@ A5001544 /* TerminalImageTransfer.swift */, A5001545 /* TerminalSSHSessionDetector.swift */, A5001225 /* SocketControlSettings.swift */, + CA100002 /* ClaudeUsageFetcher.swift */, + CA100004 /* StatuspageIOFetcher.swift */, + CA100008 /* ProviderAccountsFooterPanel.swift */, + CA100090 /* ProviderAccountsSettingsSection.swift */, + CA100094 /* ProviderAccountsPopover.swift */, + CA10000A /* ProviderUsageColorSettings.swift */, + CA10000C /* ProviderAccountEditorSheet.swift */, + CA100010 /* UsageProvider.swift */, + CA100012 /* ProviderISO8601DateParser.swift */, + CA100092 /* ProviderHTTP.swift */, + CA100014 /* ProviderAccount.swift */, + CA100016 /* ProviderAccountStore.swift */, + CA100018 /* ProviderAccountsController.swift */, + CA10001A /* ProviderRegistry.swift */, + CA10001C /* ClaudeProvider.swift */, + CA10001E /* CodexProvider.swift */, + CA100020 /* CodexUsageFetcher.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, @@ -605,6 +658,7 @@ F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, + DA7A20CA00000002 /* ProviderTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, @@ -816,6 +870,23 @@ A5001542 /* TerminalImageTransfer.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + CA100003 /* ClaudeUsageFetcher.swift in Sources */, + CA100005 /* StatuspageIOFetcher.swift in Sources */, + CA100009 /* ProviderAccountsFooterPanel.swift in Sources */, + CA100091 /* ProviderAccountsSettingsSection.swift in Sources */, + CA100095 /* ProviderAccountsPopover.swift in Sources */, + CA10000B /* ProviderUsageColorSettings.swift in Sources */, + CA10000D /* ProviderAccountEditorSheet.swift in Sources */, + CA100011 /* UsageProvider.swift in Sources */, + CA100013 /* ProviderISO8601DateParser.swift in Sources */, + CA100093 /* ProviderHTTP.swift in Sources */, + CA100015 /* ProviderAccount.swift in Sources */, + CA100017 /* ProviderAccountStore.swift in Sources */, + CA100019 /* ProviderAccountsController.swift in Sources */, + CA10001B /* ProviderRegistry.swift in Sources */, + CA10001D /* ClaudeProvider.swift in Sources */, + CA10001F /* CodexProvider.swift in Sources */, + CA100021 /* CodexUsageFetcher.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, @@ -909,6 +980,7 @@ F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + DA7A20CA00000001 /* ProviderTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, diff --git a/README.md b/README.md index 8cdae0d645..c2eb3003e6 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,15 @@ Sidebar shows git branch, linked PR status/number, working directory, listening Claude Code Teams + + +

AI provider usage panel

+The sidebar footer shows AI provider subscription usage (Session and Week bars) per account with configurable color thresholds. Supported providers: Claude and Codex. Manage accounts and customize colors in Settings. Credentials are stored in macOS Keychain. See docs/providers.md for the provider model and docs/usage-monitoring-setup.md for per-provider setup instructions. + + +AI provider usage panel in the sidebar footer + + - **Browser import** — Import cookies, history, and sessions from Chrome, Firefox, Arc, and 20+ browsers so browser panes start authenticated diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7649789be6..36392ad29b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87714,6 +87714,10223 @@ } } } + }, + "providers.accounts.error.decoding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to decode account credentials from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainからアカウント資格情報をデコードできませんでした。" + } + } + } + }, + "providers.accounts.error.keychainStatus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keychain error (OSStatus %lld). Check macOS Keychain Access permissions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainエラー (OSStatus %lld)。macOSのKeychain Access権限を確認してください。" + } + } + } + }, + "providers.accounts.error.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account not found." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントが見つかりません。" + } + } + } + }, + "providers.accounts.footer.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "loading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中…" + } + } + } + }, + "providers.accounts.header.collapse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse %@ accounts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを折りたたむ" + } + } + } + }, + "providers.accounts.header.expand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand %@ accounts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを展開" + } + } + } + }, + "providers.accounts.popover.noData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "—" + } + } + } + }, + "providers.accounts.popover.resets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } + } + }, + "providers.accounts.popover.today": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + } + } + }, + "providers.accounts.popover.tomorrow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tomorrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明日" + } + } + } + }, + "providers.accounts.popover.todayAtTime": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today %1$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日 %1$@" + } + } + } + }, + "providers.accounts.popover.tomorrowAtTime": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tomorrow %1$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明日 %1$@" + } + } + } + }, + "providers.accounts.status.openPage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open status page for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@のステータスページを開く" + } + } + } + }, + "providers.accounts.editor.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "providers.accounts.editor.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示名" + } + } + } + }, + "providers.accounts.editor.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, + "providers.accounts.error.loadSecret": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load saved credentials. Re-enter them to save changes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存された資格情報を読み込めませんでした。変更を保存するには再入力してください。" + } + } + } + }, + "providers.accounts.error.unknownProvider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown provider: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なプロバイダー: %@" + } + } + } + }, + "providers.accounts.help.link": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setup instructions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セットアップ手順" + } + } + } + }, + "providers.accounts.section.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة استخدام الذكاء الاصطناعي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Praćenje korištenja AI-ja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "AI-brugsovervågning" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AI-Nutzungsüberwachung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Usage Monitoring" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Monitoreo de uso de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivi d'utilisation IA" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Monitoraggio utilizzo IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI使用量モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 사용량 모니터링" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "AI-bruksovervåking" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Monitorowanie użycia AI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de uso de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг использования ИИ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตรวจสอบการใช้งาน AI" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "AI Kullanım İzleme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Моніторинг використання ШІ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 使用量监控" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 使用量監控" + } + } + } + }, + "providers.accounts.add.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add profile…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを追加…" + } + } + } + }, + "providers.accounts.add.menu.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add %@ profile…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@プロファイルを追加…" + } + } + } + }, + "providers.accounts.editor.title.add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add %@ account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを追加" + } + } + } + }, + "providers.accounts.editor.title.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit %@ account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを編集" + } + } + } + }, + "providers.accounts.footer.accessibility": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام حساب Claude" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Korištenje Claude računa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude-kontoforbrug" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude-Kontonutzung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude account usage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso de cuenta de Claude" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation du compte Claude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizzo account Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeアカウント使用状況" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude 계정 사용량" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude-kontobruk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie konta Claude" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso da conta Claude" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование аккаунта Claude" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งานบัญชี Claude" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude hesap kullanımı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання облікового запису Claude" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude 账户使用量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude 帳戶使用量" + } + } + } + }, + "providers.accounts.footer.session": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitz." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sess" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Økt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеанс" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otrm." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сеанс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段" + } + } + } + }, + "providers.accounts.footer.week": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أسبوع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sedmica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Uge" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Woche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Week" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Semana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sem." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sett." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "週" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tydzień" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Semana" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неделя" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัปดาห์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hafta" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тиждень" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "周" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "週" + } + } + } + }, + "providers.accounts.status.allOk": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جميع الأنظمة تعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svi sistemi rade" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Alle systemer fungerer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Systeme funktionieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All systems operational" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los sistemas operativos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tous les systèmes opérationnels" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tutti i sistemi operativi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全システム正常稼働中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 시스템 정상 작동 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Alle systemer fungerer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wszystkie systemy działają" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Todos os sistemas operacionais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все системы работают" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบทั้งหมดทำงานปกติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tüm sistemler çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Усі системи працюють" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有系统运行正常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有系統運作正常" + } + } + } + }, + "providers.accounts.status.fetchFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر التحقق من الحالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće provjeriti status" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke kontrollere status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status konnte nicht überprüft werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not check status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo verificar el estado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de vérifier l'état" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile verificare lo stato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスを確認できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태를 확인할 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke sjekke status" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się sprawdzić statusu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível verificar o status" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось проверить статус" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถตรวจสอบสถานะ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum kontrol edilemedi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося перевірити статус" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法检查状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法檢查狀態" + } + } + } + }, + "providers.accounts.status.staleWarning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قد تكون الحالة قديمة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Status je možda zastarjeo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Status kan være forældet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status ist möglicherweise veraltet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Status may be outdated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El estado puede estar desactualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'état peut être obsolète" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lo stato potrebbe essere obsoleto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスが古い可能性があります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태가 오래되었을 수 있습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Status kan være utdatert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Status może być nieaktualny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O status pode estar desatualizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус может быть устаревшим" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สถานะอาจล้าสมัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum güncel olmayabilir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Статус може бути застарілим" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态可能已过时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態可能已過時" + } + } + } + }, + "providers.accounts.status.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحقق من الحالة…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjera statusa…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kontrollerer status…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status wird überprüft…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking status…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobando estado…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de l'état…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica dello stato…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスを確認中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태 확인 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sjekker status…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdzanie statusu…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando status…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка статуса…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังตรวจสอบสถานะ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum kontrol ediliyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевірка статусу…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查状态…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢查狀態…" + } + } + } + }, + "providers.accounts.refresh.now": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Osvježi sada" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdater nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh now" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiorna ora" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 새로고침" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdater nå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież teraz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizar agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить сейчас" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเฟรชตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi yenile" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновити зараз" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重新整理" + } + } + } + }, + "providers.incident.impact.maintenance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maintenance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メンテナンス" + } + } + } + }, + "providers.incident.impact.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "providers.incident.status.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "providers.accounts.manage": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الحسابات…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upravljanje računima…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Administrer konti…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konten verwalten…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage accounts…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestionar cuentas…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les comptes…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci account…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを管理…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 관리…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administrer kontoer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zarządzaj kontami…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar contas…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление аккаунтами…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดการบัญชี…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesapları yönet…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Керувати обліковими записами…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理账户…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理帳戶…" + } + } + } + }, + "providers.accounts.popover.fetchedAt": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "آخر تحديث" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažurirano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateret" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mis à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zaktualizowano" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัปเดตแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellendi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已更新" + } + } + } + }, + "provider.status.operational": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Operativno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Operationel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Betriebsbereit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Operational" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Operativo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opérationnel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Operativo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Operativ" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Operacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปกติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + } + } + }, + "provider.status.minor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشكلة بسيطة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Manji problem" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mindre problem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kleines Problem" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Minor issue" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Problema menor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problème mineur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Problema minore" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "軽微な問題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경미한 문제" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mindre problem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Drobny problem" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Problema menor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Незначительная проблема" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปัญหาเล็กน้อย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Küçük sorun" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Незначна проблема" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "轻微问题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輕微問題" + } + } + } + }, + "provider.status.degraded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متدهور" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Degradirano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forringet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beeinträchtigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Degraded" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Degradado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dégradé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Degradato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低下" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저하됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forringet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pogorszone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Degradado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ухудшено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประสิทธิภาพลดลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düşük performans" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Погіршено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "性能下降" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "效能下降" + } + } + } + }, + "provider.status.critical": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حرج" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kritično" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kritisk" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kritisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Critical" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crítico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Critique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Critico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "重大" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "심각" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kritisk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krytyczny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Crítico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Критично" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วิกฤต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kritik" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Критично" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "严重" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "嚴重" + } + } + } + }, + "provider.status.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nepoznato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ukendt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sconosciuto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ukjent" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieznany" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ทราบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bilinmiyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Невідомо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "providers.accounts.usage.notStarted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تبدأ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "nije započeto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "ikke startet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "nicht gestartet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "not started" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "no iniciado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "non démarré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "non avviato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작되지 않음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "ikke startet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "nie rozpoczęto" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "não iniciado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "не начато" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่เริ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "başlamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "не розпочато" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + } + } + }, + "providers.accounts.usage.sessionNotStarted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تبدأ الجلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "sesija nije započeta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "session ikke startet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzung nicht gestartet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Session not started" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "sesión no iniciada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "session non démarrée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sessione non avviata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション未開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션이 시작되지 않음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "økt ikke startet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "sesja nie rozpoczęta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "sessão não iniciada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "сеанс не начат" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชันยังไม่เริ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "oturum başlamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "сеанс не розпочато" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话未开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段未開始" + } + } + } + }, + "providers.accounts.usage.resets": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُعاد التعيين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "resetuje se" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "nulstilles" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "resets" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "se restablece" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "réinitialise" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "si reimposta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "nullstilles" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "resetuje się" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "redefine" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "сброс" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sıfırlanır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "скидається" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "providers.accounts.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No profiles yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルなし" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد ملفات تعريف بعد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema profila" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen profiler endnu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Profile" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin perfiles aún" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun profil pour le moment" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun profilo ancora" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen profiler ennå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak profili" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum perfil ainda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Профилей пока нет" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีโปรไฟล์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz profil yok" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Профілів ще немає" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无配置文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無設定檔" + } + } + } + }, + "providers.accounts.edit.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعديل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uredi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bearbeiten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Edytuj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แก้ไข" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯" + } + } + } + }, + "providers.accounts.remove.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "providers.accounts.remove.confirm.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this account?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このアカウントを削除しますか?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هل تريد إزالة هذا الحساب؟" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloniti ovaj račun?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern denne konto?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Konto entfernen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar esta cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer ce compte ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovere questo account?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 계정을 제거하시겠습니까?" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern denne kontoen?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usunąć to konto?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover esta conta?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот аккаунт?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบบัญชีนี้หรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu hesap kaldırılsın mı?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити цей обліковий запис?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除此账户?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除此帳戶?" + } + } + } + }, + "providers.accounts.remove.confirm.action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "providers.accounts.remove.confirm.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The saved credentials will be deleted from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存された認証情報がキーチェーンから削除されます。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيتم حذف بيانات الاعتماد المحفوظة من سلسلة المفاتيح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Spremljeni podaci za prijavu bit će izbrisani iz Keychain-a." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "De gemte legitimationsoplysninger slettes fra nøglering." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die gespeicherten Anmeldedaten werden aus dem Schlüsselbund gelöscht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las credenciales guardadas se eliminarán del llavero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les identifiants enregistrés seront supprimés du trousseau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le credenziali salvate verranno eliminate dal Portachiavi." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장된 자격 증명이 키체인에서 삭제됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "De lagrede legitimasjonene slettes fra nøkkelringen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisane dane uwierzytelniające zostaną usunięte z pęku kluczy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As credenciais salvas serão excluídas do Chaveiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранённые учётные данные будут удалены из Связки ключей." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อมูลรับรองที่บันทึกไว้จะถูกลบออกจาก Keychain" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaydedilen kimlik bilgileri Anahtarlık'tan silinecek." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Збережені облікові дані буде видалено з В'язки ключів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存的凭据将从钥匙串中删除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已儲存的憑證將從鑰匙圈中刪除。" + } + } + } + }, + "providers.accounts.remove.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İptal" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "providers.accounts.remove.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not remove account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを削除できませんでした" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إزالة الحساب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće ukloniti račun" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto konnte nicht entfernt werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo eliminar la cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de supprimer le compte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile rimuovere l'account" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정을 제거할 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można usunąć konta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível remover a conta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось удалить аккаунт" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถลบบัญชีได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap kaldırılamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося видалити обліковий запис" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法移除账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法移除帳戶" + } + } + } + }, + "providers.accounts.remove.error.decoding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to decode account credentials from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainからアカウント資格情報をデコードできませんでした。" + } + } + } + }, + "providers.accounts.remove.error.keychain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keychain error. Check macOS Keychain Access permissions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainエラーが発生しました。macOSのKeychain Access権限を確認してください。" + } + } + } + }, + "providers.accounts.remove.error.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account not found." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントが見つかりません。" + } + } + } + }, + "providers.accounts.remove.error.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not remove account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを削除できませんでした。" + } + } + } + }, + "providers.accounts.remove.error.ok": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موافق" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aceptar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОК" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตกลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tamam" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "好" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "好" + } + } + } + }, + "claude.accounts.section.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حسابات Claude" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Claude računi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude-konti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude-Konten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Accounts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuentas de Claude" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comptes Claude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Account Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeアカウント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude 계정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude-kontoer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Konta Claude" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Contas Claude" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аккаунты Claude" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บัญชี Claude" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude hesapları" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Облікові записи Claude" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude 账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude 帳戶" + } + } + } + }, + "claude.accounts.add.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة حساب…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodaj račun…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj konto…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto hinzufügen…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add account…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir cuenta…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un compte…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi account…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを追加…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 추가…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til konto…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj konto…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar conta…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить аккаунт…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่มบัญชี…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap ekle…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати обліковий запис…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加账户…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增帳戶…" + } + } + } + }, + "claude.accounts.editor.name": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم العرض" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv za prikaz" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Visningsnavn" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigename" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre para mostrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom d'affichage" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome visualizzato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 이름" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Visningsnavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa wyświetlana" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome de exibição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображаемое имя" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อที่แสดง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Görünen ad" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ім'я для відображення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示名稱" + } + } + } + }, + "claude.accounts.editor.sessionKey": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مفتاح الجلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ključ sesije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sessionsnøgle" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzungsschlüssel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Session key" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Clave de sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé de session" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiave di sessione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションキー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션 키" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sesjonsnøkkel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Klucz sesji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Chave de sessão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключ сессии" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คีย์เซสชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum anahtarı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ключ сесії" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话密钥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段金鑰" + } + } + } + }, + "claude.accounts.editor.sessionKey.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "From claude.ai cookies" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "claude.ai のクッキーから" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "من ملفات تعريف ارتباط claude.ai" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Iz claude.ai kolačića" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fra claude.ai-cookies" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus claude.ai-Cookies" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "De las cookies de claude.ai" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Depuis les cookies de claude.ai" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dai cookie di claude.ai" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "claude.ai 쿠키에서" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fra claude.ai-informasjonskapsler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Z plików cookie claude.ai" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dos cookies do claude.ai" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Из файлов cookie claude.ai" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จากคุกกี้ claude.ai" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "claude.ai çerezlerinden" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "З файлів cookie claude.ai" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "来自 claude.ai Cookie" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "來自 claude.ai Cookie" + } + } + } + }, + "claude.accounts.editor.orgId.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "From claude.ai network requests" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "claude.ai のネットワークリクエストから" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "من طلبات شبكة claude.ai" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Iz mrežnih zahtjeva claude.ai" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fra claude.ai netværksanmodninger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus claude.ai-Netzwerkanfragen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "De las solicitudes de red de claude.ai" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Depuis les requêtes réseau de claude.ai" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dalle richieste di rete di claude.ai" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "claude.ai 네트워크 요청에서" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fra claude.ai nettverksforespørsler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Z żądań sieciowych claude.ai" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Das solicitações de rede do claude.ai" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Из сетевых запросов claude.ai" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จากคำขอเครือข่าย claude.ai" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "claude.ai ağ isteklerinden" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "З мережевих запитів claude.ai" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "来自 claude.ai 网络请求" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "來自 claude.ai 網路請求" + } + } + } + }, + "claude.accounts.editor.orgId": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف المنظمة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "ID organizacije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Organisations-ID" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Organisations-ID" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Organization ID" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ID de organización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant d'organisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "ID organizzazione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組織ID" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조직 ID" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Organisasjons-ID" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "ID organizacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "ID da organização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ID организации" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ID องค์กร" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kuruluş kimliği" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ID організації" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "组织 ID" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "組織 ID" + } + } + } + }, + "claude.accounts.editor.save": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Spremi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Salva" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lagre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Salvar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บันทึก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaydet" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зберегти" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存" + } + } + } + }, + "claude.accounts.editor.cancel": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İptal" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "claude.accounts.editor.title.add": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة حساب Claude" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodaj Claude račun" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj Claude-konto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude-Konto hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Claude account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir cuenta de Claude" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un compte Claude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi account Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeアカウントを追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude 계정 추가" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til Claude-konto" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj konto Claude" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar conta Claude" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить аккаунт Claude" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่มบัญชี Claude" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude hesabı ekle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати обліковий запис Claude" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加 Claude 账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 Claude 帳戶" + } + } + } + }, + "claude.accounts.editor.title.edit": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعديل حساب Claude" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uredi Claude račun" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rediger Claude-konto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude-Konto bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Claude account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar cuenta de Claude" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier le compte Claude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica account Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeアカウントを編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude 계정 편집" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rediger Claude-konto" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Edytuj konto Claude" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Editar conta Claude" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редактировать аккаунт Claude" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แก้ไขบัญชี Claude" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude hesabını düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати обліковий запис Claude" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑 Claude 账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯 Claude 帳戶" + } + } + } + }, + "claude.accounts.status.section": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حالة Claude.ai" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai status" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai-status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai-Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estado de Claude.ai" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "État de Claude.ai" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stato di Claude.ai" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude.aiステータス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai 상태" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai-status" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Status Claude.ai" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Status do Claude.ai" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус Claude.ai" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สถานะ Claude.ai" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai durumu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Статус Claude.ai" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai 状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude.ai 狀態" + } + } + } + }, + "claude.accounts.error.invalidOrgId": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف المنظمة غير صالح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "ID organizacije je nevažeći" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Organisations-ID er ugyldigt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Organisations-ID ist ungültig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Organization ID is invalid" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El ID de organización no es válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'identifiant d'organisation est invalide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'ID organizzazione non è valido" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組織IDが無効です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조직 ID가 유효하지 않습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Organisasjons-ID er ugyldig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "ID organizacji jest nieprawidłowe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O ID da organização é inválido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ID организации недействителен" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ID องค์กรไม่ถูกต้อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kuruluş kimliği geçersiz" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ID організації недійсний" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "组织 ID 无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "組織 ID 無效" + } + } + } + }, + "claude.accounts.error.fetchFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر جلب الاستخدام" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće dohvatiti korištenje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke hente forbrug" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nutzung konnte nicht abgerufen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not fetch usage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo obtener el uso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de récupérer l'utilisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile recuperare l'utilizzo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用状況を取得できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용량을 가져올 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke hente bruk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się pobrać użycia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível obter o uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось получить данные использования" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถดึงข้อมูลการใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım bilgisi alınamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося отримати дані використання" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法获取使用量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法取得使用量" + } + } + } + }, + "claude.accounts.error.keychain": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الوصول إلى سلسلة المفاتيح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pristup Keychainu neuspješan" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgang til nøglering mislykkedes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüsselbund-Zugriff fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keychain access failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de acceso al Llavero" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'accès au Trousseau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Accesso al Portachiavi fallito" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーチェーンへのアクセスに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키체인 접근에 실패했습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nøkkelringtilgang mislyktes" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostęp do pęku kluczy nie powiódł się" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha no acesso ao Chaveiro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка доступа к Связке ключей" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเข้าถึง Keychain ล้มเหลว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Anahtar Zinciri erişimi başarısız" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка доступу до зв'язки ключів" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "钥匙串访问失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鑰匙圈存取失敗" + } + } + } + }, + "claude.accounts.error.remove": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل إزالة الحساب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uklanjanje računa neuspješno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto konnte nicht entfernt werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to remove account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al eliminar la cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la suppression du compte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile rimuovere l'account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントの削除に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정을 제거하지 못했습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się usunąć konta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao remover a conta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось удалить аккаунт" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถลบบัญชี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap kaldırılamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося видалити обліковий запис" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法移除账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法移除帳戶" + } + } + } + }, + "claude.accounts.remove.confirm.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة هذا الحساب؟" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloniti ovaj račun?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern denne konto?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Konto entfernen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar esta cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer ce compte ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovere questo account?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このアカウントを削除しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 계정을 제거하시겠습니까?" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjerne denne kontoen?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usunąć to konto?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover esta conta?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот аккаунт?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบบัญชีนี้หรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu hesap kaldırılsın mı?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити цей обліковий запис?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除此账户?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除此帳戶?" + } + } + } + }, + "claude.accounts.remove.confirm.body": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيتم حذف مفتاح الجلسة من سلسلة المفاتيح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ključ sesije će biti izbrisan iz Keychaina." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sessionsnøglen vil blive slettet fra nøgleringen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Sitzungsschlüssel wird aus dem Schlüsselbund gelöscht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The session key will be deleted from Keychain." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La clave de sesión se eliminará del Llavero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La clé de session sera supprimée du Trousseau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La chiave di sessione verrà eliminata dal Portachiavi." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションキーがキーチェーンから削除されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션 키가 키체인에서 삭제됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sesjonsnøkkelen vil bli slettet fra nøkkelringen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Klucz sesji zostanie usunięty z pęku kluczy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A chave de sessão será excluída do Chaveiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключ сессии будет удалён из Связки ключей." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คีย์เซสชันจะถูกลบจาก Keychain" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum anahtarı Anahtar Zinciri'nden silinecektir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ключ сесії буде видалено зі зв'язки ключів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话密钥将从钥匙串中删除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段金鑰將從鑰匙圈中刪除。" + } + } + } + }, + "providers.accounts.colors.section.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ألوان شريط الاستخدام" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boje trake korištenja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farver på forbrugslinje" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farben der Nutzungsleiste" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage bar colors" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Colores de la barra de uso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleurs de la barre d'utilisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colori barra di utilizzo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用量バーの色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용량 바 색상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Farger på brukslinje" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolory paska użycia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cores da barra de uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвета полосы использования" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีแถบการใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım çubuğu renkleri" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Кольори смуги використання" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用量条颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用量列顏色" + } + } + } + }, + "providers.accounts.colors.low": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منخفض" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nisko" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lav" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Basso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lav" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niski" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Низкий" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ต่ำ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düşük" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Низький" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低" + } + } + } + }, + "providers.accounts.colors.mid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متوسط" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Srednje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mellem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mid" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Medio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moyen" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Medio" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Middels" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Średni" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Médio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Средний" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กลาง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orta" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Середній" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中" + } + } + } + }, + "providers.accounts.colors.high": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عالي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Visoko" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Høj" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hoch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "High" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Élevé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Alto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "높음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Høy" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysoki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высокий" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สูง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yüksek" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Високий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "高" + } + } + } + }, + "providers.accounts.colors.lowMidThreshold": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عتبة منخفض ← متوسط (%)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prag nisko → srednje (%)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lav → Mellem grænse (%)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrig → Mittel Schwelle (%)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low → Mid threshold (%)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Umbral bajo → medio (%)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuil bas → moyen (%)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Soglia basso → medio (%)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低→中の閾値(%)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮음 → 중간 임계값 (%)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lav → Middels terskel (%)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Próg niski → średni (%)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limiar baixo → médio (%)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порог низкий → средний (%)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เกณฑ์ต่ำ → กลาง (%)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düşük → Orta eşiği (%)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поріг низький → середній (%)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低→中阈值(%)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低→中閾值(%)" + } + } + } + }, + "providers.accounts.colors.midHighThreshold": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عتبة متوسط ← عالي (%)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prag srednje → visoko (%)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mellem → Høj grænse (%)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel → Hoch Schwelle (%)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mid → High threshold (%)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Umbral medio → alto (%)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuil moyen → élevé (%)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Soglia medio → alto (%)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中→高の閾値(%)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중간 → 높음 임계값 (%)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Middels → Høy terskel (%)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Próg średni → wysoki (%)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limiar médio → alto (%)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порог средний → высокий (%)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เกณฑ์กลาง → สูง (%)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orta → Yüksek eşiği (%)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поріг середній → високий (%)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中→高阈值(%)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中→高閾值(%)" + } + } + } + }, + "providers.accounts.colors.interpolate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تدرج بين الألوان" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Interpoliraj između boja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Interpolér mellem farver" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischen Farben interpolieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Interpolate between colors" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Interpolar entre colores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Interpoler entre les couleurs" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Interpola tra i colori" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "色間を補間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색상 간 보간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Interpoler mellom farger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Interpoluj między kolorami" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Interpolar entre cores" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интерполяция между цветами" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สอดแทรกระหว่างสี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renkler arasında ara değer" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Інтерполяція між кольорами" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在颜色之间插值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在顏色之間內插" + } + } + } + }, + "providers.accounts.colors.preview": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معاينة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pregled" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisning" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista previa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Anteprima" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレビュー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미리보기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podgląd" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pré-visualização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัวอย่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önizleme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Попередній перегляд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預覽" + } + } + } + }, + "providers.accounts.colors.reset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين إلى الافتراضي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vrati na zadano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil til standard" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Standard zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset to defaults" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer valores predeterminados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina predefiniti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトにリセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본값으로 재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill til standard" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przywróć domyślne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir padrões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить по умолчанию" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ตเป็นค่าเริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılanlara sıfırla" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скинути до стандартних" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置为默认值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置為預設值" + } + } + } + }, + "claude.accounts.edit.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعديل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uredi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Edytuj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редактировать" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แก้ไข" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯" + } + } + } + }, + "claude.accounts.remove.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "claude.accounts.remove.confirm.action": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "claude.accounts.error.decoding": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل فك تشفير بيانات الاعتماد من سلسلة المفاتيح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neuspješno dekodiranje akreditacija iz Keychaina." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke afkode kontooplysninger fra nøglering." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldedaten konnten nicht aus dem Schlüsselbund dekodiert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to decode account credentials from Keychain." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al decodificar las credenciales de la cuenta desde Llavero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du décodage des identifiants depuis le Trousseau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile decodificare le credenziali dell'account dal Portachiavi." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーチェーンからアカウント資格情報のデコードに失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키체인에서 계정 자격 증명을 디코딩하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke dekode kontoinformasjon fra nøkkelring." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zdekodować danych logowania z pęku kluczy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao decodificar credenciais da conta no Chaveiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось декодировать учётные данные из Связки ключей." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถถอดรหัสข้อมูลรับรองบัญชีจาก Keychain" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Anahtar Zinciri'nden hesap kimlik bilgileri çözümlenemedi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося декодувати облікові дані зі зв'язки ключів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法从钥匙串解码账户凭据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法從鑰匙圈解碼帳戶憑證。" + } + } + } + }, + "claude.accounts.error.invalidOrgIdDetail": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف المنظمة غير صالح. يجب ألا يكون فارغًا أو يحتوي على مسارات أو مسافات بيضاء." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nevažeći ID organizacije. Ne smije biti prazan niti sadržavati putanje ili razmake." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ugyldigt organisations-ID. Det må ikke være tomt eller indeholde stier eller mellemrum." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Organisations-ID. Darf nicht leer sein oder Pfade oder Leerzeichen enthalten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid organization ID. It must not be empty or contain paths or whitespace." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ID de organización no válido. No debe estar vacío ni contener rutas o espacios en blanco." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant d'organisation invalide. Il ne doit pas être vide ni contenir de chemins ou d'espaces." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "ID organizzazione non valido. Non deve essere vuoto né contenere percorsi o spazi." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な組織IDです。空であったり、パスや空白を含んではいけません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유효하지 않은 조직 ID입니다. 비어 있거나 경로 또는 공백을 포함할 수 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig organisasjons-ID. Kan ikke være tomt eller inneholde stier eller mellomrom." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieprawidłowe ID organizacji. Nie może być puste ani zawierać ścieżek lub spacji." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "ID da organização inválido. Não deve estar vazio nem conter caminhos ou espaços." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недействительный ID организации. Не должен быть пустым или содержать пути или пробелы." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ID องค์กรไม่ถูกต้อง ต้องไม่ว่างเปล่าหรือมีเส้นทางหรือช่องว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz kuruluş kimliği. Boş olmamalı, yol veya boşluk içermemelidir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Недійсний ID організації. Не повинен бути порожнім або містити шляхи чи пробіли." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效的组织 ID。不能为空或包含路径或空格。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效的組織 ID。不能為空或包含路徑或空白。" + } + } + } + }, + "claude.accounts.error.keychainStatus": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في سلسلة المفاتيح (OSStatus %lld). تحقق من وصول سلسلة المفاتيح في macOS." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška Keychaina (OSStatus %lld). Provjerite Keychain Access u macOS-u." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nøgleringsfejl (OSStatus %lld). Kontrollér macOS Nøglering." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüsselbund-Fehler (OSStatus %lld). Prüfen Sie den macOS-Schlüsselbund." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keychain error (OSStatus %lld). Check macOS Keychain Access app." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error del Llavero (OSStatus %lld). Compruebe Acceso a Llaveros en macOS." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur du Trousseau (OSStatus %lld). Vérifiez l'app Trousseau d'accès macOS." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore Portachiavi (OSStatus %lld). Controlla l'app Accesso Portachiavi di macOS." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーチェーンエラー(OSStatus %lld)。macOSのキーチェーンアクセスを確認してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키체인 오류 (OSStatus %lld). macOS 키체인 접근 앱을 확인하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nøkkelringfeil (OSStatus %lld). Sjekk macOS Nøkkelringtilgang." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd pęku kluczy (OSStatus %lld). Sprawdź Dostęp do pęku kluczy w macOS." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro do Chaveiro (OSStatus %lld). Verifique o app Acesso às Chaves do macOS." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка Связки ключей (OSStatus %lld). Проверьте приложение Связка ключей macOS." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาด Keychain (OSStatus %lld) ตรวจสอบแอป Keychain Access ของ macOS" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Anahtar Zinciri hatası (OSStatus %lld). macOS Anahtar Zinciri Erişimi uygulamasını kontrol edin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка зв'язки ключів (OSStatus %lld). Перевірте програму Зв'язка ключів macOS." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "钥匙串错误(OSStatus %lld)。请检查 macOS 钥匙串访问应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鑰匙圈錯誤(OSStatus %lld)。請檢查 macOS 鑰匙圈存取 App。" + } + } + } + }, + "claude.usage.error.decoding": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تحليل بيانات الاستخدام من واجهة Claude API." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neuspješno parsiranje podataka o korištenju iz Claude API-ja." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke analysere forbrugsdata fra Claude API." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nutzungsdaten von der Claude API konnten nicht geparst werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to parse usage data from Claude API." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al analizar los datos de uso de la API de Claude." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'analyse des données d'utilisation de l'API Claude." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile analizzare i dati di utilizzo dall'API Claude." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude APIからの使用状況データの解析に失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude API에서 사용량 데이터를 파싱하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke analysere bruksdata fra Claude API." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się przeanalizować danych użycia z Claude API." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao analisar dados de uso da API Claude." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось разобрать данные использования от Claude API." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถแยกวิเคราะห์ข้อมูลการใช้งานจาก Claude API" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude API'den kullanım verileri ayrıştırılamadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося розібрати дані використання від Claude API." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解析 Claude API 的使用数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解析 Claude API 的使用資料。" + } + } + } + }, + "claude.usage.error.http": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة Claude API أرجعت HTTP %lld." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Claude API vratio HTTP %lld." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude API returnerede HTTP %lld." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude API hat HTTP %lld zurückgegeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude API returned HTTP %lld." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La API de Claude devolvió HTTP %lld." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'API Claude a renvoyé HTTP %lld." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'API Claude ha restituito HTTP %lld." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude APIがHTTP %lldを返しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude API가 HTTP %lld를 반환했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude API returnerte HTTP %lld." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Claude API zwróciło HTTP %lld." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A API Claude retornou HTTP %lld." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Claude API вернул HTTP %lld." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Claude API ส่งกลับ HTTP %lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude API HTTP %lld döndürdü." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Claude API повернув HTTP %lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude API 返回 HTTP %lld。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude API 回傳 HTTP %lld。" + } + } + } + }, + "claude.usage.error.httpAuth": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مفتاح الجلسة منتهي الصلاحية أو غير صالح (HTTP %lld). يرجى تحديث بيانات الاعتماد." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ključ sesije je istekao ili nevažeći (HTTP %lld). Ažurirajte akreditacije." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sessionsnøgle udløbet eller ugyldig (HTTP %lld). Opdater venligst dine oplysninger." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzungsschlüssel abgelaufen oder ungültig (HTTP %lld). Bitte aktualisieren Sie Ihre Anmeldedaten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Session key expired or invalid (HTTP %lld). Please update your credentials." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Clave de sesión expirada o no válida (HTTP %lld). Actualice sus credenciales." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé de session expirée ou invalide (HTTP %lld). Veuillez mettre à jour vos identifiants." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiave di sessione scaduta o non valida (HTTP %lld). Aggiorna le tue credenziali." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションキーの有効期限切れまたは無効(HTTP %lld)。資格情報を更新してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션 키가 만료되었거나 유효하지 않습니다 (HTTP %lld). 자격 증명을 업데이트하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sesjonsnøkkel utløpt eller ugyldig (HTTP %lld). Oppdater legitimasjonen din." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Klucz sesji wygasł lub jest nieprawidłowy (HTTP %lld). Zaktualizuj dane logowania." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Chave de sessão expirada ou inválida (HTTP %lld). Atualize suas credenciais." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключ сессии истёк или недействителен (HTTP %lld). Обновите учётные данные." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คีย์เซสชันหมดอายุหรือไม่ถูกต้อง (HTTP %lld) กรุณาอัปเดตข้อมูลรับรอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum anahtarı süresi dolmuş veya geçersiz (HTTP %lld). Lütfen kimlik bilgilerinizi güncelleyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ключ сесії закінчився або недійсний (HTTP %lld). Оновіть облікові дані." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话密钥已过期或无效(HTTP %lld)。请更新您的凭据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段金鑰已過期或無效(HTTP %lld)。請更新您的憑證。" + } + } + } + }, + "claude.usage.error.invalidOrgId": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف منظمة غير صالح في بيانات اعتماد الحساب." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nevažeći ID organizacije u akreditacijama računa." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ugyldigt organisations-ID i kontooplysninger." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Organisations-ID in den Anmeldedaten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid organization ID in account credentials." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ID de organización no válido en las credenciales de la cuenta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant d'organisation invalide dans les identifiants du compte." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "ID organizzazione non valido nelle credenziali dell'account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント資格情報に無効な組織IDがあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 자격 증명에 유효하지 않은 조직 ID가 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig organisasjons-ID i kontoinformasjonen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieprawidłowe ID organizacji w danych logowania konta." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "ID da organização inválido nas credenciais da conta." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недействительный ID организации в учётных данных." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ID องค์กรไม่ถูกต้องในข้อมูลรับรองบัญชี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap kimlik bilgilerinde geçersiz kuruluş kimliği." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Недійсний ID організації в облікових даних." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户凭据中的组织 ID 无效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳戶憑證中的組織 ID 無效。" + } + } + } + }, + "claude.usage.error.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في الشبكة: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška mreže: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Netværksfejl: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerkfehler: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network error" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de red: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur réseau : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore di rete: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワークエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 오류: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettverksfeil: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd sieci: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro de rede: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка сети: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดเครือข่าย: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ağ hatası: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка мережі: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络错误:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路錯誤:%@" + } + } + } + }, + "claude.accounts.help.howToGetSessionKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How to get session key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションキーの取得方法" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كيفية الحصول على مفتاح الجلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kako dobiti ključ sesije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sådan får du sessionsnøglen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "So erhalten Sie den Sitzungsschlüssel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómo obtener la clave de sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comment obtenir la clé de session" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Come ottenere la chiave di sessione" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션 키 가져오는 방법" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slik får du sesjonsnøkkelen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak uzyskać klucz sesji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Como obter a chave de sessão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Как получить ключ сеанса" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วิธีรับคีย์เซสชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum anahtarı nasıl alınır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Як отримати ключ сеансу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如何获取会话密钥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如何取得工作階段金鑰" + } + } + } + }, + "codex.accounts.editor.accessToken": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رمز الوصول" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pristupni token" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgangstoken" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriffstoken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Access token" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Token de acceso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jeton d'accès" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Token di accesso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセストークン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "액세스 토큰" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilgangstoken" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Token dostępu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Token de acesso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Токен доступа" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทเค็นการเข้าถึง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Erişim jetonu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Токен доступу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "访问令牌" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存取權杖" + } + } + } + }, + "codex.accounts.editor.accessToken.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شغّل: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokrenite: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kør: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführen: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Run: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutar: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécuter : jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esegui: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kjør: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Execute: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выполните: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รัน: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalıştırın: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виконайте: jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行:jq -r .tokens.access_token < ~/.codex/auth.json" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行:jq -r .tokens.access_token < ~/.codex/auth.json" + } + } + } + }, + "codex.accounts.editor.accountId": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف الحساب (اختياري)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "ID računa (opcionalno)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Konto-ID (valgfrit)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto-ID (optional)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Account ID (optional)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ID de cuenta (opcional)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant de compte (facultatif)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "ID account (facoltativo)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントID(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 ID (선택사항)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Konto-ID (valgfritt)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "ID konta (opcjonalne)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "ID da conta (opcional)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ID аккаунта (необязательно)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ID บัญชี (ไม่บังคับ)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap kimliği (isteğe bağlı)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ID облікового запису (необов'язково)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户 ID(可选)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳戶 ID(選填)" + } + } + } + }, + "codex.accounts.editor.accountId.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شغّل: jq -r .tokens.account_id < ~/.codex/auth.json (اتركه فارغًا إذا كان فارغًا)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokrenite: jq -r .tokens.account_id < ~/.codex/auth.json (ostavite prazno ako nema)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kør: jq -r .tokens.account_id < ~/.codex/auth.json (lad være tomt hvis der ikke er noget)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführen: jq -r .tokens.account_id < ~/.codex/auth.json (leer lassen, wenn nicht vorhanden)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Run: jq -r .tokens.account_id < ~/.codex/auth.json (leave blank if empty)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutar: jq -r .tokens.account_id < ~/.codex/auth.json (dejar en blanco si está vacío)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécuter : jq -r .tokens.account_id < ~/.codex/auth.json (laisser vide si absent)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esegui: jq -r .tokens.account_id < ~/.codex/auth.json (lasciare vuoto se assente)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行: jq -r .tokens.account_id < ~/.codex/auth.json(空の場合は空欄のまま)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행: jq -r .tokens.account_id < ~/.codex/auth.json (비어 있으면 공란으로 두세요)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kjør: jq -r .tokens.account_id < ~/.codex/auth.json (la stå tomt hvis det er tomt)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom: jq -r .tokens.account_id < ~/.codex/auth.json (zostaw puste, jeśli brak)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Execute: jq -r .tokens.account_id < ~/.codex/auth.json (deixe em branco se vazio)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выполните: jq -r .tokens.account_id < ~/.codex/auth.json (оставьте пустым, если отсутствует)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รัน: jq -r .tokens.account_id < ~/.codex/auth.json (เว้นว่างหากไม่มี)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalıştırın: jq -r .tokens.account_id < ~/.codex/auth.json (boşsa boş bırakın)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виконайте: jq -r .tokens.account_id < ~/.codex/auth.json (залиште порожнім, якщо відсутній)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行:jq -r .tokens.account_id < ~/.codex/auth.json(为空则留空)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行:jq -r .tokens.account_id < ~/.codex/auth.json(若為空則留空)" + } + } + } + }, + "codex.accounts.status.section": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حالة Codex" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex status" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex-status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex-Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estado de Codex" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "État de Codex" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stato di Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexステータス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex 상태" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex-status" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Status Codex" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Status do Codex" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус Codex" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สถานะ Codex" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex durumu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Статус Codex" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex 状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex 狀態" + } + } + } + }, + "codex.usage.error.invalidCredentials": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رمز وصول Codex مفقود أو فارغ." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex pristupni token nedostaje ili je prazan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex-adgangstoken mangler eller er tomt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex-Zugriffstoken fehlt oder ist leer." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex access token is missing or empty." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El token de acceso de Codex falta o está vacío." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le jeton d'accès Codex est manquant ou vide." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il token di accesso Codex è mancante o vuoto." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexアクセストークンが見つからないか空です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex 액세스 토큰이 없거나 비어 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex-tilgangstoken mangler eller er tomt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Token dostępu Codex jest pusty lub brakuje go." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O token de acesso do Codex está ausente ou vazio." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Токен доступа Codex отсутствует или пуст." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทเค็นการเข้าถึง Codex หายไปหรือว่างเปล่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex erişim jetonu eksik veya boş." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Токен доступу Codex відсутній або порожній." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex 访问令牌缺失或为空。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex 存取權杖遺失或為空。" + } + } + } + }, + "codex.usage.error.httpAuth": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رمز Codex منتهي الصلاحية أو غير صالح (HTTP %lld). أعد الحصول على tokens.access_token من ~/.codex/auth.json." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex token je istekao ili nevažeći (HTTP %lld). Ponovo preuzmite tokens.access_token iz ~/.codex/auth.json." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex-token udløbet eller ugyldigt (HTTP %lld). Hent tokens.access_token fra ~/.codex/auth.json igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex-Token abgelaufen oder ungültig (HTTP %lld). Holen Sie tokens.access_token aus ~/.codex/auth.json erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex token expired or invalid (HTTP %lld). Re-grab tokens.access_token from ~/.codex/auth.json." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Token de Codex expirado o no válido (HTTP %lld). Vuelva a obtener tokens.access_token de ~/.codex/auth.json." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jeton Codex expiré ou invalide (HTTP %lld). Récupérez à nouveau tokens.access_token depuis ~/.codex/auth.json." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Token Codex scaduto o non valido (HTTP %lld). Recupera di nuovo tokens.access_token da ~/.codex/auth.json." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexトークンの有効期限切れまたは無効(HTTP %lld)。~/.codex/auth.jsonからtokens.access_tokenを再取得してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex 토큰이 만료되었거나 유효하지 않습니다 (HTTP %lld). ~/.codex/auth.json에서 tokens.access_token을 다시 가져오세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex-token utløpt eller ugyldig (HTTP %lld). Hent tokens.access_token fra ~/.codex/auth.json på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Token Codex wygasł lub jest nieprawidłowy (HTTP %lld). Pobierz ponownie tokens.access_token z ~/.codex/auth.json." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Token do Codex expirado ou inválido (HTTP %lld). Obtenha novamente tokens.access_token de ~/.codex/auth.json." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Токен Codex истёк или недействителен (HTTP %lld). Повторно получите tokens.access_token из ~/.codex/auth.json." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทเค็น Codex หมดอายุหรือไม่ถูกต้อง (HTTP %lld) รับ tokens.access_token จาก ~/.codex/auth.json อีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex jetonu süresi dolmuş veya geçersiz (HTTP %lld). ~/.codex/auth.json dosyasından tokens.access_token değerini tekrar alın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Токен Codex закінчився або недійсний (HTTP %lld). Повторно отримайте tokens.access_token з ~/.codex/auth.json." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex 令牌已过期或无效(HTTP %lld)。请从 ~/.codex/auth.json 重新获取 tokens.access_token。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex 權杖已過期或無效(HTTP %lld)。請從 ~/.codex/auth.json 重新取得 tokens.access_token。" + } + } + } + }, + "codex.usage.error.http404": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حساب Codex غير موجود. تحقق من chatgpt-account-id." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex račun nije pronađen. Provjerite chatgpt-account-id." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex-konto ikke fundet. Kontrollér chatgpt-account-id." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex-Konto nicht gefunden. Überprüfen Sie die chatgpt-account-id." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex account not found. Verify chatgpt-account-id." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuenta de Codex no encontrada. Verifique chatgpt-account-id." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compte Codex introuvable. Vérifiez chatgpt-account-id." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Account Codex non trovato. Verifica chatgpt-account-id." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexアカウントが見つかりません。chatgpt-account-idを確認してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex 계정을 찾을 수 없습니다. chatgpt-account-id를 확인하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex-konto ikke funnet. Kontroller chatgpt-account-id." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono konta Codex. Sprawdź chatgpt-account-id." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conta Codex não encontrada. Verifique o chatgpt-account-id." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аккаунт Codex не найден. Проверьте chatgpt-account-id." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบบัญชี Codex ตรวจสอบ chatgpt-account-id" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex hesabı bulunamadı. chatgpt-account-id değerini doğrulayın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Обліковий запис Codex не знайдено. Перевірте chatgpt-account-id." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到 Codex 账户。请验证 chatgpt-account-id。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到 Codex 帳戶。請驗證 chatgpt-account-id。" + } + } + } + }, + "codex.usage.error.http": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة Codex API أرجعت HTTP %lld." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex API vratio HTTP %lld." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex API returnerede HTTP %lld." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex API hat HTTP %lld zurückgegeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex API returned HTTP %lld." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La API de Codex devolvió HTTP %lld." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'API Codex a renvoyé HTTP %lld." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'API Codex ha restituito HTTP %lld." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex APIがHTTP %lldを返しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex API가 HTTP %lld를 반환했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex API returnerte HTTP %lld." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Codex API zwróciło HTTP %lld." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A API do Codex retornou HTTP %lld." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Codex API вернул HTTP %lld." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Codex API ส่งกลับ HTTP %lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex API HTTP %lld döndürdü." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Codex API повернув HTTP %lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex API 返回 HTTP %lld。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex API 回傳 HTTP %lld。" + } + } + } + }, + "codex.usage.error.decoding": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تحليل استجابة rate_limit من Codex." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije uspjelo parsiranje Codex rate_limit odgovora." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke analysere Codex rate_limit-svar." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex rate_limit-Antwort konnte nicht geparst werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to parse Codex rate_limit response." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al analizar la respuesta rate_limit de Codex." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'analyse de la réponse rate_limit de Codex." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile analizzare la risposta rate_limit di Codex." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex rate_limitレスポンスの解析に失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex rate_limit 응답을 파싱하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke analysere Codex rate_limit-svar." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się przeanalizować odpowiedzi rate_limit z Codex." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao analisar a resposta rate_limit do Codex." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось разобрать ответ rate_limit от Codex." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถแยกวิเคราะห์การตอบกลับ rate_limit ของ Codex" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex rate_limit yanıtı ayrıştırılamadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося розібрати відповідь rate_limit від Codex." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解析 Codex rate_limit 响应。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解析 Codex rate_limit 回應。" + } + } + } + }, + "codex.usage.error.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في الشبكة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška mreže" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Netværksfejl" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerkfehler" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network error" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de red" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur réseau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore di rete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワークエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 오류" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettverksfeil" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd sieci" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro de rede" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка сети" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดเครือข่าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ağ hatası" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка мережі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路錯誤" + } + } + } + }, + "providers.accounts.countdown.days": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldd" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld日" + } + } + } + }, + "providers.accounts.countdown.hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld時間" + } + } + } + }, + "providers.accounts.countdown.hoursMinutesSpaced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lldh %2$02lldm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld時間 %2$02lld分" + } + } + } + }, + "providers.accounts.countdown.daysHoursSpaced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lldd %2$02lldh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld日 %2$02lld時間" + } + } + } + }, + "providers.accounts.countdown.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "—" + } + } + } + }, + "providers.accounts.countdown.minutes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld分" + } + } + } + }, + "providers.accounts.countdown.lessThanMinute": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "<1m" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1分未満" + } + } + } + }, + "providers.accounts.countdown.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ (in %2$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@(あと %2$@)" + } + } + } + }, + "providers.accounts.countdown.verbose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ (%2$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@(%2$@)" + } + } + } + }, + "providers.accounts.incidents.truncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "…" + } + } + } + }, + "providers.accounts.usage.percent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + } + } + }, + "providers.accounts.usage.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ %2$@" + } + } + } + }, + "providers.accounts.usage.summaryWithReset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · resets %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@にリセット" + } + } + } + }, + "providers.accounts.usage.summaryNotStarted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · not started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · 未開始" + } + } + } + }, + "providers.accounts.usage.awaitingRefresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Awaiting refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新待ち" + } + } + } + }, + "providers.accounts.popover.fetchedAt.withTime": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新: %@" + } + } + } + }, + "providers.accounts.popover.resets.withTime": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット: %@" + } + } + } + }, + "providers.accounts.settings.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session %1$lld%% · Week %2$lld%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション %1$lld%% · 週 %2$lld%%" + } + } + } + }, + "providers.accounts.settings.unknownProvider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "UNKNOWN PROVIDER" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なプロバイダー" + } + } + } + }, + "providers.accounts.settings.unknownProvider.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダー: %@" + } + } + } + }, + "providers.accounts.error.fetchFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not refresh usage: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用状況を更新できませんでした: %@" + } + } + } + }, + "providers.accounts.error.timeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider fetch timed out after %lld s." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダーの取得が %lld 秒でタイムアウトしました。" + } + } + } + }, + "codex.usage.error.invalidAccessToken": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex access token is missing or malformed. Copy tokens.access_token from ~/.codex/auth.json." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexのアクセストークンが不足または不正です。~/.codex/auth.jsonからtokens.access_tokenをコピーしてください。" + } + } + } + }, + "codex.usage.error.invalidAccountId": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex account ID contains invalid characters. Copy tokens.account_id from ~/.codex/auth.json, or leave it blank." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CodexのアカウントIDに無効な文字が含まれています。~/.codex/auth.jsonからtokens.account_idをコピーするか、空のままにしてください。" + } + } + } + }, + "claude.usage.error.invalidSessionKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session key contains invalid characters. Paste the cookie value only, without surrounding \"sessionKey=\" or extra attributes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションキーに無効な文字が含まれています。「sessionKey=」や追加属性なしで、Cookieの値のみを貼り付けてください。" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7e346a71f1..8c43d01894 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2646,6 +2646,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installShortcutMonitor() installShortcutDefaultsObserver() SystemWideHotkeyController.shared.start() + if !isRunningUnderXCTest { + _ = ProviderAccountStore.shared + ProviderAccountsController.shared.start() + } NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -3006,6 +3010,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if TelemetrySettings.enabledForCurrentLaunch { PostHogAnalytics.shared.flush() } + ProviderAccountsController.shared.stop() notificationStore?.clearAll() enableSuddenTerminationIfNeeded() } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e525929..5d1b5a027b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11239,12 +11239,22 @@ private struct SidebarFooter: View { var body: some View { #if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + VStack(alignment: .leading, spacing: 4) { + ProviderAccountsFooterPanel() + .padding(.leading, 6) + .padding(.trailing, 10) + SidebarDevFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + } #else - SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) + VStack(alignment: .leading, spacing: 4) { + ProviderAccountsFooterPanel() + .padding(.leading, 6) + .padding(.trailing, 10) + SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } #endif } } diff --git a/Sources/Providers/ClaudeProvider.swift b/Sources/Providers/ClaudeProvider.swift new file mode 100644 index 0000000000..8854d4af6a --- /dev/null +++ b/Sources/Providers/ClaudeProvider.swift @@ -0,0 +1,90 @@ +import Foundation + +// MARK: - Claude Provider Definition + +extension Providers { + static let claude: UsageProvider = UsageProvider( + id: "claude", + displayName: "Claude", + keychainService: "com.cmuxterm.app.claude-accounts", + credentialFields: [ + CredentialField( + id: "sessionKey", + label: String(localized: "claude.accounts.editor.sessionKey", defaultValue: "Session key"), + placeholder: String(localized: "claude.accounts.editor.sessionKey.placeholder", defaultValue: "sk-ant-sid01-…"), + isSecret: true, + helpText: String(localized: "claude.accounts.editor.sessionKey.help", defaultValue: "From claude.ai cookies"), + validate: ProviderClaudeValidators.isValidSessionKey + ), + CredentialField( + id: "orgId", + label: String(localized: "claude.accounts.editor.orgId", defaultValue: "Organization ID"), + placeholder: String(localized: "claude.accounts.editor.orgId.placeholder", defaultValue: "UUID"), + isSecret: false, + helpText: String(localized: "claude.accounts.editor.orgId.help", defaultValue: "From claude.ai network requests"), + validate: ProviderClaudeValidators.isValidOrgId + ), + ], + statusPageURL: URL(string: "https://status.claude.com/"), + statusSectionTitle: String(localized: "claude.accounts.status.section", defaultValue: "Claude.ai status"), + helpDocURL: URL(string: "https://github.com/manaflow-ai/cmux/blob/main/docs/usage-monitoring-setup.md#claude"), + fetchUsage: { secret in + try await ClaudeUsageFetcher.fetch(secret: secret) + }, + fetchStatus: { + try await StatuspageIOFetcher.fetch( + host: "status.claude.com", + componentFilter: ["claude.ai", "Claude API (api.anthropic.com)", "Claude Code"] + ) + } + ) +} + +// MARK: - Claude Validators + +enum ProviderClaudeValidators { + private static let segmentReserved = CharacterSet(charactersIn: "/:@;=?#") + + static func isValidOrgId(_ orgId: String) -> Bool { + let trimmed = orgId.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty + && !trimmed.contains("..") + && trimmed.rangeOfCharacter(from: segmentReserved) == nil + // Embedded whitespace percent-encodes to `%20` and produces a + // different (missing) organization path. + && trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + } + + /// Rejects whitespace-only input and any character that would corrupt the + /// `Cookie` header (attribute separators or control bytes). A leading + /// `sessionKey=` prefix is tolerated — it is stripped at fetch time — so + /// the editor accepts a pasted cookie value verbatim. + static func isValidSessionKey(_ sessionKey: String) -> Bool { + let body = strippedSessionKey(sessionKey) + guard !body.isEmpty else { return false } + var disallowed = CharacterSet.controlCharacters + // `;` and `,` are the separators that would split the `Cookie` + // header and let a paste smuggle extra directives. `=` is left in + // because cookie values (e.g. base64-padded tokens) legitimately + // contain it. + disallowed.insert(charactersIn: ";,\n\r") + // Embedded whitespace in a cookie value is not a valid sessionKey and + // the server would reject it outright; catch it here so the editor + // shows the right guidance instead of surfacing a fetch failure. + disallowed.formUnion(.whitespacesAndNewlines) + return body.rangeOfCharacter(from: disallowed) == nil + } + + /// Trims whitespace and removes every leading `sessionKey=` prefix. + /// Loop-stripping keeps the editor validator and the fetcher's `Cookie` + /// header in lockstep even when a paste like `sessionKey=sessionKey=abc` + /// carries the prefix twice. + static func strippedSessionKey(_ sessionKey: String) -> String { + var trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "sessionKey=" + while trimmed.hasPrefix(prefix) { + trimmed = String(trimmed.dropFirst(prefix.count)) + } + return trimmed + } +} diff --git a/Sources/Providers/ClaudeUsageFetcher.swift b/Sources/Providers/ClaudeUsageFetcher.swift new file mode 100644 index 0000000000..c6f5934a3b --- /dev/null +++ b/Sources/Providers/ClaudeUsageFetcher.swift @@ -0,0 +1,135 @@ +import Foundation + +// MARK: - Errors + +enum ClaudeUsageFetchError: Error, LocalizedError { + case invalidOrgId + case invalidSessionKey + case http(Int) + case badResponse + case decoding + case network(Error) + + var errorDescription: String? { + switch self { + case .invalidOrgId: + return String(localized: "claude.usage.error.invalidOrgId", defaultValue: "Invalid organization ID in account credentials.") + case .invalidSessionKey: + return String(localized: "claude.usage.error.invalidSessionKey", defaultValue: "Session key contains invalid characters. Paste the cookie value only, without surrounding \"sessionKey=\" or extra attributes.") + case .http(let code): + if code == 401 || code == 403 { + return String(localized: "claude.usage.error.httpAuth", defaultValue: "Session key expired or invalid (HTTP \(code)). Please update your credentials.") + } + return String(localized: "claude.usage.error.http", defaultValue: "Claude API returned HTTP \(code).") + case .badResponse: + return String(localized: "claude.usage.error.badResponse", defaultValue: "Claude API returned an invalid response.") + case .decoding: + return String(localized: "claude.usage.error.decoding", defaultValue: "Failed to parse usage data from Claude API.") + case .network: + return String(localized: "claude.usage.error.network", defaultValue: "Network error") + } + } +} + +// MARK: - Fetcher + +enum ClaudeUsageFetcher { + + private static let session = ProviderHTTP.makeSession(timeout: 10) + + static func fetch(secret: ProviderSecret) async throws -> ProviderUsageWindows { + let orgId = (secret.fields["orgId"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + // Re-run the editor validator on the stored value so a persisted secret + // can't smuggle traversal characters past percent-encoding and build a + // URL like `/api/organizations/../usage`. + guard ProviderClaudeValidators.isValidOrgId(orgId) else { + throw ClaudeUsageFetchError.invalidOrgId + } + + // Canonicalize the cookie value the same way the editor validator + // does, then reject anything that would corrupt the `Cookie` header + // (attribute separators, control bytes) or imply a second cookie was + // pasted (a stray `=`). + let sessionKey = ProviderClaudeValidators.strippedSessionKey(secret.fields["sessionKey"] ?? "") + guard ProviderClaudeValidators.isValidSessionKey(sessionKey) else { + throw ClaudeUsageFetchError.invalidSessionKey + } + + var segmentAllowed = CharacterSet.alphanumerics + segmentAllowed.insert(charactersIn: "-._~") + guard let encodedOrgId = orgId.addingPercentEncoding(withAllowedCharacters: segmentAllowed), + let url = URL(string: "https://claude.ai/api/organizations/\(encodedOrgId)/usage") else { + throw ClaudeUsageFetchError.invalidOrgId + } + + let json: [String: Any] + do { + json = try await ProviderHTTP.getJSONObject( + url: url, + headers: ["Cookie": "sessionKey=\(sessionKey)"], + session: session + ) + } catch is CancellationError { + throw CancellationError() + } catch ProviderHTTPError.http(let status) { + throw ClaudeUsageFetchError.http(status) + } catch ProviderHTTPError.badResponse { + throw ClaudeUsageFetchError.badResponse + } catch ProviderHTTPError.network(let underlying) { + NSLog("ClaudeUsageFetcher network error: %@", underlying.localizedDescription) + throw ClaudeUsageFetchError.network(underlying) + } catch { + throw ClaudeUsageFetchError.decoding + } + + guard let fiveHour = json["five_hour"] as? [String: Any], + let fiveHourUtil = intUtilization(fiveHour["utilization"]) else { + throw ClaudeUsageFetchError.decoding + } + + let fiveHourResetsAt = ProviderISO8601DateParser.parse(fiveHour["resets_at"] as? String) + + // Distinguish "key absent" from "key present but malformed". A present + // `seven_day` entry must be an object with a well-formed `utilization`; + // a wrong-type payload should raise a decoding error rather than fall + // through to 0% and hide an API schema change. Absence still reports + // 0% for the week window. + let sevenDay: [String: Any]? + let sevenDayUtil: Int + if let sevenDayRaw = json["seven_day"] { + guard let dict = sevenDayRaw as? [String: Any], + let value = intUtilization(dict["utilization"]) else { + throw ClaudeUsageFetchError.decoding + } + sevenDay = dict + sevenDayUtil = value + } else { + sevenDay = nil + sevenDayUtil = 0 + } + let sevenDayResetsAt = ProviderISO8601DateParser.parse(sevenDay?["resets_at"] as? String) + + let sessionWindow = ProviderUsageWindow(utilization: fiveHourUtil, resetsAt: fiveHourResetsAt, windowSeconds: 18000) + let weekWindow = ProviderUsageWindow(utilization: sevenDayUtil, resetsAt: sevenDayResetsAt, windowSeconds: 604800) + + return ProviderUsageWindows(session: sessionWindow, week: weekWindow) + } + + /// Accepts an integer `utilization` value and clamps it to `0...100`. + /// Rejects `nil`, non-numeric types, and JSON `true`/`false` — the latter + /// would otherwise bridge to `NSNumber` and read out as `0` or `1`, + /// silently turning a schema regression into a fake percentage. + private static func intUtilization(_ value: Any?) -> Int? { + guard let number = value as? NSNumber else { return nil } + if CFGetTypeID(number as CFTypeRef) == CFBooleanGetTypeID() { + return nil + } + // Fractional value means schema mismatch; reject rather than truncate + if number.doubleValue != Double(number.intValue) { + return nil + } + return min(max(number.intValue, 0), 100) + } +} + diff --git a/Sources/Providers/CodexProvider.swift b/Sources/Providers/CodexProvider.swift new file mode 100644 index 0000000000..169c445465 --- /dev/null +++ b/Sources/Providers/CodexProvider.swift @@ -0,0 +1,89 @@ +import Foundation + +// MARK: - Codex Validators + +enum CodexValidators { + /// JWT access tokens are 3 dot-separated base64url segments and start with `eyJ` + /// (the base64 of the literal `{"`). Reject obviously-wrong values without + /// trying to fully validate JWT structure or signature. + static func isValidAccessToken(_ token: String) -> Bool { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("eyJ") else { return false } + let illegal = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + if trimmed.unicodeScalars.contains(where: { illegal.contains($0) }) { + return false + } + // Keep empty subsequences so consecutive or leading/trailing dots + // surface as a bad token ("eyJ..abc.def" must not pass as 3 segments). + let segments = trimmed.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count == 3 && segments.allSatisfy({ !$0.isEmpty }) else { return false } + let base64urlAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-=") + for segment in segments { + if segment.unicodeScalars.contains(where: { !base64urlAllowed.contains($0) }) { + return false + } + } + return true + } + + /// `account_id` is an opaque string in `auth.json`: either empty (the + /// header is optional) or a value safe to ship in an HTTP header. The + /// literal string `null` is rejected because `jq -r .tokens.account_id` + /// emits that sentinel when the field is unset; persisting it would send + /// `chatgpt-account-id: null` and break otherwise-valid configurations. + static func isValidAccountId(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + if trimmed == "null" { return false } + // Mirror the HTTP-layer sanitizer so a saved credential and the value + // actually placed on the wire always agree. + var disallowed = CharacterSet.controlCharacters + disallowed.insert(charactersIn: ";,") + disallowed.formUnion(.whitespacesAndNewlines) + return trimmed.rangeOfCharacter(from: disallowed) == nil + } +} + +// MARK: - Codex Provider + +extension Providers { + static let codex: UsageProvider = UsageProvider( + id: "codex", + displayName: "Codex", + keychainService: "com.cmuxterm.app.codex-accounts", + credentialFields: [ + CredentialField( + id: "accessToken", + label: String(localized: "codex.accounts.editor.accessToken", defaultValue: "Access token"), + placeholder: String(localized: "codex.accounts.editor.accessToken.placeholder", defaultValue: "eyJhbGciOi…"), + isSecret: true, + helpText: String( + localized: "codex.accounts.editor.accessToken.help", + defaultValue: "Run: jq -r .tokens.access_token < ~/.codex/auth.json" + ), + validate: CodexValidators.isValidAccessToken + ), + CredentialField( + id: "accountId", + label: String(localized: "codex.accounts.editor.accountId", defaultValue: "Account ID (optional)"), + placeholder: String(localized: "codex.accounts.editor.accountId.placeholder", defaultValue: "abcd-1234-…"), + isSecret: false, + helpText: String( + localized: "codex.accounts.editor.accountId.help", + defaultValue: "Run: jq -r .tokens.account_id < ~/.codex/auth.json (leave blank if empty)" + ), + validate: CodexValidators.isValidAccountId + ), + ], + statusPageURL: URL(string: "https://status.openai.com/"), + statusSectionTitle: String(localized: "codex.accounts.status.section", defaultValue: "Codex status"), + helpDocURL: URL(string: "https://github.com/manaflow-ai/cmux/blob/main/docs/usage-monitoring-setup.md#codex"), + fetchUsage: CodexUsageFetcher.fetch, + fetchStatus: { + try await StatuspageIOFetcher.fetch( + host: "status.openai.com", + componentFilter: ["Codex Web", "Codex API", "CLI", "VS Code extension", "App"] + ) + } + ) +} diff --git a/Sources/Providers/CodexUsageFetcher.swift b/Sources/Providers/CodexUsageFetcher.swift new file mode 100644 index 0000000000..29982f1a9a --- /dev/null +++ b/Sources/Providers/CodexUsageFetcher.swift @@ -0,0 +1,161 @@ +import Foundation + +// MARK: - Errors + +enum CodexUsageFetchError: Error, LocalizedError { + case invalidAccessToken + case invalidAccountId + case http(Int) + case badResponse + case decoding + case network(Error) + + var errorDescription: String? { + switch self { + case .invalidAccessToken: + return String(localized: "codex.usage.error.invalidAccessToken", defaultValue: "Codex access token is missing or malformed. Copy tokens.access_token from ~/.codex/auth.json.") + case .invalidAccountId: + return String(localized: "codex.usage.error.invalidAccountId", defaultValue: "Codex account ID contains invalid characters. Copy tokens.account_id from ~/.codex/auth.json, or leave it blank.") + case .http(let code): + if code == 401 || code == 403 { + return String(localized: "codex.usage.error.httpAuth", defaultValue: "Codex token expired or invalid (HTTP \(code)). Re-grab tokens.access_token from ~/.codex/auth.json.") + } + if code == 404 { + return String(localized: "codex.usage.error.http404", defaultValue: "Codex account not found. Verify chatgpt-account-id.") + } + return String(localized: "codex.usage.error.http", defaultValue: "Codex API returned HTTP \(code).") + case .badResponse: + return String(localized: "codex.usage.error.badResponse", defaultValue: "Codex API returned an invalid response.") + case .decoding: + return String(localized: "codex.usage.error.decoding", defaultValue: "Failed to parse Codex rate_limit response.") + case .network: + return String(localized: "codex.usage.error.network", defaultValue: "Network error") + } + } +} + +// MARK: - Fetcher + +enum CodexUsageFetcher { + + private static let session = ProviderHTTP.makeSession(timeout: 10) + + static func fetch(secret: ProviderSecret) async throws -> ProviderUsageWindows { + // Re-run the editor validators against the stored value so a persisted + // or manually edited secret can't slip past the UI and produce + // malformed `Authorization` / `chatgpt-account-id` headers. + let accessToken = (secret.fields["accessToken"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard CodexValidators.isValidAccessToken(accessToken) else { + throw CodexUsageFetchError.invalidAccessToken + } + let accountId = (secret.fields["accountId"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard CodexValidators.isValidAccountId(accountId) else { + throw CodexUsageFetchError.invalidAccountId + } + + guard let url = URL(string: "https://chatgpt.com/backend-api/wham/usage") else { + throw CodexUsageFetchError.badResponse + } + + var headers = ["Authorization": "Bearer \(accessToken)"] + if !accountId.isEmpty { + headers["chatgpt-account-id"] = accountId + } + + let root: [String: Any] + do { + root = try await ProviderHTTP.getJSONObject( + url: url, + headers: headers, + session: session + ) + } catch is CancellationError { + throw CancellationError() + } catch ProviderHTTPError.http(let status) { + throw CodexUsageFetchError.http(status) + } catch ProviderHTTPError.badResponse { + throw CodexUsageFetchError.badResponse + } catch ProviderHTTPError.network(let underlying) { + NSLog("CodexUsageFetcher network error: %@", String(describing: underlying)) + throw CodexUsageFetchError.network(underlying) + } catch { + throw CodexUsageFetchError.decoding + } + guard let limits = root["rate_limit"] as? [String: Any] else { + throw CodexUsageFetchError.decoding + } + + func parseWindow(_ key: String) throws -> ProviderUsageWindow { + guard let dict = limits[key] as? [String: Any], + let usedPercent = Self.doubleValue(dict["used_percent"]) else { + throw CodexUsageFetchError.decoding + } + guard let windowSeconds = Self.doubleValue(dict["limit_window_seconds"]), + windowSeconds > 0 else { + // A zero or negative window breaks pacing math; treat it as + // backend contract drift rather than usable data. + throw CodexUsageFetchError.decoding + } + let rawResetAfterSeconds = dict["reset_after_seconds"] + let resetsInSeconds: Double? + if rawResetAfterSeconds == nil || rawResetAfterSeconds is NSNull { + resetsInSeconds = nil + } else if let parsed = Self.doubleValue(rawResetAfterSeconds) { + // Negative `reset_after_seconds` would silently look like "no + // reset scheduled"; require a non-negative value instead. + guard parsed >= 0 else { + throw CodexUsageFetchError.decoding + } + resetsInSeconds = parsed + } else { + throw CodexUsageFetchError.decoding + } + let resetsAt = (resetsInSeconds ?? 0) > 0 + ? Date(timeIntervalSinceNow: resetsInSeconds!) + : nil + // Clamp as Double before narrowing: `Int(_:)` traps when the + // source is outside `Int`'s range, so a wild value from the API + // would crash before the min/max bounds could kick in. + let clamped = min(max(usedPercent.rounded(), 0), 100) + let utilization = Int(clamped) + return ProviderUsageWindow( + utilization: utilization, + resetsAt: resetsAt, + windowSeconds: windowSeconds + ) + } + + let sessionWindow = try parseWindow("primary_window") + let weekWindow = try parseWindow("secondary_window") + return ProviderUsageWindows(session: sessionWindow, week: weekWindow) + } + + /// Accepts JSON numbers or stringified numbers. The Codex API has been + /// observed to hand back `limit_window_seconds` / `reset_after_seconds` + /// as either a number or a decimal string; a silent `as? NSNumber` + /// fall-through would mask a real format drift as "reset time unknown." + /// + /// `NSNumber` also bridges Swift `Bool`, so a JSON `true`/`false` would + /// otherwise coerce to `1.0`/`0.0`. We explicitly reject booleans so a + /// field that unexpectedly comes back as a bool raises a decoding error + /// instead of quietly producing `0` or `1`. + private static func doubleValue(_ value: Any?) -> Double? { + let raw: Double + if let number = value as? NSNumber { + // NSNumber and CFBoolean share a toll-free bridge; check the + // CoreFoundation type ID to reject a bridged Bool reliably. + if CFGetTypeID(number as CFTypeRef) == CFBooleanGetTypeID() { + return nil + } + raw = number.doubleValue + } else if let string = value as? String, let parsed = Double(string) { + raw = parsed + } else { + return nil + } + // `Double("nan")` and `Double("inf")` parse successfully and NSNumber + // can also carry non-finite values; reject them so downstream integer + // conversion and percentage clamping get a usable number. + return raw.isFinite ? raw : nil + } +} diff --git a/Sources/Providers/ProviderAccount.swift b/Sources/Providers/ProviderAccount.swift new file mode 100644 index 0000000000..19a22e9493 --- /dev/null +++ b/Sources/Providers/ProviderAccount.swift @@ -0,0 +1,66 @@ +import Foundation + +// MARK: - Account + +struct ProviderAccount: Identifiable, Equatable, Codable { + let id: UUID + let providerId: String // "claude" | "codex" | ... + var displayName: String + /// The keychain service name captured at account creation. Persisted so + /// removal and secret lookups still target the correct keychain slot even + /// if the provider definition is later renamed or stops shipping in the + /// registry. `nil` for accounts written by earlier builds — those fall + /// back to the current `keychainServiceResolver` lookup. + var keychainService: String? +} + +// MARK: - Secret + +struct ProviderSecret: Codable, Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + let fields: [String: String] // dynamic credential payload + + /// The secret intentionally elides every field value so that + /// accidentally printing / logging / Sentry-breadcrumbing a + /// `ProviderSecret` (including via string interpolation, dictionary + /// dumps, or Xcode quick-look) never leaks a live session token. + /// The defense is cheap and catches a whole class of future mistakes. + var description: String { + let keys = fields.keys.sorted().joined(separator: ", ") + return "ProviderSecret(fields: [\(keys)] = )" + } + + var debugDescription: String { description } + + /// Reflection APIs (`dump`, Xcode quick-look, swift-playgrounds) walk the + /// stored property tree independently of `description`. Overriding the + /// mirror keeps raw credential strings from appearing in debugger dumps + /// even when a caller bypasses `CustomStringConvertible`. + var customMirror: Mirror { + Mirror( + self, + children: [ + "fields": fields.keys.sorted().map { "\($0): " } + ], + displayStyle: .struct + ) + } +} + +// MARK: - Store Errors + +enum ProviderAccountStoreError: Error, LocalizedError { + case keychain(OSStatus) + case decoding + case notFound + + var errorDescription: String? { + switch self { + case .keychain(let status): + return String(localized: "providers.accounts.error.keychainStatus", defaultValue: "Keychain error (OSStatus \(status)). Check macOS Keychain Access permissions.") + case .decoding: + return String(localized: "providers.accounts.error.decoding", defaultValue: "Failed to decode account credentials from Keychain.") + case .notFound: + return String(localized: "providers.accounts.error.notFound", defaultValue: "Account not found.") + } + } +} diff --git a/Sources/Providers/ProviderAccountStore.swift b/Sources/Providers/ProviderAccountStore.swift new file mode 100644 index 0000000000..9444b90aa6 --- /dev/null +++ b/Sources/Providers/ProviderAccountStore.swift @@ -0,0 +1,380 @@ +import Foundation +import Security + +@MainActor +final class ProviderAccountStore: ObservableObject { + static let shared = ProviderAccountStore() + + @Published private(set) var accounts: [ProviderAccount] = [] + + private static let defaultIndexKey = "cmux.providers.accounts.index" + + private let userDefaults: UserDefaults + private let indexKey: String + + /// Resolves the keychain service name for a given providerId via ProviderRegistry. + /// Falls back to "com.cmuxterm.app.-accounts" if the provider is not registered. + var keychainServiceResolver: (String) -> String = { providerId in + ProviderRegistry.provider(id: providerId)?.keychainService + ?? "com.cmuxterm.app.\(providerId)-accounts" + } + + private init() { + self.userDefaults = .standard + self.indexKey = Self.defaultIndexKey + self.accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + /// Initializer for tests only. Injects an isolated `UserDefaults` + /// suite and an index key so unit tests don't pollute (or read from) + /// the shared defaults domain used by `.shared`. Production code must + /// always go through `ProviderAccountStore.shared`; this module-internal + /// init exists purely so `cmuxTests/ProviderTests.swift` can round-trip + /// the store against a throwaway suite. + init(userDefaults: UserDefaults, indexKey: String, keychainServiceResolver: ((String) -> String)? = nil) { + self.userDefaults = userDefaults + self.indexKey = indexKey + if let keychainServiceResolver { + self.keychainServiceResolver = keychainServiceResolver + } + self.accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + // MARK: - Public API + // + // Keychain-touching operations are `async` so the synchronous `SecItem*` + // calls run on a detached task instead of blocking the MainActor while + // the system Keychain resolves. `@Published accounts` mutations stay on + // main so SwiftUI observers don't need to hop actors. + + func reload() { + accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + func add(providerId: String, displayName: String, secret: ProviderSecret) async throws { + guard ProviderRegistry.provider(id: providerId) != nil else { + throw ProviderAccountStoreError.notFound + } + let service = keychainServiceResolver(providerId) + let account = ProviderAccount( + id: UUID(), + providerId: providerId, + displayName: displayName, + keychainService: service + ) + try await ProviderAccountKeychain.save(secret: secret, for: account.id, service: service) + var current = accounts + current.append(account) + do { + try saveIndex(current) + } catch { + // Undo the keychain write if we can't persist the index — otherwise + // the credential would linger without a referencing account. + try? await ProviderAccountKeychain.delete(for: account.id, service: service) + throw error + } + accounts = current + } + + func update(id: UUID, displayName: String, secret: ProviderSecret) async throws { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + // Keep the existing credentials on hand so a failure to persist the + // index can roll the keychain back; otherwise on-disk state could drift + // behind a successful keychain write. + let previousSecret = try? await ProviderAccountKeychain.load(for: id, service: service) + try await ProviderAccountKeychain.update(secret: secret, for: id, service: service) + // Actor reentrancy: another caller could have mutated `accounts` during + // the keychain awaits above, so look the row up fresh before touching + // it instead of trusting a snapshot taken beforehand. + var current = accounts + guard let index = current.firstIndex(where: { $0.id == id }) else { + if let previousSecret { + try? await ProviderAccountKeychain.update(secret: previousSecret, for: id, service: service) + } + throw ProviderAccountStoreError.notFound + } + current[index].displayName = displayName + do { + try saveIndex(current) + } catch { + if let previousSecret { + try? await ProviderAccountKeychain.update(secret: previousSecret, for: id, service: service) + } + throw error + } + accounts = current + } + + func remove(id: UUID) async throws { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + // Delete the keychain secret first so a failure surfaces to the caller + // before any on-disk state changes. That way a "removed" account never + // lingers in the index with live credentials still on disk. + try await ProviderAccountKeychain.delete(for: id, service: service) + var current = accounts + current.removeAll { $0.id == id } + // Secret is already deleted from the keychain; if persisting the new + // index fails we still want the in-memory state to reflect reality so + // the UI doesn't show a phantom row pointing at a missing secret. + // The next `pruneOrphanAccountsIfNeeded` run will reconcile the + // stale on-disk entry. + do { + try saveIndex(current) + } catch { + NSLog("ProviderAccountStore: failed to persist index after removing \(id): \(error)") + } + accounts = current + } + + func secret(for id: UUID) async throws -> ProviderSecret { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + return try await ProviderAccountKeychain.load(for: id, service: service) + } + + /// Resolves the keychain service to target for an account. A value stored + /// at account creation wins; when the field is missing (accounts written + /// by earlier builds) the registry resolver provides the fallback. + private func serviceName(for account: ProviderAccount) -> String { + account.keychainService ?? keychainServiceResolver(account.providerId) + } + + // MARK: - Index Persistence (UserDefaults) + + private func loadIndex() -> [ProviderAccount] { + guard let data = userDefaults.data(forKey: indexKey) else { + return [] + } + do { + return try JSONDecoder().decode([ProviderAccount].self, from: data) + } catch { + // Surface decode failures so corrupted index data doesn't silently + // hide stored accounts and their keychain credentials. + NSLog("ProviderAccountStore: failed to decode account index (\(data.count) bytes): \(error)") + return [] + } + } + + /// Drops accounts whose keychain secret is definitively missing (e.g. + /// after a crash between the keychain write and the index write). Runs + /// the `SecItem*` probes off the MainActor so UI doesn't hitch while the + /// system keychain resolves. Only `errSecItemNotFound` prunes — transient + /// failures like a locked keychain keep the entry so a later launch still + /// sees it. + private func pruneOrphanAccountsIfNeeded() async { + let snapshot = accounts + if snapshot.isEmpty { + return + } + var orphanIds: [UUID] = [] + for account in snapshot { + let service = serviceName(for: account) + let accountId = account.id + let status = await ProviderAccountKeychain.probePresenceAsync(for: accountId, service: service) + if status == errSecItemNotFound { + NSLog("ProviderAccountStore: dropping orphan account \(accountId) (keychain \(service) missing secret)") + orphanIds.append(accountId) + } + } + if orphanIds.isEmpty { + return + } + var current = accounts + current.removeAll { orphanIds.contains($0.id) } + try? saveIndex(current) + accounts = current + } + + private func saveIndex(_ accounts: [ProviderAccount]) throws { + let data = try JSONEncoder().encode(accounts) + userDefaults.set(data, forKey: indexKey) + } +} + +// MARK: - Keychain I/O (nonisolated) +// +// Kept separate from the MainActor-isolated store so that synchronous +// `SecItem*` calls run on a detached task without leaking their blocking +// behavior to the UI. Every call routes through `matchQuery` / +// `addAttributes` so accessibility + synchronizability attributes stay +// consistent: +// +// - kSecAttrAccessibleWhenUnlockedThisDeviceOnly — the credential is +// reachable while the device is unlocked and never migrates via +// Time Machine or iCloud keychain restore to a different machine. +// AI provider session tokens are inherently tied to the current +// machine's sign-in; they should not follow a user restore. +// - kSecAttrSynchronizable = kCFBooleanFalse — explicit opt-out of +// iCloud Keychain sync so a future default flip can't pick these +// items up without a code change. The attribute is part of both +// the match query and the write attributes so stored items and +// lookups stay perfectly symmetric. + +enum ProviderAccountKeychain { + /// Each entry point pre-checks cancellation and propagates it into the + /// detached task via `withTaskCancellationHandler`. The underlying + /// `SecItem*` calls are synchronous and not themselves interruptible, so + /// a keychain call already in flight still runs to completion — but an + /// upstream cancellation reaches the work before it starts and reaches + /// every subsequent call afterwards. + static func save(secret: ProviderSecret, for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeAdd(secret, for: accountId, service: service) + } + } + + static func update(secret: ProviderSecret, for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeUpdate(secret, for: accountId, service: service) + } + } + + static func load(for accountId: UUID, service: String) async throws -> ProviderSecret { + try Task.checkCancellation() + return try await runDetached { + try readLoad(for: accountId, service: service) + } + } + + static func delete(for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeDelete(for: accountId, service: service) + } + } + + /// Synchronous presence probe — returns the raw `OSStatus` without + /// decrypting or returning the payload. Used at load time to distinguish + /// "item missing" (`errSecItemNotFound`) from transient failures like a + /// locked keychain, so only true orphans are pruned. + static func probePresence(for accountId: UUID, service: String) -> OSStatus { + var query = matchQuery(service: service, accountId: accountId) + query[kSecMatchLimit] = kSecMatchLimitOne + query[kSecReturnData] = false + query[kSecReturnAttributes] = false + return SecItemCopyMatching(query as CFDictionary, nil) + } + + /// Off-main wrapper around `probePresence` so MainActor callers can run + /// the synchronous `SecItemCopyMatching` without blocking the UI. + static func probePresenceAsync(for accountId: UUID, service: String) async -> OSStatus { + let task = Task.detached(priority: .utility) { + probePresence(for: accountId, service: service) + } + return await task.value + } + + private static func runDetached(_ work: @Sendable @escaping () throws -> T) async throws -> T { + let task = Task.detached(priority: .userInitiated) { + try work() + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + // MARK: Synchronous internals + + private static func matchQuery(service: String, accountId: UUID) -> [CFString: Any] { + return [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: accountId.uuidString, + kSecAttrSynchronizable: kCFBooleanFalse as Any, + ] + } + + private static func addAttributes(payload: Data) -> [CFString: Any] { + return [ + kSecValueData: payload, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrSynchronizable: kCFBooleanFalse as Any, + ] + } + + private static func encodeSecret(_ secret: ProviderSecret) throws -> Data { + try JSONEncoder().encode(secret.fields) + } + + private static func decodeSecret(_ data: Data) throws -> ProviderSecret { + do { + let fields = try JSONDecoder().decode([String: String].self, from: data) + return ProviderSecret(fields: fields) + } catch { + throw ProviderAccountStoreError.decoding + } + } + + private static func writeAdd(_ secret: ProviderSecret, for accountId: UUID, service: String) throws { + let payload = try encodeSecret(secret) + var query = matchQuery(service: service, accountId: accountId) + query.merge(addAttributes(payload: payload)) { _, new in new } + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + // Update in place instead of delete-then-add so there is no window + // where the credential is absent from the keychain. + let updateStatus = SecItemUpdate( + matchQuery(service: service, accountId: accountId) as CFDictionary, + addAttributes(payload: payload) as CFDictionary + ) + if updateStatus != errSecSuccess { + throw ProviderAccountStoreError.keychain(updateStatus) + } + } else if status != errSecSuccess { + throw ProviderAccountStoreError.keychain(status) + } + } + + private static func writeUpdate(_ secret: ProviderSecret, for accountId: UUID, service: String) throws { + let payload = try encodeSecret(secret) + let query = matchQuery(service: service, accountId: accountId) + let attributes = addAttributes(payload: payload) + // Strict update-only: if the item has vanished between the caller's + // `load(...)` and this write (e.g. a concurrent `remove(id:)` ran + // during the intervening await), surface `errSecItemNotFound` instead + // of silently re-creating an orphan credential that no index entry + // references. + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard status == errSecSuccess else { + throw ProviderAccountStoreError.keychain(status) + } + } + + private static func readLoad(for accountId: UUID, service: String) throws -> ProviderSecret { + var query = matchQuery(service: service, accountId: accountId) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + throw ProviderAccountStoreError.notFound + } + guard status == errSecSuccess, let data = result as? Data else { + throw ProviderAccountStoreError.keychain(status) + } + return try decodeSecret(data) + } + + private static func writeDelete(for accountId: UUID, service: String) throws { + let query = matchQuery(service: service, accountId: accountId) + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw ProviderAccountStoreError.keychain(status) + } + } +} diff --git a/Sources/Providers/ProviderAccountsController.swift b/Sources/Providers/ProviderAccountsController.swift new file mode 100644 index 0000000000..40aba608cc --- /dev/null +++ b/Sources/Providers/ProviderAccountsController.swift @@ -0,0 +1,373 @@ +import AppKit +import Foundation + +// MARK: - Controller + +@MainActor +final class ProviderAccountsController: ObservableObject { + static let shared = ProviderAccountsController() + + @Published private(set) var snapshots: [UUID: ProviderUsageSnapshot] = [:] + @Published private(set) var fetchErrors: [UUID: String] = [:] + /// Keyed by providerId + @Published private(set) var incidents: [String: [ProviderIncident]] = [:] + /// Keyed by providerId + @Published private(set) var statusLoaded: [String: Bool] = [:] + /// Keyed by providerId + @Published private(set) var statusFetchFailed: [String: Bool] = [:] + /// Keyed by providerId + @Published private(set) var statusHasSucceeded: [String: Bool] = [:] + @Published private(set) var isRefreshing: Bool = false + + /// Hard cap on a single provider usage/status fetch before it is dropped + /// with a timeout error. Prevents one hung backend from pushing the task + /// group past the 60s tick cadence and dropping later scheduled refreshes. + private static let perFetchTimeout: TimeInterval = 20 + + private let queue = DispatchQueue(label: "com.cmuxterm.provider-accounts.timer") + private var timer: DispatchSourceTimer? + private var tickCount: Int = 0 + private var currentTask: Task? + private var taskGeneration: Int = 0 + private var occlusionObserver: NSObjectProtocol? + private var wasVisible: Bool = false + /// Flips to `true` during `stop()` so any timer / occlusion callback that + /// was already queued when teardown ran is dropped before it can reopen a + /// tick and fire extra provider requests. + private var isStopped: Bool = true + /// Flips to `true` when a timer/occlusion tick arrives while `currentTask` + /// is still draining. Instead of dropping the signal we run one more tick + /// as soon as the current one finishes, so a long fetch can't swallow the + /// next scheduled refresh. + private var hasPendingTick: Bool = false + + private init() {} + + // MARK: - Public API + + func start() { + guard timer == nil else { return } + isStopped = false + + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: 60.0) + source.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + guard let self, !self.isStopped else { return } + self.scheduleTick() + } + } + timer = source + source.resume() + + // Resume polling immediately when app transitions from hidden to visible. + // Only fire on hidden→visible edges to avoid refresh churn when the + // occlusion state toggles frequently (e.g. during window drags). + wasVisible = NSApp.occlusionState.contains(.visible) + occlusionObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeOcclusionStateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isStopped else { return } + let isVisible = NSApp.occlusionState.contains(.visible) + if isVisible && !self.wasVisible { + self.scheduleTick(force: true) + } + self.wasVisible = isVisible + } + } + } + + func stop() { + isStopped = true + timer?.cancel() + timer = nil + currentTask?.cancel() + currentTask = nil + hasPendingTick = false + tickCount = 0 + if let observer = occlusionObserver { + NotificationCenter.default.removeObserver(observer) + occlusionObserver = nil + } + wasVisible = false + } + + func refreshNow() { + scheduleTick(force: true) + } + + // MARK: - Private + + private func scheduleTick(force: Bool = false) { + // `refreshNow()` routes through here too, so the stopped gate has to + // live inside `scheduleTick` rather than only in the callback paths — + // otherwise an explicit manual refresh could restart polling after + // teardown. + guard !isStopped else { return } + if currentTask != nil && !force { + // A refresh is already in flight. Record the signal and run one + // more tick when the current task drains, instead of silently + // dropping it and waiting another 60s. + hasPendingTick = true + return + } + // A forced refresh replaces any in-flight task, so an earlier queued + // signal is already subsumed by the new run — clearing the flag stops + // the completion handler from scheduling a redundant second pass. + hasPendingTick = false + currentTask?.cancel() + taskGeneration += 1 + let generation = taskGeneration + currentTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.tick(generation: generation, force: force) + if self.taskGeneration == generation { + self.currentTask = nil + if self.hasPendingTick && !self.isStopped { + self.hasPendingTick = false + self.scheduleTick() + } + } + } + } + + private func tick(generation: Int, force: Bool = false) async { + // Skip if app is occluded (not visible), unless forced + if !force { + if !NSApp.occlusionState.contains(.visible) { + return + } + } + + // Bail out if a newer task has already replaced us + guard taskGeneration == generation else { return } + + isRefreshing = true + defer { + // Only clear isRefreshing if no newer task has replaced us + if taskGeneration == generation || currentTask == nil { + isRefreshing = false + } + } + + let accounts = ProviderAccountStore.shared.accounts + + // Skip all network requests when no accounts are configured + guard !accounts.isEmpty else { + snapshots.removeAll() + fetchErrors.removeAll() + incidents.removeAll() + statusLoaded.removeAll() + statusFetchFailed.removeAll() + statusHasSucceeded.removeAll() + return + } + + // Usage fetches run in parallel so one slow provider can't starve the + // others. Results are applied back on the MainActor after the group + // completes. + let fetchResults = await withTaskGroup(of: (ProviderAccount, Result?).self) { group -> [(ProviderAccount, Result?)] in + for account in accounts { + guard let provider = ProviderRegistry.provider(id: account.providerId) else { + group.addTask { (account, nil) } + continue + } + group.addTask { + do { + // Keychain retrieval is on the same timer budget as + // the network fetch so a stalled keychain access can't + // block the refresh cadence any longer than a hung + // provider call would. + let result = try await ProviderAccountsController.withTimeout(seconds: Self.perFetchTimeout) { + let secret = try await ProviderAccountStore.shared.secret(for: account.id) + return try await provider.fetchUsage(secret) + } + return (account, .success(result)) + } catch { + return (account, .failure(error)) + } + } + } + var out: [(ProviderAccount, Result?)] = [] + for await item in group { + out.append(item) + } + return out + } + + guard !Task.isCancelled, taskGeneration == generation else { return } + + // Re-read the store after the await: the user may have removed + // accounts or providers while fetches were in flight, and those + // results must not be written back into the published state. + let liveAccountIds = Set(ProviderAccountStore.shared.accounts.map(\.id)) + let liveProviderIds = Set(ProviderAccountStore.shared.accounts.map(\.providerId)) + + for (account, outcome) in fetchResults { + guard liveAccountIds.contains(account.id) else { continue } + switch outcome { + case nil: + fetchErrors[account.id] = String( + localized: "providers.accounts.error.unknownProvider", + defaultValue: "Unknown provider: \(account.providerId)" + ) + case .success(let result): + snapshots[account.id] = ProviderUsageSnapshot( + accountId: account.id, + providerId: account.providerId, + displayName: account.displayName, + session: result.session, + week: result.week, + fetchedAt: Date() + ) + fetchErrors.removeValue(forKey: account.id) + case .failure(_ as CancellationError): + continue + case .failure(let error): + fetchErrors[account.id] = Self.localizedFetchErrorMessage(error) + } + } + + // Clean up snapshots/errors for removed accounts. + // Collect stale keys first — mutating a Dictionary while iterating its + // Keys view is undefined behavior. + let staleSnapshotIds = snapshots.keys.filter { !liveAccountIds.contains($0) } + for id in staleSnapshotIds { + snapshots.removeValue(forKey: id) + } + let staleErrorIds = fetchErrors.keys.filter { !liveAccountIds.contains($0) } + for id in staleErrorIds { + fetchErrors.removeValue(forKey: id) + } + + // Clean up provider-keyed status state for providers with no remaining accounts + let staleIncidentProviderIds = incidents.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleIncidentProviderIds { + incidents.removeValue(forKey: providerId) + } + let staleLoadedProviderIds = statusLoaded.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleLoadedProviderIds { + statusLoaded.removeValue(forKey: providerId) + } + let staleFailedProviderIds = statusFetchFailed.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleFailedProviderIds { + statusFetchFailed.removeValue(forKey: providerId) + } + let staleSucceededProviderIds = statusHasSucceeded.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleSucceededProviderIds { + statusHasSucceeded.removeValue(forKey: providerId) + } + + // Fetch status incidents every 5th tick (every ~5 minutes) + tickCount += 1 + if tickCount % 5 == 1 || force { + let providerIds = liveProviderIds + let statusResults = await withTaskGroup(of: (String, Result<[ProviderIncident], Error>?).self) { group -> [(String, Result<[ProviderIncident], Error>?)] in + for providerId in providerIds { + guard let provider = ProviderRegistry.provider(id: providerId), + let fetchStatus = provider.fetchStatus else { + group.addTask { (providerId, nil) } + continue + } + group.addTask { + do { + let fetched = try await ProviderAccountsController.withTimeout(seconds: Self.perFetchTimeout) { + try await fetchStatus() + } + return (providerId, .success(fetched)) + } catch { + return (providerId, .failure(error)) + } + } + } + var out: [(String, Result<[ProviderIncident], Error>?)] = [] + for await item in group { + out.append(item) + } + return out + } + + guard !Task.isCancelled, taskGeneration == generation else { return } + + // Re-check which providers still have an account so a provider + // removed during the status fetch cannot reopen its status state. + let currentProviderIds = Set(ProviderAccountStore.shared.accounts.map(\.providerId)) + + for (providerId, outcome) in statusResults { + guard currentProviderIds.contains(providerId) else { continue } + switch outcome { + case nil: + continue + case .success(let fetched): + incidents[providerId] = fetched + statusLoaded[providerId] = true + statusFetchFailed[providerId] = false + statusHasSucceeded[providerId] = true + case .failure: + // Keep previous incidents on failure; mark loaded so UI stops showing spinner + statusLoaded[providerId] = true + statusFetchFailed[providerId] = true + } + } + } + } + + // MARK: - Error mapping + + /// Ensures every string exposed through `fetchErrors` is routed through + /// the app's `.xcstrings` catalog. Provider-owned errors already return + /// localized `errorDescription` values; any other error is wrapped in a + /// localized shell so raw OS-level strings never reach the UI. + private static func localizedFetchErrorMessage(_ error: Error) -> String { + if let localized = error as? LocalizedError, + let description = localized.errorDescription { + return description + } + return String( + localized: "providers.accounts.error.fetchFailed", + defaultValue: "Could not refresh usage: \(error.localizedDescription)" + ) + } + + // MARK: - Timeout helper + + struct ProviderFetchTimeoutError: Error, LocalizedError { + let seconds: TimeInterval + var errorDescription: String? { + String( + localized: "providers.accounts.error.timeout", + defaultValue: "Provider fetch timed out after \(Int(seconds))s." + ) + } + } + + /// Runs `operation` with a deadline. When the sibling timer wins the + /// group is cancelled and `ProviderFetchTimeoutError` is thrown. Swift's + /// structured concurrency still awaits the operation task, so a timely + /// exit requires `operation` to cooperate with cancellation — URLSession + /// fetches do this automatically (they throw `URLError.cancelled`), and + /// the keychain helpers check `Task.isCancelled` at each entry point so a + /// new call after the timer fires exits immediately. A keychain call + /// already mid-flight runs to completion; `SecItem*` is not itself + /// interruptible. + nonisolated static func withTimeout( + seconds: TimeInterval, + operation: @Sendable @escaping () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw ProviderFetchTimeoutError(seconds: seconds) + } + guard let value = try await group.next() else { + throw ProviderFetchTimeoutError(seconds: seconds) + } + group.cancelAll() + return value + } + } +} diff --git a/Sources/Providers/ProviderHTTP.swift b/Sources/Providers/ProviderHTTP.swift new file mode 100644 index 0000000000..a1bd3ab492 --- /dev/null +++ b/Sources/Providers/ProviderHTTP.swift @@ -0,0 +1,96 @@ +import Foundation + +// MARK: - Shared HTTP plumbing for provider fetchers +// +// Claude, Codex, and Statuspage.io fetchers all need the same shape: an +// ephemeral URLSession (no disk cookie/cache storage for credentials), a +// short timeout, a GET that expects `application/json`, a response-type +// check, an HTTP 200 gate, and JSON decoding. Centralizing it here keeps +// each concrete fetcher focused on its provider-specific parsing. + +enum ProviderHTTPError: Error { + case badResponse + case http(Int) + case decoding + case network(Error) +} + +enum ProviderHTTP { + + /// Returns a fresh ephemeral `URLSession` with the requested request / + /// resource timeout (seconds). Ephemeral sessions don't persist cookies + /// or URL cache to disk, which is important for credential-bearing + /// requests — a crashed process mustn't leave a plaintext cookie in + /// `~/Library/Caches`. + static func makeSession(timeout: TimeInterval) -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + config.httpCookieStorage = nil + config.httpShouldSetCookies = false + config.urlCache = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config) + } + + /// GETs `url` with optional `Cookie` / `Authorization` / `chatgpt-account-id` + /// style headers (already sanitized by the caller) and returns the JSON + /// top-level object. Header values pass through `sanitizeHeaderValue`, + /// which strips control characters and cookie attribute separators so a + /// malformed credential can't smuggle extra directives into the request. + static func getJSONObject( + url: URL, + headers: [String: String] = [:], + session: URLSession + ) async throws -> [String: Any] { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + for (name, value) in headers { + request.setValue(sanitizeHeaderValue(value), forHTTPHeaderField: name) + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let urlError as URLError where urlError.code == .cancelled { + // URLSession surfaces cooperative task cancellation as + // `URLError.cancelled`; forward it as `CancellationError` so + // callers can handle cancellation through one code path. + throw CancellationError() + } catch { + // Defensive: if the surrounding Task was cancelled, treat any + // resulting error as cancellation rather than a network failure. + if Task.isCancelled { + throw CancellationError() + } + throw ProviderHTTPError.network(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderHTTPError.badResponse + } + guard httpResponse.statusCode == 200 else { + throw ProviderHTTPError.http(httpResponse.statusCode) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderHTTPError.decoding + } + return json + } + + /// Strips every byte that would change the semantics of an HTTP header: + /// control characters (including `\r\n\t\0`) and cookie attribute + /// separators (`;` and `,`). Upstream provider fetchers already validate + /// credential shape, but this acts as a last-line defense so a malformed + /// value can never inject extra directives into the wire request. + static func sanitizeHeaderValue(_ value: String) -> String { + var disallowed = CharacterSet.controlCharacters + disallowed.insert(charactersIn: ";,") + let scalars = value.unicodeScalars.filter { !disallowed.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } +} diff --git a/Sources/Providers/ProviderISO8601DateParser.swift b/Sources/Providers/ProviderISO8601DateParser.swift new file mode 100644 index 0000000000..295027d86b --- /dev/null +++ b/Sources/Providers/ProviderISO8601DateParser.swift @@ -0,0 +1,22 @@ +import Foundation + +// MARK: - Shared ISO8601 Date Parser + +enum ProviderISO8601DateParser { + /// Formatters are created per call so concurrent parses from different + /// provider fetch tasks can never share mutable `ISO8601DateFormatter` + /// state. `ISO8601DateFormatter` is not documented as thread-safe, and + /// this parser is intentionally lightweight so the extra allocations are + /// cheaper than the alternatives (locks, thread-local caches). + static func parse(_ string: String?) -> Date? { + guard let string else { return nil } + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: string) { + return date + } + let withoutFractional = ISO8601DateFormatter() + withoutFractional.formatOptions = [.withInternetDateTime] + return withoutFractional.date(from: string) + } +} diff --git a/Sources/Providers/ProviderRegistry.swift b/Sources/Providers/ProviderRegistry.swift new file mode 100644 index 0000000000..734c6a4d0a --- /dev/null +++ b/Sources/Providers/ProviderRegistry.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Provider Registry + +enum ProviderRegistry { + /// All registered providers (including stubs with empty credentialFields). + /// Each concrete provider appends itself in its own source commit so this + /// file stays buildable in isolation. + static var all: [UsageProvider] { [Providers.claude, Providers.codex] } + + /// Providers ready for use in the UI — excludes stubs with empty credentialFields. + static var ui: [UsageProvider] { all.filter { !$0.credentialFields.isEmpty } } + + static func provider(id: String) -> UsageProvider? { + all.first { $0.id == id } + } +} + +// MARK: - Providers namespace + +enum Providers {} diff --git a/Sources/Providers/ProviderUsageColorSettings.swift b/Sources/Providers/ProviderUsageColorSettings.swift new file mode 100644 index 0000000000..53c7d0cb17 --- /dev/null +++ b/Sources/Providers/ProviderUsageColorSettings.swift @@ -0,0 +1,206 @@ +import SwiftUI + +// MARK: - Color Settings + +@MainActor +final class ProviderUsageColorSettings: ObservableObject { + static let shared = ProviderUsageColorSettings() + + private static let keyLow = "cmux.provider.usageColor.low" + private static let keyMid = "cmux.provider.usageColor.mid" + private static let keyHigh = "cmux.provider.usageColor.high" + private static let keyLowMidThreshold = "cmux.provider.usageColor.lowMidThreshold" + private static let keyMidHighThreshold = "cmux.provider.usageColor.midHighThreshold" + private static let keyInterpolate = "cmux.provider.usageColor.interpolate" + + static let defaultLowHex = "#46B46E" + static let defaultMidHex = "#D2AA3C" + static let defaultHighHex = "#DC5050" + + /// Resolved `Color` value for each built-in threshold color. Kept as + /// derived constants so the Settings color pickers, the live preview, and + /// `color(for:)` all use the exact same fallback when a persisted hex + /// value fails to parse. + static var defaultLowColor: Color { Color(usageHex: defaultLowHex) ?? .green } + static var defaultMidColor: Color { Color(usageHex: defaultMidHex) ?? .yellow } + static var defaultHighColor: Color { Color(usageHex: defaultHighHex) ?? .red } + private static let defaultLowMidThreshold = 85 + private static let defaultMidHighThreshold = 95 + + private let defaults: UserDefaults + + @Published var lowColorHex: String { + didSet { defaults.set(lowColorHex, forKey: Self.keyLow) } + } + + @Published var midColorHex: String { + didSet { defaults.set(midColorHex, forKey: Self.keyMid) } + } + + @Published var highColorHex: String { + didSet { defaults.set(highColorHex, forKey: Self.keyHigh) } + } + + /// Writes are funneled through `setThresholds(low:high:)` so the `1...99` + /// contract and the `low < high` ordering invariant can be enforced in one + /// place. Direct external writes are disallowed to keep out-of-range or + /// inverted pairs from bypassing that validation. + @Published private(set) var lowMidThreshold: Int { + didSet { defaults.set(lowMidThreshold, forKey: Self.keyLowMidThreshold) } + } + + @Published private(set) var midHighThreshold: Int { + didSet { defaults.set(midHighThreshold, forKey: Self.keyMidHighThreshold) } + } + + @Published var interpolate: Bool { + didSet { defaults.set(interpolate, forKey: Self.keyInterpolate) } + } + + private convenience init() { + self.init(userDefaults: .standard) + } + + /// Test-only initializer. Keeps production code on `.shared` but lets + /// `cmuxTests/ProviderTests.swift` point the settings at an isolated + /// `UserDefaults` suite so it never pollutes or reads from the user's + /// real defaults domain. + init(userDefaults: UserDefaults) { + self.defaults = userDefaults + self.lowColorHex = userDefaults.string(forKey: Self.keyLow) ?? Self.defaultLowHex + self.midColorHex = userDefaults.string(forKey: Self.keyMid) ?? Self.defaultMidHex + self.highColorHex = userDefaults.string(forKey: Self.keyHigh) ?? Self.defaultHighHex + + var lowMid = (userDefaults.object(forKey: Self.keyLowMidThreshold) as? Int) ?? Self.defaultLowMidThreshold + var midHigh = (userDefaults.object(forKey: Self.keyMidHighThreshold) as? Int) ?? Self.defaultMidHighThreshold + lowMid = min(max(lowMid, 1), 98) + midHigh = min(max(midHigh, 2), 99) + if lowMid >= midHigh { + lowMid = Self.defaultLowMidThreshold + midHigh = Self.defaultMidHighThreshold + } + self.lowMidThreshold = lowMid + self.midHighThreshold = midHigh + + if userDefaults.object(forKey: Self.keyInterpolate) != nil { + self.interpolate = userDefaults.bool(forKey: Self.keyInterpolate) + } else { + self.interpolate = true + } + } + + // MARK: - Color Resolution + + func color(for percent: Int) -> Color { + let clamped = min(max(percent, 0), 100) + let lowColor = Color(usageHex: lowColorHex) ?? Self.defaultLowColor + let midColor = Color(usageHex: midColorHex) ?? Self.defaultMidColor + let highColor = Color(usageHex: highColorHex) ?? Self.defaultHighColor + + if !interpolate { + if clamped <= lowMidThreshold { + return lowColor + } else if clamped <= midHighThreshold { + return midColor + } else { + return highColor + } + } + + // Interpolation mode + if clamped <= lowMidThreshold { + let t = lowMidThreshold > 0 + ? Double(clamped) / Double(lowMidThreshold) + : 0.0 + return interpolateColor(from: lowColor, to: midColor, t: t) + } else if clamped <= midHighThreshold { + let range = midHighThreshold - lowMidThreshold + let t = range > 0 + ? Double(clamped - lowMidThreshold) / Double(range) + : 0.0 + return interpolateColor(from: midColor, to: highColor, t: t) + } else { + return highColor + } + } + + // MARK: - Threshold Validation + + func setThresholds(low: Int, high: Int) { + guard low >= 1, high <= 99, low < high else { return } + lowMidThreshold = low + midHighThreshold = high + } + + // MARK: - Reset + + func resetToDefaults() { + lowColorHex = Self.defaultLowHex + midColorHex = Self.defaultMidHex + highColorHex = Self.defaultHighHex + setThresholds(low: Self.defaultLowMidThreshold, high: Self.defaultMidHighThreshold) + interpolate = true + } + + // MARK: - Color Interpolation + + private func interpolateColor(from: Color, to: Color, t: Double) -> Color { + let clampedT = min(max(t, 0), 1) + let fromComponents = from.rgbComponents + let toComponents = to.rgbComponents + return Color( + red: fromComponents.red + (toComponents.red - fromComponents.red) * clampedT, + green: fromComponents.green + (toComponents.green - fromComponents.green) * clampedT, + blue: fromComponents.blue + (toComponents.blue - fromComponents.blue) * clampedT + ) + } +} + +// MARK: - Color Hex Extension + +extension Color { + init?(usageHex hex: String) { + // Accept exactly zero or one leading `#`. `trimmingCharacters` would + // have silently normalized junk like `###RRGGBB` or `#RRGGBB#`; + // requiring a tight shape lets corrupted persisted colors fail closed. + let sanitized: String + if hex.hasPrefix("#") { + sanitized = String(hex.dropFirst()) + } else { + sanitized = hex + } + guard sanitized.count == 6, let value = UInt64(sanitized, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255.0, + green: Double((value >> 8) & 0xFF) / 255.0, + blue: Double(value & 0xFF) / 255.0 + ) + } + + var usageHexString: String { + let components = rgbComponents + let r = Int(round(components.red * 255)) + let g = Int(round(components.green * 255)) + let b = Int(round(components.blue * 255)) + return String(format: "#%02X%02X%02X", r, g, b) + } + + /// Returns the sRGB red/green/blue components of this color in the 0...1 range. + /// + /// If the color cannot be converted to the sRGB color space (for example, pattern + /// colors or asset-catalog dynamic colors that lack a concrete sRGB representation), + /// this falls back to opaque black `(0, 0, 0)`. + var rgbComponents: (red: Double, green: Double, blue: Double) { + guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else { + #if DEBUG + NSLog("[ProviderUsageColorSettings] rgbComponents: color is not sRGB-convertible, falling back to black. color=%@", String(describing: self)) + #endif + return (red: 0, green: 0, blue: 0) + } + return ( + red: Double(nsColor.redComponent), + green: Double(nsColor.greenComponent), + blue: Double(nsColor.blueComponent) + ) + } +} diff --git a/Sources/Providers/StatuspageIOFetcher.swift b/Sources/Providers/StatuspageIOFetcher.swift new file mode 100644 index 0000000000..30b4a931be --- /dev/null +++ b/Sources/Providers/StatuspageIOFetcher.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - Errors + +enum StatuspageIOFetchError: Error { + case http(Int) + case decoding + case network(Error) + case invalidHost(String) +} + +// MARK: - Fetcher + +enum StatuspageIOFetcher { + + private static let session = ProviderHTTP.makeSession(timeout: 5) + + /// Hosts permitted as Statuspage.io API origins. Must be kept in sync with + /// the `fetchStatus` closures in each registered provider. + private static let allowedHosts: Set = [ + "status.claude.com", + "status.openai.com", + ] + + /// Fetches unresolved incidents from a Statuspage.io-compatible API. + /// When `componentFilter` is provided, only incidents affecting at least one + /// of the listed component names are returned. + static func fetch(host: String, componentFilter: Set? = nil) async throws -> [ProviderIncident] { + guard !host.contains("/"), !host.contains(":"), allowedHosts.contains(host) else { + throw StatuspageIOFetchError.invalidHost(host) + } + guard let url = URL(string: "https://\(host)/api/v2/incidents.json") else { + throw StatuspageIOFetchError.decoding + } + + let json: [String: Any] + do { + json = try await ProviderHTTP.getJSONObject(url: url, session: session) + } catch is CancellationError { + throw CancellationError() + } catch ProviderHTTPError.http(let status) { + throw StatuspageIOFetchError.http(status) + } catch ProviderHTTPError.badResponse { + throw StatuspageIOFetchError.decoding + } catch ProviderHTTPError.network(let underlying) { + throw StatuspageIOFetchError.network(underlying) + } catch { + throw StatuspageIOFetchError.decoding + } + guard let incidents = json["incidents"] as? [[String: Any]] else { + throw StatuspageIOFetchError.decoding + } + + let closedStatuses: Set = ["resolved", "postmortem"] + return incidents.compactMap { dict in + let status = dict["status"] as? String ?? "unknown" + guard !closedStatuses.contains(status) else { return nil } + guard let id = dict["id"] as? String, + let name = dict["name"] as? String else { + return nil + } + + if let filter = componentFilter { + let components = dict["components"] as? [[String: Any]] ?? [] + // The component intersection only applies when the payload + // attaches a component list; page-wide incidents arrive with an + // empty components array and should always be surfaced. + if !components.isEmpty { + let componentNames = Set(components.compactMap { $0["name"] as? String }) + guard !componentNames.isDisjoint(with: filter) else { return nil } + } + } + + let impact = dict["impact"] as? String ?? "none" + let updatedAt = ProviderISO8601DateParser.parse(dict["updated_at"] as? String) + return ProviderIncident(id: id, name: name, status: status, impact: impact, updatedAt: updatedAt) + } + } +} + diff --git a/Sources/Providers/UsageProvider.swift b/Sources/Providers/UsageProvider.swift new file mode 100644 index 0000000000..4bae91b6f0 --- /dev/null +++ b/Sources/Providers/UsageProvider.swift @@ -0,0 +1,60 @@ +import Foundation + +// MARK: - Usage Windows + +struct ProviderUsageWindows: Sendable { + let session: ProviderUsageWindow + let week: ProviderUsageWindow +} + +struct ProviderUsageWindow: Sendable { + let utilization: Int // 0..100, "% used" + let resetsAt: Date? + let windowSeconds: TimeInterval // 18000 for session, 604800 for week +} + +// MARK: - Incidents + +struct ProviderIncident: Identifiable, Sendable { + let id: String + let name: String + let status: String + let impact: String + let updatedAt: Date? +} + +// MARK: - Usage Snapshot + +struct ProviderUsageSnapshot { + let accountId: UUID + let providerId: String + let displayName: String + let session: ProviderUsageWindow + let week: ProviderUsageWindow + let fetchedAt: Date +} + +// MARK: - Credential Field + +struct CredentialField: Identifiable, Sendable { + let id: String // dictionary key, e.g. "sessionKey", "orgId", "apiKey" + let label: String // localized + let placeholder: String + let isSecret: Bool // SecureField vs TextField + let helpText: String? // shown under the field + let validate: (@Sendable (String) -> Bool)? +} + +// MARK: - Usage Provider + +struct UsageProvider: Identifiable, Sendable { + let id: String // "claude" | "codex" | ... + let displayName: String // "Claude" | "Codex" + let keychainService: String // "com.cmuxterm.app.claude-accounts" + let credentialFields: [CredentialField] + let statusPageURL: URL? // for the popover "open status page" button + let statusSectionTitle: String // localized popover header, e.g. "Claude.ai status" + let helpDocURL: URL? // for the editor sheet help link + let fetchUsage: @Sendable (ProviderSecret) async throws -> ProviderUsageWindows + let fetchStatus: (@Sendable () async throws -> [ProviderIncident])? +} diff --git a/Sources/Sidebar/ProviderAccountEditorSheet.swift b/Sources/Sidebar/ProviderAccountEditorSheet.swift new file mode 100644 index 0000000000..69e95245ae --- /dev/null +++ b/Sources/Sidebar/ProviderAccountEditorSheet.swift @@ -0,0 +1,210 @@ +import AppKit +import SwiftUI + +// MARK: - Editor Sheet + +struct ProviderAccountEditorSheet: View { + let provider: UsageProvider + let editingAccount: ProviderAccount? + let onDismiss: () -> Void + + @State private var displayName: String = "" + @State private var values: [String: String] = [:] + @State private var errorMessage: String? + @State private var isSaving: Bool = false + @State private var isLoadingCredentials: Bool = false + + private var isEditing: Bool { editingAccount != nil } + + private var isValid: Bool { + let nameOk = !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // A provider with no credential fields cannot produce a meaningful + // ProviderSecret — reject save rather than persisting an empty payload. + guard !provider.credentialFields.isEmpty else { return false } + let fieldsOk = provider.credentialFields.allSatisfy { field in + let value = values[field.id]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if let validate = field.validate { + return validate(value) + } + return !value.isEmpty + } + return nameOk && fieldsOk + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + Text(isEditing + ? String( + localized: "providers.accounts.editor.title.edit", + defaultValue: "Edit \(provider.displayName) account" + ) + : String( + localized: "providers.accounts.editor.title.add", + defaultValue: "Add \(provider.displayName) account" + ) + ) + .font(.headline) + + Spacer() + + if let helpDocURL = provider.helpDocURL { + Button { + NSWorkspace.shared.open(helpDocURL) + } label: { + HStack(spacing: 4) { + Image(systemName: "questionmark.circle") + Text(String( + localized: "providers.accounts.help.link", + defaultValue: "Setup instructions" + )) + } + .font(.caption) + } + .buttonStyle(.link) + } + } + + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + let nameLabel = String( + localized: "providers.accounts.editor.name", + defaultValue: "Display name" + ) + Text(nameLabel) + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $displayName) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(nameLabel) + } + + ForEach(provider.credentialFields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.label) + .font(.caption) + .foregroundColor(.secondary) + // Credential inputs are locked while the existing + // secret is being read back from Keychain so an async + // load completion can't clobber characters the user + // started typing. + if field.isSecret { + SecureField(field.placeholder, text: fieldBinding(for: field.id)) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(field.label) + .disabled(isLoadingCredentials) + } else { + TextField(field.placeholder, text: fieldBinding(for: field.id)) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(field.label) + .disabled(isLoadingCredentials) + } + if let helpText = field.helpText { + Text(helpText) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + + HStack { + Spacer() + // Cancel is locked while a save is in flight so the user + // can't dismiss the sheet halfway through a keychain write + // and be surprised by credentials still landing on disk. + Button(String(localized: "providers.accounts.editor.cancel", defaultValue: "Cancel")) { + onDismiss() + } + .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) + .disabled(isSaving) + + Button(String(localized: "providers.accounts.editor.save", defaultValue: "Save")) { + guard !isSaving else { return } + isSaving = true + Task { await save() } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) + } + } + .padding(20) + .frame(width: 380) + .task { + guard let account = editingAccount else { return } + displayName = account.displayName + isLoadingCredentials = true + defer { isLoadingCredentials = false } + do { + let secret = try await ProviderAccountStore.shared.secret(for: account.id) + for field in provider.credentialFields { + values[field.id] = secret.fields[field.id] ?? "" + } + } catch let storeError as ProviderAccountStoreError { + errorMessage = storeError.localizedDescription + } catch { + errorMessage = String( + localized: "providers.accounts.error.loadSecret", + defaultValue: "Could not load saved credentials. Re-enter them to save changes." + ) + } + } + } + + private func fieldBinding(for key: String) -> Binding { + Binding( + get: { values[key] ?? "" }, + set: { values[key] = $0 } + ) + } + + private func save() async { + errorMessage = nil + + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + var trimmedFields: [String: String] = [:] + for field in provider.credentialFields { + let trimmed = (values[field.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + trimmedFields[field.id] = trimmed + } + } + + let secret = ProviderSecret(fields: trimmedFields) + + do { + if let account = editingAccount { + try await ProviderAccountStore.shared.update(id: account.id, displayName: trimmedName, secret: secret) + } else { + try await ProviderAccountStore.shared.add( + providerId: provider.id, + displayName: trimmedName, + secret: secret + ) + } + ProviderAccountsController.shared.refreshNow() + onDismiss() + } catch let storeError as ProviderAccountStoreError { + errorMessage = storeError.localizedDescription + isSaving = false + } catch { + // An unexpected error type reached here; show a purely localized + // message so raw OS-level strings never leak into the sheet. The + // raw error still lands in Console via NSLog for debugging. + NSLog("ProviderAccountEditorSheet: save failed: \(error)") + errorMessage = String( + localized: "providers.accounts.error.saveFailed", + defaultValue: "Could not save credentials. Please try again." + ) + isSaving = false + } + } +} diff --git a/Sources/Sidebar/ProviderAccountsFooterPanel.swift b/Sources/Sidebar/ProviderAccountsFooterPanel.swift new file mode 100644 index 0000000000..cf97a0d37c --- /dev/null +++ b/Sources/Sidebar/ProviderAccountsFooterPanel.swift @@ -0,0 +1,645 @@ +import AppKit +import SwiftUI + +// MARK: - Footer Panel + +struct ProviderAccountsFooterPanel: View { + @ObservedObject private var store = ProviderAccountStore.shared + @ObservedObject private var controller = ProviderAccountsController.shared + + private var providers: [UsageProvider] { ProviderRegistry.ui } + + private var providersWithAccounts: [UsageProvider] { + let configuredIds = Set(store.accounts.map { $0.providerId }) + return providers.filter { configuredIds.contains($0.id) } + } + + var body: some View { + if providersWithAccounts.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(providersWithAccounts, id: \.id) { provider in + ProviderSection(provider: provider, store: store, controller: controller) + } + } + } + } +} + +// MARK: - Provider Section + +private struct ProviderSection: View { + let provider: UsageProvider + @ObservedObject var store: ProviderAccountStore + @ObservedObject var controller: ProviderAccountsController + + @State private var isPopoverShown = false + @State private var isCollapsed: Bool + + init(provider: UsageProvider, store: ProviderAccountStore, controller: ProviderAccountsController) { + self.provider = provider + self.store = store + self.controller = controller + + let key = "cmux.providers.accounts.collapsed.\(provider.id)" + _isCollapsed = State(initialValue: UserDefaults.standard.bool(forKey: key)) + } + + private var collapsedKey: String { "cmux.providers.accounts.collapsed.\(provider.id)" } + + private var providerAccounts: [ProviderAccount] { + store.accounts.filter { $0.providerId == provider.id } + } + + var body: some View { + // The parent footer only mounts `ProviderSection` for providers with + // at least one configured account, so the accounts list is guaranteed + // to be non-empty once this view renders. + VStack(alignment: .leading, spacing: 4) { + header + if !isCollapsed { + accountsList + } + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + .popover(isPresented: $isPopoverShown, arrowEdge: .top) { + ProviderAccountsPopover( + provider: provider, + store: store, + controller: controller, + isPresented: $isPopoverShown + ) + } + } + + private var header: some View { + Button { + toggleCollapsed() + } label: { + HStack(spacing: 4) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .frame(width: 10, alignment: .center) + Text(provider.displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + if provider.fetchStatus != nil { + ProviderStatusLabel(provider: provider, controller: controller) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isCollapsed + ? String(localized: "providers.accounts.header.expand", defaultValue: "Expand \(provider.displayName) accounts") + : String(localized: "providers.accounts.header.collapse", defaultValue: "Collapse \(provider.displayName) accounts") + ) + } + + private var accountsList: some View { + // A real `Button` is used so keyboard focus and VoiceOver both treat + // the stack as an actionable control that opens the detailed popover, + // not just a mouse-only surface. + Button { + isPopoverShown.toggle() + } label: { + VStack(alignment: .leading, spacing: 6) { + ForEach(providerAccounts) { account in + AccountRow( + account: account, + snapshot: controller.snapshots[account.id], + errorMessage: controller.fetchErrors[account.id] + ) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "providers.accounts.footer.accessibility", defaultValue: "\(provider.displayName) account usage")) + } + + private func toggleCollapsed() { + let newValue = !isCollapsed + withAnimation(.easeInOut(duration: 0.15)) { + isCollapsed = newValue + } + UserDefaults.standard.set(newValue, forKey: collapsedKey) + } +} + +// MARK: - Provider Status Label (Header) + +private struct ProviderStatusLabel: View { + let provider: UsageProvider + @ObservedObject var controller: ProviderAccountsController + + private var providerIncidents: [ProviderIncident] { + controller.incidents[provider.id] ?? [] + } + + private var isStatusLoaded: Bool { + controller.statusLoaded[provider.id] ?? false + } + + private var isStatusFetchFailed: Bool { + controller.statusFetchFailed[provider.id] ?? false + } + + private var hasStatusSucceeded: Bool { + controller.statusHasSucceeded[provider.id] ?? false + } + + var body: some View { + HStack(spacing: 3) { + Text("·") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.quaternary) + Circle() + .fill(dotColor) + .frame(width: 5, height: 5) + Text(statusText) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + .help(tooltipText) + .accessibilityLabel(statusText) + } + + private var statusText: String { + if !isStatusLoaded { + return String(localized: "provider.status.loading", defaultValue: "…") + } + // When the latest status fetch failed, surface that directly rather + // than falling through to a cached "Operational"/incident reading. A + // stale snapshot could hide a real outage that landed between polls. + if isStatusFetchFailed { + return String(localized: "provider.status.unknown", defaultValue: "Unknown") + } + if providerIncidents.isEmpty { + return String(localized: "provider.status.operational", defaultValue: "Operational") + } + let worst = providerIncidents + .map { Self.impactSeverity($0.impact) } + .max() ?? 0 + switch worst { + case 0: return String(localized: "provider.status.operational", defaultValue: "Operational") + case 1: return String(localized: "provider.status.minor", defaultValue: "Minor issue") + case 2: return String(localized: "provider.status.degraded", defaultValue: "Degraded") + default: return String(localized: "provider.status.critical", defaultValue: "Critical") + } + } + + private var dotColor: Color { + if !isStatusLoaded || isStatusFetchFailed { + return .gray + } + if providerIncidents.isEmpty { + return .green + } + let worst = providerIncidents + .map { Self.impactSeverity($0.impact) } + .max() ?? 0 + switch worst { + case 0: return .green + case 1: return .yellow + case 2: return .orange + default: return .red + } + } + + private var tooltipText: String { + if !isStatusLoaded { + return String(localized: "providers.accounts.status.loading", defaultValue: "Checking status…") + } + // Match the header's failure semantics: when the latest fetch failed, + // surface that directly rather than reading from a cached snapshot + // that could hide a real outage landing between polls. + if isStatusFetchFailed { + return String(localized: "providers.accounts.status.fetchFailed", defaultValue: "Could not check status") + } + if providerIncidents.isEmpty { + return String(localized: "providers.accounts.status.allOk", defaultValue: "All systems operational") + } + if providerIncidents.count == 1, let only = providerIncidents.first { + return only.name + } + let names = providerIncidents.prefix(3).map(\.name).joined(separator: "\n") + let extra = providerIncidents.count > 3 + ? "\n" + String(localized: "providers.accounts.incidents.truncated", defaultValue: "…") + : "" + return names + extra + } + + private static func impactSeverity(_ impact: String) -> Int { + switch impact.lowercased() { + case "none": return 0 + case "minor": return 1 + case "major": return 2 + case "critical": return 3 + default: return 1 + } + } +} + +// MARK: - Shared label width +// +// "Sess" and "Week" fit in a narrow fixed label column when rendered in +// English, but translations ("Сессия", "Неделя", etc.) can be noticeably +// wider and would either truncate or wrap. The two usage rows in a single +// account card publish their intrinsic label width through this preference +// key; the card takes the max and feeds it back to both rows so they share +// the widest-localized label column and the bar shrinks to fit the rest. +// +// This type is module-internal (not file-private) so that +// `ProviderAccountsPopover.swift` can reuse the same key on its own usage +// rows — both surfaces render "Sess"/"Week" and should share the mechanism. + +struct UsageLabelWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +// MARK: - Account Row + +private struct AccountRow: View { + let account: ProviderAccount + let snapshot: ProviderUsageSnapshot? + let errorMessage: String? + + @State private var sharedLabelWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(account.displayName) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + if let errorMessage { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.red) + Text(errorMessage) + .font(.system(size: 10)) + .foregroundColor(.red) + .lineLimit(1) + .truncationMode(.tail) + } + .safeHelp(errorMessage) + } else if let snapshot { + VStack(spacing: 1) { + UsageRow( + label: String(localized: "providers.accounts.footer.session", defaultValue: "Sess"), + window: snapshot.session, + sharedLabelWidth: sharedLabelWidth + ) + UsageRow( + label: String(localized: "providers.accounts.footer.week", defaultValue: "Week"), + window: snapshot.week, + sharedLabelWidth: sharedLabelWidth + ) + } + .onPreferenceChange(UsageLabelWidthPreferenceKey.self) { width in + sharedLabelWidth = width + } + } else { + Text(String(localized: "providers.accounts.footer.loading", defaultValue: "loading…")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Usage Row + +private struct UsageRow: View { + @ObservedObject private var colorSettings = ProviderUsageColorSettings.shared + + let label: String + let window: ProviderUsageWindow + /// Width that both rows in a single account card share, so the "Sess" + /// and "Week" labels render at the same width regardless of locale. + /// Starts at 0; the parent reads the max intrinsic label width via a + /// preference key and writes it back here. + let sharedLabelWidth: CGFloat + + private var percent: Int { window.utilization } + + private var pacePercent: Int? { + guard let resetsAt = window.resetsAt else { return nil } + guard window.windowSeconds > 0 else { return nil } + let remaining = max(0, resetsAt.timeIntervalSinceNow) + let elapsed = max(0, min(window.windowSeconds, window.windowSeconds - remaining)) + return Int((elapsed * 100) / window.windowSeconds) + } + + private var metaText: String { + let unavailable = String( + localized: "providers.accounts.countdown.unavailable", + defaultValue: "—" + ) + guard let resetsAt = window.resetsAt else { + return unavailable + } + let interval = resetsAt.timeIntervalSince(Date()) + guard interval > 0 else { + return unavailable + } + + let totalMinutes = Int(interval) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + // Week-scale windows render as `Nd` / `Nd Nh` while at least 24h + // remain, so the column keeps day-level granularity but still shows + // the hour remainder (e.g. `2d 22h` vs a misleading flat `2d`). + // Smaller windows and the final day of a week window surface the + // hour/minute form so the user can see precision when it matters. + // Sub-minute remainders render as "<1m" so the countdown keeps a + // visible value right up to the reset instead of falling to "0m". + let isMultiDayWindow = window.windowSeconds >= 86_400 + if isMultiDayWindow && hours >= 24 { + let days = hours / 24 + let remainderHours = hours % 24 + return remainderHours == 0 + ? CountdownUnitFormat.days(days) + : CountdownUnitFormat.daysHoursSpaced(days: days, hours: remainderHours) + } + if hours > 0 { + return minutes > 0 + ? CountdownUnitFormat.hoursMinutesSpaced(hours: hours, minutes: minutes) + : CountdownUnitFormat.hours(hours) + } + return minutes > 0 + ? CountdownUnitFormat.minutes(minutes) + : CountdownUnitFormat.lessThanMinute() + } + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundColor(.secondary) + .fixedSize(horizontal: true, vertical: false) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: UsageLabelWidthPreferenceKey.self, + value: proxy.size.width + ) + } + ) + .frame( + width: sharedLabelWidth > 0 ? sharedLabelWidth : nil, + alignment: .leading + ) + + Text(String( + localized: "providers.accounts.usage.percent", + defaultValue: "\(percent)%" + )) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(colorSettings.color(for: percent)) + .frame(width: 28, alignment: .trailing) + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.08)) + + RoundedRectangle(cornerRadius: 2) + .fill(colorSettings.color(for: percent).opacity(0.7)) + .frame(width: max(0, geometry.size.width * CGFloat(min(max(percent, 0), 100)) / 100.0)) + + if let pacePercent { + Rectangle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 1) + .offset(x: max(0, min(geometry.size.width - 1, geometry.size.width * CGFloat(pacePercent) / 100.0))) + } + } + } + .frame(height: 5) + + Text(metaText) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + // Width fits the widest compact countdown (`NNh NNm`, `Nd NNh`) + // rendered at size-9 monospaced without truncation; longer + // localized strings expand past the minimum via fixedSize. + .frame(minWidth: 46, alignment: .trailing) + } + .safeHelp(tooltipText) + } + + private var tooltipText: String { + let percentText = String( + localized: "providers.accounts.usage.percent", + defaultValue: "\(percent)%" + ) + let summary = String( + localized: "providers.accounts.usage.summary", + defaultValue: "\(label) \(percentText)" + ) + // Only present the reset phrase for an upcoming reset. A resetsAt in + // the past means the server-side window rolled over but we haven't + // fetched a refresh yet; pairing it with "resets