diff --git a/.github/workflows/upstream-monitor.yml b/.github/workflows/upstream-monitor.yml index 2f7d219a4..0e6f27503 100644 --- a/.github/workflows/upstream-monitor.yml +++ b/.github/workflows/upstream-monitor.yml @@ -78,16 +78,24 @@ jobs: UPSTREAM_BRANCH=$(remote_default_branch upstream) UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env) - UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" - if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then - echo "Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-} from version.env" >&2 + UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env) + if [ -n "$UPSTREAM_MONITOR_BASE" ]; then + UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE" + UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE" + else + UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" + UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION" + fi + if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then + echo "Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-} from version.env" >&2 exit 1 fi echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT echo "upstream_version=$UPSTREAM_VERSION" >> $GITHUB_OUTPUT echo "upstream_base_ref=$UPSTREAM_BASE_REF" >> $GITHUB_OUTPUT + echo "upstream_base_label=$UPSTREAM_BASE_LABEL" >> $GITHUB_OUTPUT - # Count new commits since the upstream version last recorded in version.env. + # Count new commits since the upstream ref last reviewed/merged by QuotaKit. UPSTREAM_NEW=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT @@ -105,12 +113,14 @@ jobs: const upstreamRef = '${{ steps.check.outputs.upstream_ref }}'; const upstreamVersion = '${{ steps.check.outputs.upstream_version }}'; const upstreamBaseRef = '${{ steps.check.outputs.upstream_base_ref }}'; + const upstreamBaseLabel = '${{ steps.check.outputs.upstream_base_label }}'; const upstreamBranch = upstreamRef.replace('upstream/', ''); const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`; const body = `## Upstream Changes Detected - **steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamVersion}\` + **steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamBaseLabel}\` + **Last shipped upstream version:** \`${upstreamVersion}\` **Source refs:** ${upstreamBaseRef}..${upstreamRef} ### steipete/CodexBar Recent Commits @@ -131,7 +141,7 @@ jobs: \`\`\` ### Links - - [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamVersion}...steipete:CodexBar:${upstreamBranch}) + - [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamBaseLabel}...steipete:CodexBar:${upstreamBranch}) --- *Auto-generated by upstream-monitor workflow* @@ -159,7 +169,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issues.data[0].number, - body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamVersion})` + body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamBaseLabel})` }); } else { // Create new issue diff --git a/AGENTS.md b/AGENTS.md index c048ea6ab..f55c40591 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ xcodebuild -project CodexBarMobile/CodexBarMobile.xcodeproj \ CODE_SIGNING_ALLOWED=NO build ``` -Never run checks that can display macOS Keychain prompts unless the user explicitly asks for live provider validation. Prefer parser tests, stubs, test stores, or no-UI keychain queries. +Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts unless the user explicitly asks for live provider validation; use parser tests, stubs, test stores, or `KeychainNoUIQuery`. ## Release Configuration diff --git a/CHANGELOG.md b/CHANGELOG.md index f37940471..5e4e38daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Notable QuotaKit Mac and cross-platform release changes are documented here. Older upstream history is intentionally preserved in Git, but this file now focuses on Columbus Labs QuotaKit releases and product-facing changes. +## Upcoming + +### Changed + +- Synced trusted upstream CodexBar Mac improvements after `v0.32.4`, including + Codex account/auth hardening, MiniMax quota fixes, menu performance updates, + merged provider-switching hang fixes, Claude probe cleanup, + Antigravity/Alibaba/Cursor fixes, and additional Mac localizations. + ## 0.32.4.4 / iOS 1.11.1 — 2026-06-08 ### Fixed diff --git a/Scripts/check_upstreams.sh b/Scripts/check_upstreams.sh index fabedcfc6..eaf5a4b4a 100755 --- a/Scripts/check_upstreams.sh +++ b/Scripts/check_upstreams.sh @@ -67,31 +67,40 @@ echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}" UPSTREAM_BRANCH=$(remote_default_branch upstream) UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env) -UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" -if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then - echo -e "${RED}Error: Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-} from version.env.${NC}" >&2 +UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env) +if [ -n "$UPSTREAM_MONITOR_BASE" ]; then + UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE" + UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE" +else + UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" + UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION" +fi +if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then + echo -e "${RED}Error: Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-} from version.env.${NC}" >&2 exit 1 fi UPSTREAM_COUNT=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') if [ "$UPSTREAM_COUNT" -gt 0 ]; then - echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_VERSION${NC}" + echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_BASE_LABEL${NC}" + echo "Last shipped upstream version: $UPSTREAM_VERSION" echo "" git log --oneline --graph "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges | head -20 || true echo "" echo -e "${YELLOW}Files changed:${NC}" git diff --stat "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" | tail -20 || true else - echo -e "${GREEN}No new commits since $UPSTREAM_VERSION${NC}" + echo -e "${GREEN}No new commits since $UPSTREAM_BASE_LABEL${NC}" + echo "Last shipped upstream version: $UPSTREAM_VERSION" fi echo "" # Summary echo -e "${BLUE}==> Summary${NC}" -echo "Upstream commits since $UPSTREAM_VERSION: $UPSTREAM_COUNT" +echo "Upstream commits since $UPSTREAM_BASE_LABEL: $UPSTREAM_COUNT" echo "" echo -e "${YELLOW}Next steps:${NC}" echo " Review upstream: ./Scripts/review_upstream.sh upstream" -echo " Detailed diff: git diff upstream/$UPSTREAM_VERSION..upstream/$UPSTREAM_BRANCH" +echo " Detailed diff: git diff $UPSTREAM_BASE_REF..upstream/$UPSTREAM_BRANCH" diff --git a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift index 207ca5b8a..891b40961 100644 --- a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift +++ b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift @@ -14,11 +14,53 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un private struct Record: Codable { let id: String + let accountIdentity: AccountIdentity? let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? } + private struct AccountIdentity: Codable, Equatable { + let normalizedEmail: String? + let workspaceAccountID: String? + let authFingerprint: String? + let storedAccountID: UUID? + let selectionSource: CodexActiveSource? + + init(account: CodexVisibleAccount) { + self.normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + self.workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + self.authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + self.storedAccountID = account.storedAccountID + self.selectionSource = account.selectionSource + } + + func matches(_ account: CodexVisibleAccount) -> Bool { + guard self.normalizedEmail == CodexIdentityResolver.normalizeEmail(account.email) else { + return false + } + + let currentWorkspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + if self.workspaceAccountID != nil || currentWorkspaceAccountID != nil { + return self.workspaceAccountID == currentWorkspaceAccountID + } + + let currentAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if self.authFingerprint != nil || currentAuthFingerprint != nil { + return self.authFingerprint == currentAuthFingerprint + } + + if self.storedAccountID != nil || account.storedAccountID != nil { + return self.storedAccountID == account.storedAccountID + } + + guard let selectionSource else { return true } + return selectionSource == account.selectionSource + } + } + private static let currentVersion = 1 private let fileURL: URL @@ -41,6 +83,11 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) return payload.records.compactMap { record in guard let account = accountsByID[record.id] else { return nil } + guard record.accountIdentity?.matches(account) + ?? Self.canHydrateLegacyRecord(record, account: account) + else { + return nil + } return CodexAccountUsageSnapshot( account: account, snapshot: record.snapshot, @@ -55,6 +102,7 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un records: snapshots.map { snapshot in Record( id: snapshot.id, + accountIdentity: AccountIdentity(account: snapshot.account), snapshot: snapshot.snapshot, error: snapshot.error, sourceLabel: snapshot.sourceLabel) @@ -77,6 +125,17 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un } } + private static func canHydrateLegacyRecord(_ record: Record, account: CodexVisibleAccount) -> Bool { + guard record.accountIdentity == nil else { return false } + let normalizedID = CodexIdentityResolver.normalizeEmail(record.id) + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + let isEmailOnlyVisibleID = normalizedID == normalizedEmail + guard isEmailOnlyVisibleID else { return true } + return CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) == nil && + account.storedAccountID == nil && + CodexAuthFingerprint.normalize(account.authFingerprint) == nil + } + static func defaultURL() -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser diff --git a/Sources/CodexBar/CodexLoginRunner.swift b/Sources/CodexBar/CodexLoginRunner.swift index f6734588e..e92933f5a 100644 --- a/Sources/CodexBar/CodexLoginRunner.swift +++ b/Sources/CodexBar/CodexLoginRunner.swift @@ -16,18 +16,23 @@ struct CodexLoginRunner { let output: String } - static func run(homePath: String? = nil, timeout: TimeInterval = 120) async -> Result { + static func run( + homePath: String? = nil, + timeout: TimeInterval = 120, + environment: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current) async -> Result + { await Task(priority: .userInitiated) { - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .tty, .nodeTooling], env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath) guard let executable = BinaryLocator.resolveCodexBinary( env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) else { return Result(outcome: .missingBinary, output: "") } @@ -42,6 +47,11 @@ struct CodexLoginRunner { process.standardOutput = stdout process.standardError = stderr + let termination = ProcessTermination() + process.terminationHandler = { _ in + termination.resolve(timedOut: false) + } + var processGroup: pid_t? do { try process.run() @@ -50,7 +60,7 @@ struct CodexLoginRunner { return Result(outcome: .launchFailed(error.localizedDescription), output: "") } - let timedOut = await self.wait(for: process, timeout: timeout) + let timedOut = await self.wait(timeout: timeout, termination: termination) if timedOut { self.terminate(process, processGroup: processGroup) } @@ -68,23 +78,60 @@ struct CodexLoginRunner { }.value } - private static func wait(for process: Process, timeout: TimeInterval) async -> Bool { - await withTaskGroup(of: Bool.self) { group -> Bool in - group.addTask { - process.waitUntilExit() - return false + private final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var timedOut: Bool? + private var continuation: CheckedContinuation? + + func resolve(timedOut: Bool) { + let continuation: CheckedContinuation? + self.lock.lock() + guard self.timedOut == nil else { + self.lock.unlock() + return } - group.addTask { - let nanos = UInt64(max(0, timeout) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) - return true + self.timedOut = timedOut + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: timedOut) + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + let timedOut: Bool? + self.lock.lock() + timedOut = self.timedOut + if timedOut == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let timedOut { + continuation.resume(returning: timedOut) + } } - let result = await group.next() ?? false - group.cancelAll() - return result } } + private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool { + let timeoutTask = Task.detached(priority: .userInitiated) { + try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout)) + if Task.isCancelled == false { + termination.resolve(timedOut: true) + } + } + let timedOut = await termination.wait() + timeoutTask.cancel() + return timedOut + } + + private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 { + guard timeout.isFinite else { return UInt64.max } + let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000)) + return UInt64(seconds * 1_000_000_000) + } + private static func terminate(_ process: Process, processGroup: pid_t?) { if let pgid = processGroup { kill(-pgid, SIGTERM) diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 09326cbfd..7231ee630 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -9,6 +9,7 @@ struct CodexOwnershipContext { let planUtilizationLegacyEmailHash: String? let currentWeeklyResetAt: Date? let hasAdjacentMultiAccountVeto: Bool + let hasAdjacentEmailScopeAmbiguity: Bool } extension UsageStore { @@ -65,7 +66,43 @@ extension UsageStore { Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) }, currentWeeklyResetAt: currentWeeklyResetAt, - hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto()) + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) + } + + func codexOwnershipContext( + forVisibleAccount account: CodexVisibleAccount, + currentWeeklyResetAt: Date? = nil) -> CodexOwnershipContext + { + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) + let canonicalIdentity: CodexIdentity = if let workspaceAccountID { + .providerAccount(id: workspaceAccountID) + } else if let normalizedEmail { + .emailOnly(normalizedEmail: normalizedEmail) + } else { + .unresolved + } + + return CodexOwnershipContext( + canonicalKey: CodexHistoryOwnership.canonicalKey(for: canonicalIdentity), + canonicalEmailHashKey: normalizedEmail.map { CodexHistoryOwnership.canonicalEmailHashKey(for: $0) }, + historicalLegacyEmailHash: normalizedEmail.map { + CodexHistoryOwnership.legacyEmailHash(normalizedEmail: $0) + }, + planUtilizationLegacyEmailHash: normalizedEmail.map { + Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) + }, + currentWeeklyResetAt: currentWeeklyResetAt, + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto() || + self.codexVisibleAccountsHaveAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) } func codexHasAdjacentMultiAccountVeto() -> Bool { @@ -87,6 +124,59 @@ extension UsageStore { return distinctAccounts.count > 1 } + private func codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let snapshot = self.settings.codexAccountReconciliationSnapshot + var distinctAccounts: Set = [] + + if let activeManagedAccount = self.settings.activeManagedCodexAccount, + CodexIdentityResolver.normalizeEmail(snapshot.runtimeEmail(for: activeManagedAccount)) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: activeManagedAccount), + fallbackEmail: snapshot.runtimeEmail(for: activeManagedAccount))) + } + + if let liveSystemAccount = snapshot.liveSystemAccount, + CodexIdentityResolver.normalizeEmail(liveSystemAccount.email) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: liveSystemAccount), + fallbackEmail: liveSystemAccount.email)) + } + + return distinctAccounts.count > 1 + } + + private func codexVisibleAccountsHaveAdjacentMultiAccountVeto() -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else if let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + + private func codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts where CodexIdentityResolver.normalizeEmail(account.email) == normalizedEmail { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + nonisolated static func codexLegacyPlanUtilizationEmailHashKey(for normalizedEmail: String) -> String { self.sha256Hex("\(UsageProvider.codex.rawValue):email:\(normalizedEmail)") } diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 8fbdf3217..bd25d5e6b 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -16,12 +16,16 @@ private func appLanguageDefaults() -> UserDefaults { return UserDefaults(suiteName: "CodexBar") ?? .standard } -private func isRunningTestsProcess() -> Bool { +private let isRunningTestsProcessAtStartup: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil +}() + +private func isRunningTestsProcess() -> Bool { + isRunningTestsProcessAtStartup } private let standardAppLanguageAtProcessStart = UserDefaults.standard.string(forKey: "appLanguage") @@ -41,10 +45,73 @@ func codexBarLocalizationSignature() -> String { resolvedAppLanguage() } +/// Resolving the `.lproj`/resource bundles repeats `Bundle(url:)`/`Bundle(path:)` filesystem lookups, +/// which are surprisingly hot: every `L(…)` and `codexBarLocalizationSignature()` call runs them, and +/// menu row bodies (`MetricRow`, `ProviderCostContent`, `UsageMenuCardView.Model`) re-evaluate them on +/// every closed-menu rebuild tick on the main thread (#1347). The resolved bundles never change unless +/// the language changes, so cache them. A single lock with compute-happening-outside-the-lock keeps the +/// disk work off the critical section and avoids re-entrant deadlock when the localized-bundle compute +/// closure calls back into the resource-bundle accessor. +private enum LocalizationBundleCache { + private static let lock = NSLock() + private nonisolated(unsafe) static var resourceBundle: Bundle? + private nonisolated(unsafe) static var cachedLanguage: String? + private nonisolated(unsafe) static var cachedLocalizedBundle: Bundle? + + static func defaultResourceBundle(_ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if let resourceBundle { + self.lock.unlock() + return resourceBundle + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + resourceBundle = computed + self.lock.unlock() + return computed + } + + static func localizedBundle(forLanguage language: String, _ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if self.cachedLanguage == language, let cachedLocalizedBundle { + let hit = cachedLocalizedBundle + self.lock.unlock() + return hit + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + self.cachedLanguage = language + cachedLocalizedBundle = computed + self.lock.unlock() + return computed + } + + static func reset() { + self.lock.lock() + self.resourceBundle = nil + self.cachedLanguage = nil + self.cachedLocalizedBundle = nil + self.lock.unlock() + } +} + func codexBarLocalizationResourceBundle( mainBundle: Bundle = .main, bundleName: String = "CodexBar_CodexBar") -> Bundle { + // Only the default (process `.main`) resolution is cached: it is constant for the lifetime of the + // process. Custom arguments (tests) keep resolving directly so they stay isolated from the cache. + guard mainBundle === Bundle.main, bundleName == "CodexBar_CodexBar" else { + return resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } + return LocalizationBundleCache.defaultResourceBundle { + resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } +} + +private func resolveLocalizationResourceBundle(mainBundle: Bundle, bundleName: String) -> Bundle { guard mainBundle.bundleURL.pathExtension == "app" else { return Bundle.module } @@ -65,8 +132,16 @@ func codexBarLocalizationResourceBundle( } private func localizedBundle() -> Bundle { - let resourceBundle = codexBarLocalizationResourceBundle() + // Keyed on the resolved language so a language switch (settings change or test override) transparently + // re-resolves; otherwise the cached bundle is returned without touching the filesystem. let language = resolvedAppLanguage() + return LocalizationBundleCache.localizedBundle(forLanguage: language) { + resolveLocalizedBundle(forLanguage: language) + } +} + +private func resolveLocalizedBundle(forLanguage language: String) -> Bundle { + let resourceBundle = codexBarLocalizationResourceBundle() if !language.isEmpty { if let bundle = lprojBundle(named: language, in: resourceBundle) { return bundle @@ -141,6 +216,16 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } +#if DEBUG +func codexBarLocalizedBundleForTesting() -> Bundle { + localizedBundle() +} + +func resetCodexBarLocalizationCacheForTesting() { + LocalizationBundleCache.reset() +} +#endif + func configureUsageFormatterLocalizationProvider() { UsageFormatter.setLocalizationProvider { key in let resourceBundle = codexBarLocalizationResourceBundle() diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 520878d4d..20085b502 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -84,7 +84,10 @@ enum MenuBarMetricWindowResolver { private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { if provider == .antigravity { - return self.window(in: snapshot, following: [.primary, .secondary, .tertiary]) + return self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) } if provider == .perplexity { return snapshot.automaticPerplexityWindow() @@ -104,7 +107,7 @@ enum MenuBarMetricWindowResolver { { return primary.usedPercent >= secondary.usedPercent ? primary : secondary } - if provider == .cursor { + if provider == .cursor || provider == .minimax { return Self.mostConstrainedWindow( primary: snapshot.primary, secondary: snapshot.secondary, diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift new file mode 100644 index 000000000..440c24ca2 --- /dev/null +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -0,0 +1,162 @@ +import Foundation + +extension UsageMenuCardView.Model { + func heightFingerprint(section: String, additional: [String] = []) -> String { + let notesFingerprint = MenuCardHeightFingerprint.join(self.usageNotes.map { + MenuCardHeightFingerprint.field("note", $0) + }) + return MenuCardHeightFingerprint.join([ + "section=\(section)", + "provider=\(self.provider.rawValue)", + "localization=\(codexBarLocalizationSignature())", + MenuCardHeightFingerprint.field("name", self.providerName), + MenuCardHeightFingerprint.field("email", self.email), + MenuCardHeightFingerprint.field("subtitle", self.subtitleText), + "subtitleStyle=\(self.subtitleStyle.heightFingerprint)", + MenuCardHeightFingerprint.field("plan", self.planText), + MenuCardHeightFingerprint.field("placeholder", self.placeholder), + MenuCardHeightFingerprint.field("credits", self.creditsText), + "creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")", + MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText), + MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText), + "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", + "notes=\(notesFingerprint)", + "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", + "providerCost=\(self.providerCost?.heightFingerprint ?? "")", + "tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")", + "openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")", + ] + additional) + } + + static func heightFingerprintField(_ name: String, _ value: String?) -> String { + MenuCardHeightFingerprint.field(name, value) + } +} + +private enum MenuCardHeightFingerprint { + private static let hashSalt = UUID() + + static func join(_ values: [String]) -> String { + values.map { "\($0.count):\($0)" }.joined(separator: "|") + } + + static func field(_ name: String, _ value: String?) -> String { + guard let value else { + return "\(name)=nil" + } + return "\(name)=\(Self.stringShape(value))" + } + + private static func stringShape(_ value: String) -> String { + var hasher = Hasher() + hasher.combine(Self.hashSalt) + hasher.combine(value) + let digest = String(UInt(bitPattern: hasher.finalize()), radix: 16) + return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(Self.lineCount(value)),hash:\(digest)" + } + + private static func lineCount(_ value: String) -> Int { + guard !value.isEmpty else { return 0 } + return value.utf8.reduce(1) { count, byte in + byte == 10 ? count + 1 : count + } + } +} + +extension UsageMenuCardView.Model.SubtitleStyle { + fileprivate var heightFingerprint: String { + switch self { + case .info: "info" + case .loading: "loading" + case .error: "error" + } + } +} + +extension UsageMenuCardView.Model.Metric { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + MenuCardHeightFingerprint.field("title", self.title), + "percent=\(Int(self.percent.rounded()))", + "percentStyle=\(self.percentStyle.rawValue)", + MenuCardHeightFingerprint.field("status", self.statusText), + MenuCardHeightFingerprint.field("reset", self.resetText), + MenuCardHeightFingerprint.field("detail", self.detailText), + MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), + MenuCardHeightFingerprint.field("detailRight", self.detailRightText), + self.pacePercent == nil ? "pace=0" : "pace=1", + self.paceOnTop ? "paceTop=1" : "paceTop=0", + self.cardStyle ? "card=1" : "card=0", + "markers=\(self.warningMarkerPercents.count)", + ]) + } +} + +extension UsageMenuCardView.Model.ProviderCostSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("spend", self.spendLine), + MenuCardHeightFingerprint.field("percentLine", self.percentLine), + self.percentUsed == nil ? "percent=0" : "percent=1", + ]) + } +} + +extension UsageMenuCardView.Model.TokenUsageSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("session", self.sessionLine), + MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("hint", self.hintLine), + MenuCardHeightFingerprint.field("error", self.errorLine), + MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), + ]) + } +} + +extension InlineUsageDashboardModel { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("accessibility", self.accessibilityLabel), + self.valueStyle.heightFingerprint, + MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.detailLines.map { MenuCardHeightFingerprint.field("detail", $0) }), + ]) + } +} + +extension InlineUsageDashboardModel.KPI { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("value", self.value), + self.emphasis ? "1" : "0", + ]) + } +} + +extension InlineUsageDashboardModel.Point { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + MenuCardHeightFingerprint.field("label", self.label), + MenuCardHeightFingerprint.field("accessibilityValue", self.accessibilityValue), + ]) + } +} + +extension InlineUsageDashboardModel.ValueStyle { + fileprivate var heightFingerprint: String { + switch self { + case .currencyUSD: + "currencyUSD" + case let .currency(symbol): + "currency:\(symbol)" + case .tokens: + "tokens" + } + } +} diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 37973a99a..62a25cb49 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -166,6 +166,15 @@ extension UsageMenuCardView.Model { percentLine: nil) } + if provider == .minimax, cost.period == "MiniMax points balance" { + let balance = String(format: "%.0f", cost.used) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + if provider == .openai || provider == .claude, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index dda62d32f..d41077061 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -4,21 +4,22 @@ import Foundation extension UsageMenuCardView.Model { static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { let percentStyle: PercentStyle = .used - let textGenerationCount = services.count { $0.displayName == "Text Generation" } + let displayNameCounts = Dictionary(grouping: services.map(\.displayName), by: { $0 }).mapValues(\.count) return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = String( - format: L("minimax_usage_amount_format"), - used.formatted(), - service.limit.formatted()) - let usedLabel = String( - format: L("minimax_used_percent_format"), - String(format: "%.0f%%", displayPercent)) + let usageLabel = if service.isUnlimited { + nil as String? + } else { + String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + } let localizedName = Self.localizedMiniMaxServiceName(service.displayName) - let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { - "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" + let title = if (displayNameCounts[service.displayName] ?? 0) > 1 { + "\(localizedName) · \(Self.displayWindowBadge(for: service.windowType))" } else { localizedName } @@ -28,13 +29,60 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, + statusText: service.isUnlimited ? "∞ Unlimited" : nil, resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), - detailText: service.timeRange, + detailText: nil, detailLeftText: usageLabel, - detailRightText: usedLabel, + detailRightText: nil, pacePercent: nil, paceOnTop: true, - cardStyle: true) + warningMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWarningMarkerPercents(service: service, input: input), + cardStyle: false) + } + } + + private static func miniMaxWarningMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + switch self.miniMaxQuotaWarningWindow(for: service) { + case .session: + warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: true) + case .weekly: + markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: true, + workDays: input.workDaysPerWeek, + windowMinutes: self.miniMaxWindowMinutes(for: service.windowType), + includeWorkdayMarkers: true) + } + } + + private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow { + service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session + } + + private static func miniMaxWindowMinutes(for windowType: String) -> Int? { + let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "weekly" { + return 7 * 24 * 60 + } + if normalized == "today" || normalized == "daily" { + return 24 * 60 + } + if normalized == "5h" { + return 5 * 60 + } + let pieces = normalized.split(separator: " ") + guard pieces.count >= 2, let value = Int(pieces[0]) else { return nil } + switch pieces[1] { + case "hour", "hours", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins": + return value + default: + return nil } } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 645e28dd5..3ba3a7c02 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -133,6 +133,25 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + static func cursorBillingCyclePaceDetail( + window: RateWindow, + input: Input, + pace: UsagePace? = nil) -> PaceDetail? + { + guard input.provider == .cursor, + window.windowMinutes != nil + else { return nil } + let resolved = pace ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + guard let resolved, + resolved.expectedUsedPercent >= 3 + else { return nil } + return Self.weeklyPaceDetail( + window: window, + now: input.now, + pace: resolved, + showUsed: input.usageBarsShowUsed) + } + static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left var metrics = [ @@ -175,7 +194,11 @@ extension UsageMenuCardView.Model { return [] } return extraRateWindows.map { namedWindow in - Metric( + let paceDetail = Self.extraRateWindowPaceDetail( + provider: input.provider, + window: namedWindow.window, + input: input) + return Metric( id: namedWindow.id, title: namedWindow.title, percent: Self.clamped( @@ -188,10 +211,36 @@ extension UsageMenuCardView.Model { style: input.resetTimeDisplayStyle, now: input.now), detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true) + } + } + + private static func extraRateWindowPaceDetail( + provider: UsageProvider, + window: RateWindow, + input: Input) -> PaceDetail? + { + guard provider == .codex else { return nil } + switch window.windowMinutes { + case 300: + return self.sessionPaceDetail( + provider: provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case 10080: + let pace = UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil } + return Self.weeklyPaceDetail( + window: window, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + default: + return nil } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7601233dc..1f5d6a8d1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -845,8 +845,10 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider) + if input.provider == .kiro { - return kiroUsageNotes(input: input) + return kiroUsageNotes(input: input) + subscriptionNotes } if input.provider == .kilo { @@ -860,24 +862,24 @@ extension UsageMenuCardView.Model { { notes.append(L("Using CLI fallback")) } - return notes + return notes + subscriptionNotes } if input.provider == .mimo, input.snapshot != nil { return [ L("Balance updates in near-real time (up to 5 min lag)"), L("Daily billing data finalizes at 07:00 UTC"), - ] + ] + subscriptionNotes } if let notes = apiProviderUsageNotes(input: input) { - return notes + return notes + subscriptionNotes } guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { - return [] + return subscriptionNotes } var notes = Self.openRouterSpendNotes(openRouter) @@ -889,7 +891,35 @@ extension UsageMenuCardView.Model { case .unavailable: notes.append(L("API key limit unavailable right now")) } - return notes + return notes + subscriptionNotes + } + + private static func subscriptionMetadataNotes(snapshot: UsageSnapshot?, provider: UsageProvider) -> [String] { + guard let snapshot else { return [] } + if let renewsAt = snapshot.subscriptionRenewsAt { + return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt, provider: provider))] + } + if let expiresAt = snapshot.subscriptionExpiresAt { + return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt, provider: provider))] + } + return [] + } + + private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = self.subscriptionDateTimeZone(provider: provider) + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: date) + } + + private static func subscriptionDateTimeZone(provider: UsageProvider) -> TimeZone { + switch provider { + case .minimax: + TimeZone(identifier: "Asia/Shanghai") ?? .current + default: + .current + } } private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { @@ -952,6 +982,9 @@ extension UsageMenuCardView.Model { } private static func planDisplay(_ text: String, for provider: UsageProvider) -> String { + if provider == .minimax { + return self.miniMaxPlanDisplay(text) + } let cleaned = if provider == .codex { CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) } else { @@ -960,6 +993,21 @@ extension UsageMenuCardView.Model { return cleaned.isEmpty ? text : cleaned } + private static func miniMaxPlanDisplay(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.lowercased() + if normalized.contains("tokenplanplus") || normalized.contains("token plan plus") { + return "Plus" + } + if normalized.contains("tokenplanmax") || normalized.contains("token plan max") { + return "Max" + } + if normalized.contains("tokenplanultra") || normalized.contains("token plan ultra") { + return "Ultra" + } + return trimmed + } + private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? { self.kiloLoginParts(snapshot: snapshot).pass } @@ -1057,7 +1105,8 @@ extension UsageMenuCardView.Model { } if input.provider == .minimax { if let minimaxUsage = snapshot.minimaxUsage { - if let services = minimaxUsage.services, !services.isEmpty { + let services = minimaxUsage.orderedQuotaServices + if !services.isEmpty { return Self.minimaxMetrics(services: services, input: input) } } @@ -1107,6 +1156,7 @@ extension UsageMenuCardView.Model { let opusResetText: String? = input.provider == .perplexity ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) + let tertiaryPaceDetail = Self.cursorBillingCyclePaceDetail(window: opus, input: input) metrics.append(Metric( id: "tertiary", title: labels.tertiary, @@ -1114,10 +1164,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: opusResetText, detailText: tertiaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true, + detailLeftText: tertiaryPaceDetail?.leftLabel, + detailRightText: tertiaryPaceDetail?.rightLabel, + pacePercent: tertiaryPaceDetail?.pacePercent, + paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( thresholds: input.quotaWarningThresholds[.weekly], showUsed: input.usageBarsShowUsed))) @@ -1270,6 +1320,12 @@ extension UsageMenuCardView.Model { } } } + if let paceDetail = Self.cursorBillingCyclePaceDetail(window: primary, input: input) { + primaryDetailLeft = paceDetail.leftLabel + primaryDetailRight = paceDetail.rightLabel + primaryPacePercent = paceDetail.pacePercent + primaryPaceOnTop = paceDetail.paceOnTop + } if input.provider == .synthetic, let regen = Self.syntheticRollingRegenDetail( window: primary, @@ -1371,6 +1427,13 @@ extension UsageMenuCardView.Model { { paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true) } + if let cursorPaceDetail = Self.cursorBillingCyclePaceDetail( + window: weekly, + input: input, + pace: input.weeklyPace) + { + paceDetail = cursorPaceDetail + } // Perplexity bonus credits don't reset; show balance without "Resets" prefix. if input.provider == .perplexity, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 181b49633..0854310f9 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,6 +11,10 @@ enum AppLanguage: String, CaseIterable, Identifiable { case chineseTraditional = "zh-Hant" case portugueseBrazilian = "pt-BR" case swedish = "sv" + case french = "fr" + case dutch = "nl" + case ukrainian = "uk" + case vietnamese = "vi" var id: String { self.rawValue @@ -26,6 +30,10 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .chineseTraditional: L("language_chinese_traditional") case .portugueseBrazilian: L("language_portuguese_brazilian") case .swedish: L("language_swedish") + case .french: L("language_french") + case .dutch: L("language_dutch") + case .ukrainian: L("language_ukrainian") + case .vietnamese: L("language_vietnamese") } } } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index eeeb541b1..6503af373 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -142,19 +142,16 @@ struct ProviderRegistry { } } } - // Managed Codex routing only scopes remote account fetches such as identity, plan, - // quotas, and dashboard data, and only when the active source is a managed account. - // Token-cost/session history is intentionally not routed through the managed home - // because that data is currently treated as provider-level local telemetry from this - // Mac's Codex sessions, not as account-owned remote state. If we later want - // account-scoped token history in the UI, that needs an explicit product decision and - // presentation change so the two concepts are not conflated. + // Codex account routing scopes remote account fetches such as identity, plan, + // quotas, and dashboard data. Token-cost/session history is intentionally handled + // separately because it is provider-level local telemetry from this Mac's Codex sessions, + // not account-owned remote state. if provider == .codex { let codexActiveSource = codexActiveSourceOverride ?? settings.codexResolvedActiveSource - if case .managedAccount = codexActiveSource, - let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) - { + if let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) + } else if let liveHomePath = settings.liveSystemCodexHomePath(forActiveSource: codexActiveSource) { + env = CodexHomeScope.scopedEnvironment(base: env, codexHome: liveHomePath) } } return env diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 592da4899..1f7987b16 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -70,6 +70,18 @@ extension SettingsStore { self.managedCodexRemoteHomePath(forActiveSource: self.codexResolvedActiveSource) } + func liveSystemCodexHomePath(forActiveSource source: CodexActiveSource) -> String? { + guard source == .liveSystem else { + return nil + } + let path = self.codexAccountReconciliationSnapshot(activeSourceOverride: source) + .liveSystemAccount?.codexHomePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard let path, !path.isEmpty else { + return nil + } + return path + } + func managedCodexRemoteHomePath(forActiveSource source: CodexActiveSource) -> String? { guard case let .managedAccount(id) = source else { return nil diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 0952a47ae..190ca2098 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -13,6 +13,19 @@ struct CodexAccountScopedRefreshGuard: Equatable { let source: CodexActiveSource let identity: CodexIdentity let accountKey: String? + let authFingerprint: String? + + init( + source: CodexActiveSource, + identity: CodexIdentity, + accountKey: String?, + authFingerprint: String? = nil) + { + self.source = source + self.identity = identity + self.accountKey = accountKey + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) + } } @MainActor @@ -34,7 +47,7 @@ extension UsageStore { phaseDidChange?(.credits) if self.settings.codexCookieSource.isEnabled { - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, @@ -55,13 +68,15 @@ extension UsageStore { @discardableResult func prepareCodexAccountScopedRefreshIfNeeded() -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard( + let currentGuard = self.freshCodexAccountScopedRefreshGuard( preferCurrentSnapshot: false, allowLastKnownLiveFallback: false) let previousGuard = self.lastCodexAccountScopedRefreshGuard self.lastCodexAccountScopedRefreshGuard = currentGuard - guard previousGuard != nil, previousGuard != currentGuard else { return false } + guard let previousGuard, + !Self.codexScopedRefreshGuardsMatchAccount(previousGuard, currentGuard) + else { return false } self.snapshots.removeValue(forKey: .codex) self.errors[.codex] = nil @@ -72,6 +87,7 @@ extension UsageStore { self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) + self.lastKnownResetSnapshots.removeValue(forKey: .codex) self.credits = nil self.lastCreditsError = nil @@ -105,7 +121,8 @@ extension UsageStore { self.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( source: resolvedSource, identity: resolvedIdentity, - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: resolvedSource)) } func currentCodexAccountScopedRefreshGuard( @@ -120,7 +137,8 @@ extension UsageStore { allowLastKnownLiveFallback: allowLastKnownLiveFallback), accountKey: self.codexAccountScopedRefreshKey( preferCurrentSnapshot: preferCurrentSnapshot, - allowLastKnownLiveFallback: allowLastKnownLiveFallback)) + allowLastKnownLiveFallback: allowLastKnownLiveFallback), + authFingerprint: self.currentCodexAuthFingerprint(source: self.settings.codexResolvedActiveSource)) } func currentCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { @@ -136,36 +154,77 @@ extension UsageStore { return CodexAccountScopedRefreshGuard( source: source, identity: self.currentCodexOpenAIWebIdentity(source: source), - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: source)) + } + + func freshCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: Bool = true, + allowLastKnownLiveFallback: Bool = true) -> CodexAccountScopedRefreshGuard + { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: preferCurrentSnapshot, + allowLastKnownLiveFallback: allowLastKnownLiveFallback) + } + + func freshCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexOpenAIWebRefreshGuard() } func shouldApplyCodexUsageResult( expectedGuard: CodexAccountScopedRefreshGuard, usage: UsageSnapshot) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + let fingerprintsAllowApply = Self.codexGuardAuthFingerprintAllowsUsageApply( + currentGuard, + expectedGuard) + let expectedAuthFingerprint = CodexAuthFingerprint.normalize(expectedGuard.authFingerprint) + let currentAuthFingerprint = CodexAuthFingerprint.normalize(currentGuard.authFingerprint) + let canProveNilToCurrentAuth = expectedAuthFingerprint == nil && currentAuthFingerprint != nil + let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) + let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex)) + let resultMatchesCurrentAccountKey = Self.codexUsageResultAccountKeyMatchesCurrentGuard( + resultAccountKey, + expectedGuard: expectedGuard, + currentGuard: currentGuard) if expectedGuard.identity != .unresolved { - return currentGuard.identity == expectedGuard.identity + guard currentGuard.identity == expectedGuard.identity else { return false } + if fingerprintsAllowApply { + guard case .managedAccount = currentGuard.source else { return true } + return resultMatchesCurrentAccountKey + } + guard canProveNilToCurrentAuth else { return false } + guard resultMatchesCurrentAccountKey else { return false } + return resultIdentity == currentGuard.identity || + (resultAccountKey != nil && resultAccountKey == currentGuard.accountKey) } - let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) if currentGuard.identity != .unresolved { - return resultIdentity == currentGuard.identity + guard resultIdentity == currentGuard.identity else { return false } + return fingerprintsAllowApply || canProveNilToCurrentAuth } switch currentGuard.source { case .liveSystem: - return resultIdentity != .unresolved + guard resultIdentity != .unresolved else { return false } + if fingerprintsAllowApply { return true } + guard canProveNilToCurrentAuth else { return false } + guard let currentAccountKey = currentGuard.accountKey else { return true } + return resultAccountKey == currentAccountKey case .managedAccount: return false } } func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -174,9 +233,25 @@ extension UsageStore { return currentGuard.identity == .unresolved } + func codexScopedNonUsageSuccessApplyGuard( + expectedGuard: CodexAccountScopedRefreshGuard) -> CodexAccountScopedRefreshGuard? + { + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return nil } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return nil } + guard expectedGuard.identity != .unresolved else { return nil } + guard currentGuard.identity == expectedGuard.identity else { return nil } + return currentGuard + } + func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) != nil + } + + func shouldApplyCodexScopedNonUsageFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } return currentGuard.identity == expectedGuard.identity } @@ -186,8 +261,9 @@ extension UsageStore { routingTargetEmail: String?) -> Bool { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) - let currentGuard = self.currentCodexOpenAIWebRefreshGuard() + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -205,9 +281,44 @@ extension UsageStore { expectedGuard: CodexAccountScopedRefreshGuard, routingTargetEmail: String?) -> Bool { - self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: routingTargetEmail) + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + + if expectedGuard.identity != .unresolved { + return currentGuard.identity == expectedGuard.identity + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail + } + + func shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: CodexAccountScopedRefreshGuard, + routingTargetEmail: String?) -> Bool + { + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + + if expectedGuard.identity != .unresolved { + guard currentGuard.identity == expectedGuard.identity else { return false } + return Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) || + Self.codexGuardAuthFingerprintAllowsSameProviderAccount(currentGuard, expectedGuard) + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail } func codexDashboardKnownOwnerCandidates() -> [CodexDashboardKnownOwnerCandidate] { @@ -276,6 +387,83 @@ extension UsageStore { self.lastKnownLiveSystemCodexEmail = normalized } + nonisolated static func codexGuardAuthFingerprintMatches( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + if lhsFingerprint != nil || rhsFingerprint != nil { + return lhsFingerprint == rhsFingerprint + } + return true + } + + nonisolated static func codexGuardAuthFingerprintAllowsUsageApply( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + if self.codexGuardAuthFingerprintMatches(lhs, rhs) { + return true + } + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard lhsFingerprint != nil, rhsFingerprint != nil else { return false } + guard case .providerAccount = rhs.identity, lhs.identity == rhs.identity else { return false } + guard case .liveSystem = lhs.source else { return true } + return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey + } + + private nonisolated static func codexGuardAuthFingerprintAllowsSameProviderAccount( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard lhsFingerprint != nil, rhsFingerprint != nil else { return false } + guard case .providerAccount = rhs.identity else { return false } + return lhs.identity == rhs.identity + } + + nonisolated static func codexScopedRefreshGuardsMatchAccount( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + guard lhs.source == rhs.source else { return false } + if lhs == rhs { return true } + guard lhs.identity != .unresolved, + lhs.identity == rhs.identity, + lhs.accountKey == rhs.accountKey + else { + return false + } + return self.codexGuardAuthFingerprintAllowsUsageApply(lhs, rhs) + } + + private nonisolated static func codexUsageResultAccountKeyMatchesCurrentGuard( + _ resultAccountKey: String?, + expectedGuard: CodexAccountScopedRefreshGuard, + currentGuard: CodexAccountScopedRefreshGuard) -> Bool + { + guard let currentAccountKey = currentGuard.accountKey else { return true } + guard let resultAccountKey else { + guard let expectedAccountKey = expectedGuard.accountKey else { return true } + return expectedAccountKey == currentAccountKey + } + return resultAccountKey == currentAccountKey + } + + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { + let snapshot = self.settings.codexAccountReconciliationSnapshot + switch source { + case .liveSystem: + return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) + case let .managedAccount(id): + guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil } + return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + } + } + func codexAccountScopedRefreshKey( preferCurrentSnapshot: Bool = true, allowLastKnownLiveFallback: Bool = true) -> String? diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index dc0608017..540adf782 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -15,7 +15,7 @@ extension UsageStore { func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) { let refreshKey = self.codexCreditsRefreshKey( - expectedGuard: self.currentCodexAccountScopedRefreshGuard()) + expectedGuard: self.freshCodexAccountScopedRefreshGuard()) if let existing = self.creditsRefreshTask, !existing.isCancelled, self.creditsRefreshTaskKey == refreshKey @@ -71,18 +71,19 @@ extension UsageStore { sourceKey, identityKey, expectedGuard.accountKey ?? "account:nil", + "auth:\(expectedGuard.authFingerprint ?? "nil")", ].joined(separator: "|") } func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } - var expectedGuard = self.currentCodexAccountScopedRefreshGuard() + var expectedGuard = self.freshCodexAccountScopedRefreshGuard() if expectedGuard.identity == .unresolved, let minimumSnapshotUpdatedAt, case .liveSystem = expectedGuard.source { _ = await self.waitForCodexSnapshotOrRefreshCompletion(minimumUpdatedAt: minimumSnapshotUpdatedAt) - expectedGuard = self.currentCodexAccountScopedRefreshGuard() + expectedGuard = self.freshCodexAccountScopedRefreshGuard() } guard expectedGuard.identity != .unresolved, expectedGuard.accountKey != nil @@ -92,15 +93,16 @@ extension UsageStore { do { let credits = try await self.loadLatestCodexCredits() guard !Task.isCancelled else { return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard( + expectedGuard: expectedGuard) else { return } await MainActor.run { self.credits = credits self.lastCreditsError = nil self.lastCreditsSnapshot = credits - self.lastCreditsSnapshotAccountKey = expectedGuard.accountKey + self.lastCreditsSnapshotAccountKey = applyGuard.accountKey self.lastCreditsSource = .api self.creditsFailureStreak = 0 - self.lastCodexAccountScopedRefreshGuard = expectedGuard + self.lastCodexAccountScopedRefreshGuard = applyGuard } let codexSnapshot = await MainActor.run { self.snapshots[.codex] @@ -123,7 +125,7 @@ extension UsageStore { guard !Task.isCancelled else { return } let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } await MainActor.run { if let cached = self.lastCreditsSnapshot, self.lastCreditsSnapshotAccountKey == expectedGuard.accountKey @@ -140,7 +142,7 @@ extension UsageStore { return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } await MainActor.run { self.creditsFailureStreak += 1 if let cached = self.lastCreditsSnapshot, diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 1550c0005..b1e7a68a8 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1,918 +1,1814 @@ /* Catalan localization for CodexBar */ " providers" = " proveïdors"; + "(System)" = "(Sistema)"; + "30d" = "30 d"; + "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir "; + "API key" = "Clau d'API"; + "API region" = "Regió de l'API"; + "API token" = "Testimoni d'API"; + "API tokens" = "Testimonis d'API"; + "About" = "Quant a"; + "Account" = "Compte"; + "Accounts" = "Comptes"; + "Accounts subtitle" = "Subtítol de comptes"; + "Active" = "Actiu"; + "Add" = "Afegeix"; + "Add Workspace" = "Afegeix espai de treball"; + "Advanced" = "Avançat"; + "All" = "Tot"; + "Always allow prompts" = "Permet sempre les sol·licituds"; + "Animation pattern" = "Patró d'animació"; + "Antigravity login is managed in the app" = "L'inici de sessió d'Antigravity es gestiona a l'app"; + "Applies only to the Security.framework OAuth keychain reader." = "Només s'aplica al lector de Clauer OAuth de Security.framework."; + "Auto falls back to the next source if the preferred one fails." = "Auto recorre a la font següent si la preferida falla."; + "Auto uses API first, then falls back to CLI on auth failures." = "Auto fa servir primer l'API i recorre a la CLI si falla l'autenticació."; + "Auto-detect" = "Detecció automàtica"; + "Auto-refresh is off; use the menu's Refresh command." = "L'actualització automàtica està desactivada; fes servir l'ordre Actualitza del menú."; + "Auto-refresh: hourly · Timeout: 10m" = "Actualització automàtica: cada hora · Temps d'espera: 10 m"; + "Automatic" = "Automàtic"; + "Automatic imports browser cookies and WorkOS tokens." = "El mode automàtic importa galetes del navegador i testimonis de WorkOS."; + "Automatic imports browser cookies and local storage tokens." = "El mode automàtic importa galetes del navegador i testimonis de l'emmagatzematge local."; + "Automatic imports browser cookies for dashboard extras." = "El mode automàtic importa galetes del navegador per als extres del tauler."; + "Automatic imports browser cookies for the web API." = "El mode automàtic importa galetes del navegador per a l'API web."; + "Automatic imports browser cookies from Model Studio/Bailian." = "El mode automàtic importa galetes del navegador des de Model Studio/Bailian."; + "Automatic imports browser cookies from admin.mistral.ai." = "El mode automàtic importa galetes del navegador des d'admin.mistral.ai."; + "Automatic imports browser cookies from opencode.ai." = "El mode automàtic importa galetes del navegador des d'opencode.ai."; + "Automatic imports browser cookies or stored sessions." = "El mode automàtic importa galetes del navegador o sessions desades."; + "Automatic imports browser cookies." = "El mode automàtic importa galetes del navegador."; + "Automatically imports browser session cookie." = "Importa automàticament la galeta de sessió del navegador."; + "Automatically opens CodexBar when you start your Mac." = "Obre el QuotaKit automàticament en iniciar el Mac."; + "Automation" = "Automatització"; + "Average (\\(label1) + \\(label2))" = "Mitjana (\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Mitjana (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "Evita les sol·licituds del Clauer"; + "Balance" = "Saldo"; + "Battery Saver" = "Estalvi de bateria"; + "Bordered" = "Amb vora"; + "Build" = "Compilació"; + "Built \\(buildTimestamp)" = "Compilat \\(buildTimestamp)"; + "Buy Credits..." = "Compra crèdits..."; + "Buy Credits…" = "Compra crèdits…"; + "CLI paths" = "Camins de la CLI"; + "CLI sessions" = "Sessions de la CLI"; + "Caches" = "Memòries cau"; + "Cancel" = "Cancel·la"; + "Check for Updates…" = "Cerca actualitzacions…"; + "Check for updates automatically" = "Cerca actualitzacions automàticament"; + "Check if you like your agents having some fun up there." = "Activa-ho si t'agrada que els teus agents es diverteixin allà dalt."; + "Check provider status" = "Comprova l'estat del proveïdor"; + "Choose Codex workspace" = "Tria l'espai de treball de Codex"; + "Choose the MiniMax host (global .io or China mainland .com)." = "Tria l'amfitrió de MiniMax (global .io o la Xina continental .com)."; + "Choose up to " = "Tria fins a "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "Tria fins a \\(Self.maxOverviewProviders) proveïdors"; + "Choose up to \\(count) providers" = "Tria fins a \\(count) proveïdors"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Tria què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; + "Choose which Codex account CodexBar should follow." = "Tria quin compte de Codex ha de seguir el QuotaKit."; + "Choose which window drives the menu bar percent." = "Tria quina finestra determina el percentatge de la barra de menús."; + "Chrome" = "Chrome"; + "Claude CLI not found" = "No s'ha trobat la CLI de Claude"; + "Claude binary" = "Binari de Claude"; + "Claude cookies" = "Galetes de Claude"; + "Claude login failed" = "L'inici de sessió de Claude ha fallat"; + "Claude login timed out" = "L'inici de sessió de Claude ha esgotat el temps d'espera"; + "Close" = "Tanca"; + "Codex CLI not found" = "No s'ha trobat la CLI de Codex"; + "Codex account login already running" = "Ja hi ha un inici de sessió de compte de Codex en curs"; + "Codex binary" = "Binari de Codex"; + "Codex login failed" = "L'inici de sessió de Codex ha fallat"; + "Codex login timed out" = "L'inici de sessió de Codex ha esgotat el temps d'espera"; + "CodexBar Lifecycle Keepalive" = "Manteniment del cicle de vida del QuotaKit"; + "QuotaKit can't show its menu bar icon" = "El QuotaKit no pot mostrar la seva icona a la barra de menús"; + "CodexBar could not read managed account storage. " = "El QuotaKit no ha pogut llegir l'emmagatzematge de comptes gestionats. "; + "Configure…" = "Configura…"; + "Connected" = "Connectat"; + "Controls how much detail is logged." = "Controla quant detall es registra."; + "Cookie header" = "Capçalera de galeta"; + "Cookie source" = "Origen de la galeta"; + "Cookie: ..." = "Cookie: ..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\no enganxa una captura cURL del tauler d'Abacus AI"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\no enganxa el valor de __Secure-next-auth.session-token"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\no enganxa el valor del testimoni kimi-auth"; + "Cookie: …" = "Cookie: …"; + "CopilotDeviceFlow" = "CopilotDeviceFlow"; + "Cost" = "Cost"; + "Could not add Codex account" = "No s'ha pogut afegir el compte de Codex"; + "Could not open Terminal for Gemini" = "No s'ha pogut obrir el Terminal per a Gemini"; + "Could not start claude /login" = "No s'ha pogut iniciar claude /login"; + "Could not start codex login" = "No s'ha pogut iniciar codex login"; + "Could not switch system account" = "No s'ha pogut canviar el compte del sistema"; + "Credits" = "Crèdits"; + "Credits history" = "Historial de crèdits"; + "Cursor login failed" = "L'inici de sessió de Cursor ha fallat"; + "Custom" = "Personalitzat"; + "Custom Path" = "Camí personalitzat"; + "Daily Routines" = "Rutines diàries"; + "Debug" = "Depuració"; + "Default" = "Per defecte"; + "Disable Keychain access" = "Desactiva l'accés al Clauer"; + "Disabled" = "Desactivat"; + "Dismiss" = "Descarta"; + "Disconnected" = "Desconnectat"; + "Display" = "Pantalla"; + "Display mode" = "Mode de visualització"; + "Display reset times as absolute clock values instead of countdowns." = "Mostra les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; + "Done" = "Fet"; + "Effective PATH" = "PATH efectiu"; + "Email" = "Correu electrònic"; + "Enable Merge Icons to configure Overview tab providers." = "Activa Combina les icones per configurar els proveïdors de la pestanya Resum."; + "Enable file logging" = "Activa el registre en fitxer"; + "Enabled" = "Activat"; + "Error" = "Error"; + "Error simulation" = "Simulació d'errors"; + "Expose troubleshooting tools in the Debug tab." = "Mostra eines de diagnòstic a la pestanya Depuració."; + "Failed" = "Ha fallat"; + "False" = "Fals"; + "Fetch strategy attempts" = "Intents d'estratègia d'obtenció"; + "Fetching" = "S'està obtenint"; + "Field" = "Camp"; + "Field subtitle" = "Subtítol del camp"; + "Finish the current managed account change before switching the system account." = "Acaba el canvi de compte gestionat actual abans de canviar el compte del sistema."; + "Force animation on next refresh" = "Força l'animació a la propera actualització"; + "Gateway region" = "Regió de la passarel·la"; + "Gemini CLI not found" = "No s'ha trobat la CLI de Gemini"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, mostrant incidències a la icona i al menú."; + "General" = "General"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "Inici de sessió de GitHub Copilot"; + "GitHub Login" = "Inici de sessió de GitHub"; + "Hide details" = "Amaga els detalls"; + "Hide personal information" = "Amaga la informació personal"; + "Historical tracking" = "Seguiment històric"; + "How often CodexBar polls providers in the background." = "Amb quina freqüència el QuotaKit consulta els proveïdors en segon pla."; + "Inactive" = "Inactiu"; + "Install CLI" = "Instal·la la CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instal·la la CLI de Claude (npm i -g @anthropic-ai/claude-code) i torna-ho a provar."; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instal·la la CLI de Codex (npm i -g @openai/codex) i torna-ho a provar."; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instal·la la CLI de Gemini (npm i -g @google/gemini-cli) i torna-ho a provar."; + "JetBrains AI is ready" = "JetBrains AI està a punt"; + "JetBrains IDE" = "IDE de JetBrains"; + "Keep CLI sessions alive" = "Mantén actives les sessions de la CLI"; + "Keyboard shortcut" = "Drecera de teclat"; + "Keychain access" = "Accés al Clauer"; + "Keychain prompt policy" = "Política de sol·licituds del Clauer"; + "Last \\(name) fetch failed:" = "L'última obtenció de \\(name) ha fallat:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "L'última obtenció de \\(self.store.metadata(for: self.provider).displayName) ha fallat:"; + "Last attempt" = "Últim intent"; + "Link" = "Enllaç"; + "Loading animations" = "Animacions de càrrega"; + "Loading…" = "S'està carregant…"; + "Local" = "Local"; + "Logging" = "Registre"; + "Login failed" = "L'inici de sessió ha fallat"; + "Login shell PATH (startup capture)" = "PATH del shell d'inici de sessió (captura a l'arrencada)"; + "Login timed out" = "L'inici de sessió ha esgotat el temps d'espera"; + "MCP details" = "Detalls de l'MCP"; + "Managed Codex accounts unavailable" = "Comptes gestionats de Codex no disponibles"; + "Managed account storage is unreadable. Live account access is still available, " = "L'emmagatzematge de comptes gestionats no es pot llegir. L'accés a comptes en directe encara està disponible, "; + "Manual" = "Manual"; + "May your tokens never run out—keep agent limits in view." = "Que els teus testimonis no s'esgotin mai: mantén els límits dels teus agents a la vista."; + "Menu bar" = "Barra de menús"; + "Menu bar auto-shows the provider closest to its rate limit." = "La barra de menús mostra automàticament el proveïdor més a prop del seu límit."; + "Menu bar metric" = "Mètrica de la barra de menús"; + "Menu bar shows percent" = "La barra de menús mostra el percentatge"; + "Menu content" = "Contingut del menú"; + "Merge Icons" = "Combina les icones"; + "Never prompt" = "No ho demanis mai"; + "No" = "No"; + "No Codex accounts detected yet." = "Encara no s'han detectat comptes de Codex."; + "No JetBrains IDE detected" = "No s'ha detectat cap IDE de JetBrains"; + "No cost history data." = "No hi ha dades d'historial de cost."; + "No credits history data." = "No hi ha dades d'historial de crèdits."; + "No data available" = "No hi ha dades disponibles"; + "No data yet" = "Encara no hi ha dades"; + "No enabled providers available for Overview." = "No hi ha proveïdors activats disponibles per al Resum."; + "No providers selected" = "No hi ha cap proveïdor seleccionat"; + "No token accounts yet." = "Encara no hi ha comptes amb testimoni."; + "No usage breakdown data." = "No hi ha dades de desglossament d'ús."; + "None" = "Cap"; + "Notifications" = "Notificacions"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa quan la quota de sessió de 5 hores arriba al 0 % i quan torna a estar "; + "OK" = "D'acord"; + "Obscure email addresses in the menu bar and menu UI." = "Amaga les adreces de correu a la barra de menús i a la interfície del menú."; + "Off" = "Desactivat"; + "Offline" = "Sense connexió"; + "On" = "Activat"; + "Online" = "En línia"; + "Only on user action" = "Només en accions de l'usuari"; + "Open" = "Obre"; + "Open API Keys" = "Obre les claus d'API"; + "Open Amp Settings" = "Obre la configuració d'Amp"; + "Open Antigravity to sign in, then refresh CodexBar." = "Obre Antigravity per iniciar la sessió i després actualitza el QuotaKit."; + "Open Browser" = "Obre el navegador"; + "Open Coding Plan" = "Obre el pla de programació"; + "Open Console" = "Obre la Consola"; + "Open Dashboard" = "Obre el tauler"; + "Open Mistral Admin" = "Obre l'administració de Mistral"; + "Open Menu Bar Settings" = "Obre la configuració de la barra de menús"; + "Open Ollama Settings" = "Obre la configuració d'Ollama"; + "Open Terminal" = "Obre el Terminal"; + "Open Usage Page" = "Obre la pàgina d'ús"; + "Open Warp API Key Guide" = "Obre la guia de la clau d'API de Warp"; + "Open menu" = "Obre el menú"; + "Open token file" = "Obre el fitxer de testimoni"; + "OpenAI cookies" = "Galetes d'OpenAI"; + "OpenAI web extras" = "Extres web d'OpenAI"; + "Option A" = "Opció A"; + "Option B" = "Opció B"; + "Optional override if workspace lookup fails." = "Substitució opcional si falla la cerca de l'espai de treball."; + "Options" = "Opcions"; + "Override auto-detection with a custom IDE base path" = "Substitueix la detecció automàtica amb un camí base d'IDE personalitzat"; + "Overview" = "Resum"; + "Overview rows always follow provider order." = "Les files del Resum sempre segueixen l'ordre dels proveïdors."; + "Overview tab providers" = "Proveïdors de la pestanya Resum"; + "Paste API key…" = "Enganxa la clau d'API…"; + "Paste API token…" = "Enganxa el testimoni d'API…"; + "Paste key…" = "Enganxa la clau…"; + "Paste sessionKey or OAuth token…" = "Enganxa la sessionKey o el testimoni OAuth…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "Enganxa la capçalera Cookie d'una petició a admin.mistral.ai. "; + "Paste token…" = "Enganxa el testimoni…"; + "Personal" = "Personal"; + "Picker" = "Selector"; + "Picker subtitle" = "Subtítol del selector"; + "Placeholder" = "Text de marcador"; + "Plan" = "Pla"; + "Play full-screen confetti when weekly usage resets." = "Mostra confeti a pantalla completa quan es reinicia l'ús setmanal."; + "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta les pàgines d'estat d'OpenAI/Claude i Google Workspace per a "; + "Prevents any Keychain access while enabled." = "Impedeix qualsevol accés al Clauer mentre estigui activat."; + "Primary (API key limit)" = "Principal (límit de la clau d'API)"; + "Primary (\\(label))" = "Principal (\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "Principal (\\(metadata.sessionLabel))"; + "Probe logs" = "Registres de sondeig"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progrés s'omplen a mesura que consumeixes la quota (en comptes de mostrar el que queda)."; + "Provider" = "Proveïdor"; + "Providers" = "Proveïdors"; + "Quit CodexBar" = "Surt del QuotaKit"; + "Random (default)" = "Aleatori (per defecte)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Llegeix els registres d'ús locals. Mostra el cost d'avui + la finestra d'historial seleccionada al menú."; + "Refresh" = "Actualitza"; + "Refresh cadence" = "Freqüència d'actualització"; + "Remote" = "Remot"; + "Remove" = "Elimina"; + "Remove Codex account?" = "Vols eliminar el compte de Codex?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Vols eliminar \\(account.email) del QuotaKit? El seu directori Codex gestionat s'esborrarà."; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Vols eliminar \\(email) del QuotaKit? El seu directori Codex gestionat s'esborrarà."; + "Remove selected account" = "Elimina el compte seleccionat"; + "Replace critter bars with provider branding icons and a percentage." = "Substitueix les barres de bestioles per icones de marca del proveïdor i un percentatge."; + "Replay selected animation" = "Reprodueix l'animació seleccionada"; + "Requires authentication via GitHub Device Flow." = "Requereix autenticació mitjançant el flux de dispositiu de GitHub."; + "Resets: \\(reset)" = "Es reinicia: \\(reset)"; + "Rolling five-hour limit" = "Límit mòbil de cinc hores"; + "Search hourly" = "Cerques per hora"; + "Secondary (\\(label))" = "Secundari (\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "Secundari (\\(metadata.weeklyLabel))"; + "Select a provider" = "Selecciona un proveïdor"; + "Select the IDE to monitor" = "Selecciona l'IDE que cal monitorar"; + "Session quota notifications" = "Notificacions de quota de sessió"; + "Session tokens" = "Testimonis de sessió"; + "Settings" = "Configuració"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; + "Show Debug Settings" = "Mostra la configuració de depuració"; + "Show all token accounts" = "Mostra tots els comptes amb testimoni"; + "Show cost summary" = "Mostra el resum de cost"; + "Show credits + extra usage" = "Mostra crèdits + ús addicional"; + "Show details" = "Mostra els detalls"; + "Show most-used provider" = "Mostra el proveïdor més utilitzat"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostra les icones de proveïdor al selector (si no, mostra una línia de progrés setmanal)."; + "Show reset time as clock" = "Mostra l'hora de reinici com a rellotge"; + "Show usage as used" = "Mostra l'ús com a consumit"; + "Sign in via button below" = "Inicia la sessió amb el botó de sota"; + "Skip teardown between probes (debug-only)." = "Omet el tancament entre sondeigs (només depuració)."; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apila els comptes amb testimoni al menú (si no, mostra una barra de canvi de compte)."; + "Start at Login" = "Obrir en iniciar la sessió"; + "Status" = "Estat"; + "Store Claude sessionKey cookies or OAuth access tokens." = "Desa galetes sessionKey de Claude o testimonis d'accés OAuth."; + "Store multiple Abacus AI Cookie headers." = "Desa diverses capçaleres Cookie d'Abacus AI."; + "Store multiple Augment Cookie headers." = "Desa diverses capçaleres Cookie d'Augment."; + "Store multiple Cursor Cookie headers." = "Desa diverses capçaleres Cookie de Cursor."; + "Store multiple Factory Cookie headers." = "Desa diverses capçaleres Cookie de Factory."; + "Store multiple MiniMax Cookie headers." = "Desa diverses capçaleres Cookie de MiniMax."; + "Store multiple Mistral Cookie headers." = "Desa diverses capçaleres Cookie de Mistral."; + "Store multiple Ollama Cookie headers." = "Desa diverses capçaleres Cookie d'Ollama."; + "Store multiple OpenCode Cookie headers." = "Desa diverses capçaleres Cookie d'OpenCode."; + "Store multiple OpenCode Go Cookie headers." = "Desa diverses capçaleres Cookie d'OpenCode Go."; + "Stored in the CodexBar config file." = "Desat al fitxer de configuració del QuotaKit."; + "Stored in ~/.codexbar/config.json. " = "Desat a ~/.quotakit/config.json. "; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Desat a ~/.quotakit/config.json. Genera'n una a kimi-k2.ai."; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Desat a ~/.quotakit/config.json. Enganxa la clau del tauler de Synthetic."; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Desat a ~/.quotakit/config.json. Enganxa la clau d'API del teu pla de programació des de Model Studio."; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Desat a ~/.quotakit/config.json. Enganxa la teva clau d'API de MiniMax."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Desat a ~/.quotakit/config.json. També pots proporcionar KILO_API_KEY o "; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Desa l'historial d'ús local de Codex (8 setmanes) per personalitzar les prediccions de Ritme."; + "Subscription Utilization" = "Ús de la subscripció"; + "Surprise me" = "Sorprèn-me"; + "Switcher shows icons" = "El selector mostra icones"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "Sistema"; + "Temporarily shows the loading animation after the next refresh." = "Mostra temporalment l'animació de càrrega després de la propera actualització."; + "Tertiary (\\(label))" = "Terciari (\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "Terciari (\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "El compte de Codex per defecte en aquest Mac."; + "Toggle" = "Commutador"; + "Toggle subtitle" = "Subtítol del commutador"; + "Token" = "Testimoni"; + "Trigger the menu bar menu from anywhere." = "Obre el menú de la barra de menús des de qualsevol lloc."; + "True" = "Cert"; + "Twitter" = "Twitter"; + "Unsupported" = "No compatible"; + "Update Channel" = "Canal d'actualitzacions"; + "Updated" = "Actualitzat"; + "Updates unavailable in this build." = "Actualitzacions no disponibles en aquesta compilació."; + "Usage" = "Ús"; + "Usage breakdown" = "Desglossament d'ús"; + "Usage history (30 days)" = "Historial d'ús"; + "Usage source" = "Origen de l'ús"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Fes servir BigModel per als endpoints de la Xina continental (open.bigmodel.cn)."; + "Use a single menu bar icon with a provider switcher." = "Fes servir una sola icona a la barra de menús amb un selector de proveïdor."; + "Use international or China mainland console gateways for quota fetches." = "Fes servir les passarel·les de consola internacionals o de la Xina continental per obtenir la quota."; + "Version" = "Versió"; + "Version \\(self.versionString)" = "Versió \\(self.versionString)"; + "Version \\(version)" = "Versió \\(version)"; + "Version \\(versionString)" = "Versió \\(versionString)"; + "Vertex AI Login" = "Inici de sessió de Vertex AI"; + "Wait for the current managed Codex login to finish before adding another account." = "Espera que acabi l'inici de sessió gestionat de Codex actual abans d'afegir un altre compte."; + "Waiting for Authentication..." = "S'està esperant l'autenticació..."; + "Website" = "Lloc web"; + "Weekly limit confetti" = "Confeti del límit setmanal"; + "Weekly token limit" = "Límit setmanal de testimonis"; + "Weekly usage" = "Ús setmanal"; + "Weekly usage unavailable for this account." = "Ús setmanal no disponible per a aquest compte."; + "Window: \\(window)" = "Finestra: \\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "Escriu els registres a \\(self.fileLogPath) per a la depuració."; + "Yes" = "Sí"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + "\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 d \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name): s'està obtenint…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name): últim intent \\(when)"; + "\\(name): no data yet" = "\\(name): encara sense dades"; + "\\(name): unsupported" = "\\(name): no compatible"; + "all browsers" = "tots els navegadors"; + "available again." = "disponible de nou."; + "built_format" = "Compilació %@"; + "copilot_complete_in_browser" = "Completa l'inici de sessió al teu navegador."; + "copilot_device_code" = "Codi de dispositiu copiat al porta-retalls: %1$@\n\nVerifica'l a: %2$@"; + "copilot_device_code_copied" = "Codi de dispositiu copiat."; + "copilot_verify_at" = "Verifica'l a %@"; + "copilot_waiting_text" = "Completa l'inici de sessió al teu navegador.\nAquesta finestra es tanca automàticament quan finalitza l'inici de sessió."; + "copilot_window_closes_auto" = "Aquesta finestra es tanca automàticament quan finalitza l'inici de sessió."; + "cost_status_error" = "%1$@: %2$@"; + "cost_status_fetching" = "%1$@: s'està obtenint… %2$@"; + "cost_status_last_attempt" = "%1$@: últim intent %2$@"; + "cost_status_no_data" = "%@: encara sense dades"; + "cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@: no compatible"; + "credits_remaining" = "Crèdits: %@"; + "cursor_on_demand" = "Sota demanda: %@"; + "cursor_on_demand_with_limit" = "Sota demanda: %1$@ / %2$@"; + "extra_usage_format" = "Ús addicional: %1$@ / %2$@"; + "jetbrains_detected_generate" = "Detectat: %@. Fes servir l'assistent d'IA una vegada per generar dades de quota i després actualitza el QuotaKit."; + "jetbrains_detected_select" = "Detectat: %@. Selecciona el teu IDE preferit a la configuració i després actualitza el QuotaKit."; + "last_fetch_failed_with_provider" = "L'última obtenció de %@ ha fallat:"; + "last_spend" = "Última despesa: %@"; + "mcp_model_usage" = "%1$@: %2$@"; + "mcp_resets" = "Es reinicia: %@"; + "mcp_window" = "Finestra: %@"; + "metric_average" = "Mitjana (%1$@ + %2$@)"; + "metric_primary" = "Principal (%@)"; + "metric_secondary" = "Secundari (%@)"; + "metric_tertiary" = "Terciari (%@)"; + "multiple_workspaces_found" = "El QuotaKit ha trobat diversos espais de treball per a %@. Tria l'espai de treball que vols afegir."; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "Tria fins a %@ proveïdors"; + "remove_account_message" = "Vols eliminar %@ del QuotaKit? El seu directori Codex gestionat s'esborrarà."; + "version_format" = "Versió %@"; + "vertex_ai_login_instructions" = "Per fer un seguiment de l'ús de Vertex AI, autentica't amb Google Cloud.\n\n1. Obre el Terminal\n2. Executa: gcloud auth application-default login\n3. Segueix les indicacions del navegador per iniciar la sessió\n4. Defineix el teu projecte: gcloud config set project PROJECT_ID\n\nVols obrir el Terminal ara?"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID està definit, però només opencode, opencodego i deepgram admeten workspaceID."; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Llicència MIT."; + /* General Pane */ "section_system" = "Sistema"; + "section_usage" = "Ús"; + "section_automation" = "Automatització"; + "language_title" = "Idioma"; + "language_subtitle" = "Canvia l'idioma de la interfície. Cal reiniciar l'app perquè s'apliqui completament."; + "language_system" = "Sistema"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "Suec"; + +"language_dutch" = "Nederlands"; + +"language_french" = "Francès"; + +"language_ukrainian" = "Ucraïnès"; + "start_at_login_title" = "Obrir en iniciar la sessió"; + "start_at_login_subtitle" = "Obre el QuotaKit automàticament en iniciar el Mac."; + "show_cost_summary" = "Mostra el resum de cost"; + "show_cost_summary_subtitle" = "Llegeix els registres d'ús locals. Mostra el cost d'avui + la finestra d'historial seleccionada al menú."; + "cost_history_days_title" = "Finestra d'historial: %d dies"; + "cost_auto_refresh_info" = "Actualització automàtica: cada hora · Temps d'espera: 10 m"; + "refresh_cadence_title" = "Freqüència d'actualització"; + "refresh_cadence_subtitle" = "Amb quina freqüència el QuotaKit consulta els proveïdors en segon pla."; + "manual_refresh_hint" = "L'actualització automàtica està desactivada; fes servir l'ordre Actualitza del menú."; + "check_provider_status_title" = "Comprova l'estat del proveïdor"; + "check_provider_status_subtitle" = "Consulta les pàgines d'estat d'OpenAI/Claude i Google Workspace per a Gemini/Antigravity, mostrant incidències a la icona i al menú."; + "session_quota_notifications_title" = "Notificacions de quota de sessió"; + "session_quota_notifications_subtitle" = "Avisa quan la quota de sessió de 5 hores arriba al 0 % i quan torna a estar disponible."; + "quota_warning_notifications_title" = "Notificacions d'avís de quota"; + "quota_warning_notifications_subtitle" = "Avisa quan la quota restant de sessió o setmanal supera els llindars configurats."; + "quota_warnings_title" = "Avisos de quota"; + "quota_warning_session" = "sessió"; + "quota_warning_session_capitalized" = "Sessió"; + "quota_warning_weekly" = "setmanal"; + "quota_warning_weekly_capitalized" = "Setmanal"; + "quota_warning_warn_at" = "Avisa al"; + "quota_warning_global_threshold_subtitle" = "Percentatges restants per a les finestres de sessió i setmanal, llevat que un proveïdor els substitueixi."; + "quota_warning_sound" = "Reprodueix el so de notificació"; + "quota_warning_provider_inherits" = "Fa servir la configuració global d'avís de quota llevat que es personalitzi una finestra aquí."; + "quota_warning_customize_thresholds" = "Personalitza els llindars de %@"; + "quota_warning_enable_warnings" = "Activa els avisos de %@"; + "quota_warning_window_warn_at" = "%@ avisa al"; + "quota_warning_off" = "Desactivat"; + "quota_warning_inherited" = "Heretat: %@"; + "quota_warning_depleted_only" = "només esgotat"; + "quota_warning_upper" = "Superior"; + "quota_warning_lower" = "Inferior"; + "apply" = "Aplica"; + "quit_app" = "Surt del QuotaKit"; + /* Tab titles */ "tab_general" = "General"; + "tab_providers" = "Proveïdors"; + "tab_display" = "Pantalla"; + "tab_advanced" = "Avançat"; + "tab_about" = "Quant a"; + "tab_debug" = "Depuració"; + /* Providers Pane */ "select_a_provider" = "Selecciona un proveïdor"; + "cancel" = "Cancel·la"; + "last_fetch_failed" = "l'última obtenció ha fallat"; + "usage_not_fetched_yet" = "encara no s'ha obtingut l'ús"; + "managed_account_storage_unreadable" = "L'emmagatzematge de comptes gestionats no es pot llegir. L'accés a comptes en directe encara està disponible, però les accions d'afegir, reautenticar i eliminar comptes gestionats estan desactivades fins que el magatzem es pugui recuperar."; + "remove_codex_account_title" = "Vols eliminar el compte de Codex?"; + "remove" = "Elimina"; + "managed_login_already_running" = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir o reautenticar un altre compte."; + "managed_login_failed" = "L'inici de sessió gestionat de Codex no s'ha completat. Comprova que `codex --version` funciona al Terminal. Si macOS ha bloquejat o ha mogut `codex` a la Paperera, elimina les instal·lacions duplicades obsoletes, executa `npm install -g --include=optional @openai/codex@latest` i torna-ho a provar."; + "codex_login_output" = "Sortida de codex login:"; + "managed_login_missing_email" = "L'inici de sessió de Codex s'ha completat, però no hi havia cap correu de compte disponible. Torna-ho a provar després de confirmar que el compte té la sessió totalment iniciada."; + "workspace_selection_cancelled" = "El QuotaKit ha trobat diversos espais de treball, però no se n'ha seleccionat cap."; + "unsafe_managed_home" = "El QuotaKit s'ha negat a modificar un camí de directori gestionat inesperat: %@"; + "menu_bar_metric_title" = "Mètrica de la barra de menús"; + "menu_bar_metric_subtitle" = "Tria quina finestra determina el percentatge de la barra de menús."; + "menu_bar_metric_subtitle_deepseek" = "Mostra el saldo de DeepSeek a la barra de menús."; + "menu_bar_metric_subtitle_moonshot" = "Mostra el saldo de l'API de Moonshot / Kimi a la barra de menús."; + "menu_bar_metric_subtitle_mistral" = "Mostra la despesa de l'API de Mistral del mes actual a la barra de menús."; + "menu_bar_metric_subtitle_kimik2" = "Mostra els crèdits de la clau d'API de Kimi K2 a la barra de menús."; + "automatic" = "Automàtic"; + "primary_api_key_limit" = "Principal (límit de la clau d'API)"; + /* Display Pane */ "section_menu_bar" = "Barra de menús"; + "merge_icons_title" = "Combina les icones"; + "merge_icons_subtitle" = "Fes servir una sola icona a la barra de menús amb un selector de proveïdor."; + "switcher_shows_icons_title" = "El selector mostra icones"; + "switcher_shows_icons_subtitle" = "Mostra les icones de proveïdor al selector (si no, mostra una línia de progrés setmanal)."; + "show_most_used_provider_title" = "Mostra el proveïdor més utilitzat"; + "show_most_used_provider_subtitle" = "La barra de menús mostra automàticament el proveïdor més a prop del seu límit."; + "menu_bar_shows_percent_title" = "La barra de menús mostra el percentatge"; + "menu_bar_shows_percent_subtitle" = "Substitueix les barres de bestioles per icones de marca del proveïdor i un percentatge."; + "display_mode_title" = "Mode de visualització"; + "display_mode_subtitle" = "Tria què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; + "section_menu_content" = "Contingut del menú"; + "show_usage_as_used_title" = "Mostra l'ús com a consumit"; + "show_usage_as_used_subtitle" = "Les barres de progrés s'omplen a mesura que consumeixes la quota (en comptes de mostrar el que queda)."; + "show_quota_warning_markers_title" = "Mostra els marcadors d'avís de quota"; + "show_quota_warning_markers_subtitle" = "Dibuixa marques de llindar a les barres d'ús quan hi ha avisos de quota configurats."; + "show_reset_time_as_clock_title" = "Mostra l'hora de reinici com a rellotge"; + "show_reset_time_as_clock_subtitle" = "Mostra les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; + "show_provider_changelog_links_title" = "Mostra els enllaços al registre de canvis del proveïdor"; + "show_provider_changelog_links_subtitle" = "Afegeix al menú enllaços a les notes de versió dels proveïdors compatibles basats en CLI."; + "show_credits_extra_usage_title" = "Mostra crèdits + ús addicional"; + "show_credits_extra_usage_subtitle" = "Mostra les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; + "show_all_token_accounts_title" = "Mostra tots els comptes amb testimoni"; + "show_all_token_accounts_subtitle" = "Apila els comptes amb testimoni al menú (si no, mostra una barra de canvi de compte)."; + "multi_account_layout_title" = "Disposició multicompte"; + "multi_account_layout_subtitle" = "Tria el canvi de compte segmentat o targetes de compte apilades."; + "multi_account_layout_segmented" = "Segmentat"; + "multi_account_layout_stacked" = "Apilat"; + "overview_tab_providers_title" = "Proveïdors de la pestanya Resum"; + "configure" = "Configura…"; + "overview_enable_merge_icons_hint" = "Activa Combina les icones per configurar els proveïdors de la pestanya Resum."; + "overview_no_providers_hint" = "No hi ha proveïdors activats disponibles per al Resum."; + "overview_rows_follow_order" = "Les files del Resum sempre segueixen l'ordre dels proveïdors."; + "overview_no_providers_selected" = "No hi ha cap proveïdor seleccionat"; + /* Advanced Pane */ "section_keyboard_shortcut" = "Drecera de teclat"; + "open_menu_shortcut_title" = "Obre el menú"; + "open_menu_shortcut_subtitle" = "Obre el menú de la barra de menús des de qualsevol lloc."; + "install_cli" = "Instal·la la CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "No s'han trobat directoris bin amb permís d'escriptura."; + "show_debug_settings_title" = "Mostra la configuració de depuració"; + "show_debug_settings_subtitle" = "Mostra eines de diagnòstic a la pestanya Depuració."; + "surprise_me_title" = "Sorprèn-me"; + "surprise_me_subtitle" = "Activa-ho si t'agrada que els teus agents es diverteixin allà dalt."; + "weekly_limit_confetti_title" = "Confeti del límit setmanal"; + "weekly_limit_confetti_subtitle" = "Mostra confeti a pantalla completa quan es reinicia l'ús setmanal."; + "hide_personal_info_title" = "Amaga la informació personal"; + "hide_personal_info_subtitle" = "Amaga les adreces de correu a la barra de menús i a la interfície del menú."; + "show_provider_storage_usage_title" = "Mostra l'ús d'emmagatzematge del proveïdor"; + "show_provider_storage_usage_subtitle" = "Mostra l'ús de disc local als menús. Analitza en segon pla els camins coneguts del proveïdor."; + "section_keychain_access" = "Accés al Clauer"; + "keychain_access_caption" = "Desactiva totes les lectures i escriptures del Clauer. La importació de galetes del navegador no estarà disponible; enganxa les capçaleres Cookie manualment a Proveïdors."; + "disable_keychain_access_title" = "Desactiva l'accés al Clauer"; + "disable_keychain_access_subtitle" = "Impedeix qualsevol accés al Clauer mentre estigui activat."; + /* About Pane */ "about_tagline" = "Que els teus testimonis no s'esgotin mai: mantén els límits dels teus agents a la vista."; + "link_github" = "GitHub"; + "link_website" = "Lloc web"; + "link_twitter" = "Twitter"; + "link_email" = "Correu electrònic"; + "check_updates_auto" = "Cerca actualitzacions automàticament"; + "update_channel" = "Canal d'actualitzacions"; + "check_for_updates" = "Cerca actualitzacions…"; + "updates_unavailable" = "Actualitzacions no disponibles en aquesta compilació."; + "copyright" = "© 2026 Peter Steinberger. Llicència MIT."; + /* Debug Pane */ "section_logging" = "Registre"; + "enable_file_logging" = "Activa el registre en fitxer"; + "enable_file_logging_subtitle" = "Escriu els registres a %@ per a la depuració."; + "verbosity_title" = "Nivell de detall"; + "verbosity_subtitle" = "Controla quant detall es registra."; + "open_log_file" = "Obre el fitxer de registre"; + "force_animation_next_refresh" = "Força l'animació a la propera actualització"; + "force_animation_next_refresh_subtitle" = "Mostra temporalment l'animació de càrrega després de la propera actualització."; + "section_loading_animations" = "Animacions de càrrega"; + "loading_animations_caption" = "Tria un patró i reprodueix-lo a la barra de menús. «Aleatori» manté el comportament actual."; + "animation_random_default" = "Aleatori (per defecte)"; + "replay_selected_animation" = "Reprodueix l'animació seleccionada"; + "blink_now" = "Parpelleja ara"; + "section_probe_logs" = "Registres de sondeig"; + "probe_logs_caption" = "Obté la sortida de sondeig més recent per a la depuració; Copia conserva el text complet."; + "fetch_log" = "Obtén el registre"; + "copy" = "Copia"; + "save_to_file" = "Desa en un fitxer"; + "load_parse_dump" = "Carrega l'abocament d'anàlisi"; + "rerun_provider_autodetect" = "Torna a executar l'autodetecció de proveïdors"; + "loading" = "S'està carregant…"; + "no_log_yet_fetch" = "Encara no hi ha registre. Obtén per carregar-lo."; + "section_fetch_strategy" = "Intents d'estratègia d'obtenció"; + "fetch_strategy_caption" = "Últimes decisions i errors del flux d'obtenció d'un proveïdor."; + "section_openai_cookies" = "Galetes d'OpenAI"; + "openai_cookies_caption" = "Registres d'importació de galetes i extracció amb WebKit de l'últim intent de galetes d'OpenAI."; + "no_log_yet" = "Encara no hi ha registre. Actualitza les galetes d'OpenAI a Proveïdors → Codex per executar una importació."; + "section_caches" = "Memòries cau"; + "caches_caption" = "Esborra els resultats d'anàlisi de cost a la memòria cau o les memòries cau de galetes del navegador."; + "clear_cookie_cache" = "Esborra la memòria cau de galetes"; + "clear_cost_cache" = "Esborra la memòria cau de cost"; + "section_notifications" = "Notificacions"; + "notifications_caption" = "Llança notificacions de prova per a la finestra de sessió de 5 hores (esgotada/restaurada)."; + "post_depleted" = "Envia esgotada"; + "post_restored" = "Envia restaurada"; + "section_cli_sessions" = "Sessions de la CLI"; + "cli_sessions_caption" = "Mantén actives les sessions de la CLI de Codex/Claude després d'un sondeig. Per defecte es tanquen quan es capturen les dades."; + "keep_cli_sessions_alive" = "Mantén actives les sessions de la CLI"; + "keep_cli_sessions_alive_subtitle" = "Omet el tancament entre sondeigs (només depuració)."; + "reset_cli_sessions" = "Reinicia les sessions de la CLI"; + "section_error_simulation" = "Simulació d'errors"; + "error_simulation_caption" = "Injecta un missatge d'error fals a la targeta del menú per provar la disposició."; + "set_menu_error" = "Estableix l'error de menú"; + "clear_menu_error" = "Esborra l'error de menú"; + "set_cost_error" = "Estableix l'error de cost"; + "clear_cost_error" = "Esborra l'error de cost"; + "section_cli_paths" = "Camins de la CLI"; + "cli_paths_caption" = "Binari de Codex resolt i capes de PATH; captura del PATH d'inici de sessió a l'arrencada (temps d'espera curt)."; + "codex_binary" = "Binari de Codex"; + "claude_binary" = "Binari de Claude"; + "effective_path" = "PATH efectiu"; + "unavailable" = "No disponible"; + "login_shell_path" = "PATH del shell d'inici de sessió (captura a l'arrencada)"; + "cleared" = "Esborrat."; + "no_fetch_attempts" = "Encara no hi ha intents d'obtenció."; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe pot bloquejar les apps de la barra de menús a Configuració del Sistema → Barra de menús → Permet a la barra de menús. El QuotaKit s'està executant, però macOS podria estar amagant-ne la icona. Obre la configuració de la barra de menús i activa el QuotaKit."; + /* Metric preferences */ "metric_pref_automatic" = "Automàtic"; + "metric_pref_primary" = "Principal"; + "metric_pref_secondary" = "Secundari"; + "metric_pref_tertiary" = "Terciari"; + "metric_pref_extra_usage" = "Ús addicional"; + "metric_pref_average" = "Mitjana"; + /* Display modes */ "display_mode_percent" = "Percentatge"; + "display_mode_pace" = "Ritme"; + "display_mode_both" = "Tots dos"; + "display_mode_percent_desc" = "Mostra el percentatge restant/usat (p. ex. 45 %)"; + "display_mode_pace_desc" = "Mostra l'indicador de ritme (p. ex. +5 %)"; + "display_mode_both_desc" = "Mostra el percentatge i el ritme (p. ex. 45 % · +5 %)"; + /* Provider status */ "status_operational" = "Operatiu"; + "status_partial_outage" = "Interrupció parcial"; + "status_major_outage" = "Interrupció greu"; + "status_critical_issue" = "Problema crític"; + "status_maintenance" = "Manteniment"; + "status_unknown" = "Estat desconegut"; + /* Refresh frequency */ "refresh_manual" = "Manual"; + "refresh_1min" = "1 min"; + "refresh_2min" = "2 min"; + "refresh_5min" = "5 min"; + "refresh_15min" = "15 min"; + "refresh_30min" = "30 min"; + /* Additional keys */ "not_found" = "No trobat"; + /* Cost estimation */ "cost_header_estimated" = "Cost (estimat)"; + "cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la teva factura"; + /* Popup panels */ "No usage configured." = "No hi ha cap ús configurat."; + "Quota" = "Quota"; + "tokens" = "tokens"; + "requests" = "sol·licituds"; + "Latest" = "Més recent"; + "Monthly" = "Mensual"; + "Sonnet" = "Sonnet"; + "Auth" = "Autenticació"; + "Overages" = "Excedents"; + "Activity" = "Activitat"; + "Copied" = "Copiat"; + "Copy error" = "Error en copiar"; + "Copy path" = "Copia el camí"; + "Extra usage spent" = "Despesa d'ús addicional"; + "Credits remaining" = "Crèdits restants"; + "Using CLI fallback" = "S'està utilitzant l'alternativa de la CLI"; + "Balance updates in near-real time (up to 5 min lag)" = "El saldo s'actualitza gairebé en temps real (fins a 5 min de retard)"; + "Daily billing data finalizes at 07:00 UTC" = "Les dades diàries de facturació es tanquen a les 07:00 UTC"; + "%@ of %@ credits left" = "Queden %@ de %@ crèdits"; + "%@ of %@ bonus credits left" = "Queden %@ de %@ crèdits de bonificació"; + "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restants)"; + "%@/%@ left" = "%@/%@ restant"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "Es regenera %@"; + "used after next regen" = "usat després de la propera regeneració"; + "after next regen" = "després de la propera regeneració"; + "Near full" = "Gairebé ple"; + "Full in ~1 regen" = "Ple en ~1 regeneració"; + "Full in ~%.0f regens" = "Ple en ~%.0f regeneracions"; + "Overage usage" = "Ús excedent"; + "Overage cost" = "Cost excedent"; + "credits" = "crèdits"; + "Zen balance" = "Saldo Zen"; + "API spend" = "Despesa d'API"; + "Extra usage" = "Ús addicional"; + "Quota usage" = "Ús de quota"; + "%.0f%% used" = "%.0f%% usat"; + "Usage history (today)" = "Historial d'ús (avui)"; + "Usage history (%d days)" = "Historial d'ús (%d dies)"; + "%d percent remaining" = "%d%% restant"; + "Unknown" = "Desconegut"; + "stale data" = "dades obsoletes"; + "No credits history data available." = "No hi ha dades d'historial de crèdits disponibles."; + "Credits history chart" = "Gràfic d'historial de crèdits"; + "%d days of credits data" = "%d dies de dades de crèdits"; + "Usage breakdown chart" = "Gràfic de desglossament d'ús"; + "%d days of usage data across %d services" = "%d dies de dades d'ús en %d serveis"; + "Cost history chart" = "Gràfic d'historial de costos"; + "%d days of cost data" = "%d dies de dades de costos"; + "Plan utilization chart" = "Gràfic d'utilització del pla"; + "%d utilization samples" = "%d mostres d'utilització"; + "Hourly Usage" = "Ús per hora"; + "Usage remaining" = "Ús restant"; + "Usage used" = "Ús utilitzat"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "Clau d'API verificada. Ollama no exposa els límits de quota de Cloud a través de l'API."; + "Last 30 days: %@ tokens" = "Últims 30 dies: %@ tokens"; + "7d spend" = "Despesa 7 d"; + "30d spend" = "Despesa 30 d"; + "Cache read" = "Lectura de memòria cau"; + "Claude Admin API 30 day spend trend" = "Tendència de despesa de 30 dies de Claude Admin API"; + "OpenRouter API key spend trend" = "Tendència de despesa de la clau API d'OpenRouter"; + "z.ai hourly token trend" = "Tendència horària de tokens de z.ai"; + "MiniMax 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de MiniMax"; + "Today cash" = "Efectiu d'avui"; + "DeepSeek 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de DeepSeek"; + "cache-hit input" = "entrada amb encert de memòria cau"; + "cache-miss input" = "entrada sense encert de memòria cau"; + "output" = "sortida"; + "Requests" = "Sol·licituds"; + "Reported by OpenAI Admin API organization usage." = "Informat per l'ús de l'organització a OpenAI Admin API."; + "Reported by Mistral billing usage." = "Informat per l'ús de facturació de Mistral."; + "Today" = "Avui"; + "Today tokens" = "Tokens d'avui"; + "30d cost" = "Cost 30 d"; + "30d tokens" = "Tokens 30 d"; + "Latest tokens" = "Tokens recents"; + "Top model" = "Model principal"; + "Storage" = "Emmagatzematge"; + "No data" = "Sense dades"; + "Last %d days" = "Últims %d dies"; + "%@ tokens" = "%@ tokens"; + "Latest billing day" = "Últim dia de facturació"; + "Latest billing day (%@)" = "Últim dia de facturació (%@)"; + "This week" = "Aquesta setmana"; + "This month" = "Aquest mes"; + "Week" = "Setmana"; + "Month" = "Mes"; + "Models" = "Models"; + "24h tokens" = "Tokens 24 h"; + "Latest hour" = "Última hora"; + "Peak hour" = "Hora punta"; + "Top method" = "Mètode principal"; + "30d cash" = "Efectiu 30 d"; + "30d billing history from MiniMax web session" = "Historial de facturació de 30 dies de la sessió web de MiniMax"; + "AWS Cost Explorer billing can lag." = "La facturació d'AWS Cost Explorer pot endarrerir-se."; + "Rate limit: %d / %@" = "Límit de taxa: %d / %@"; + "Key remaining" = "Restant de la clau"; + "No limit set for the API key" = "No hi ha cap límit configurat per a la clau API"; + "API key limit unavailable right now" = "El límit de la clau API no està disponible ara mateix"; + "Today: %@ · %@ tokens" = "Avui: %@ · %@ tokens"; + "Today: %@" = "Avui: %@"; + "Today: %@ tokens" = "Avui: %@ tokens"; + "This month: %@ tokens" = "Aquest mes: %@ tokens"; + "API key limit" = "Límit de la clau API"; + "Limits not available" = "Límits no disponibles"; + "No usage yet" = "Encara no hi ha ús"; + "Not fetched yet" = "Encara no obtingut"; + "Code review" = "Revisió de codi"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ espera permís"; + "%@ requests" = "%@ sol·licituds"; + "%@: %@ credits" = "%@: %@ crèdits"; + "30d requests" = "Sol·licituds de 30 d"; + "4 days" = "4 dies"; + "5 days" = "5 dies"; + "7 days" = "7 dies"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clau API verifica l'accés a Ollama Cloud; les galetes encara exposen els límits de quota."; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clau d'accés d'AWS. També es pot definir amb AWS_ACCESS_KEY_ID."; + "AWS region. Can also be set with AWS_REGION." = "Regió d'AWS. També es pot definir amb AWS_REGION."; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clau secreta d'accés d'AWS. També es pot definir amb AWS_SECRET_ACCESS_KEY."; + "Access key ID" = "ID de clau d'accés"; + "Add Account" = "Afegeix compte"; + "Adding Account…" = "S'està afegint el compte…"; + "Antigravity login failed" = "L'inici de sessió d'Antigravity ha fallat"; + "Antigravity login timed out" = "L'inici de sessió d'Antigravity ha esgotat el temps"; + "Auth source" = "Font d'autenticació"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes de Chrome de Xiaomi MiMo."; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automàticament dades de sessió de Windsurf del localStorage de Chromium."; + "Automatic imports browser cookies from Bailian." = "Importa automàticament galetes del navegador de Bailian."; + "Automatically imports browser cookies." = "Importa automàticament galetes del navegador."; + "Automatically imports browser session cookies." = "Importa automàticament galetes de sessió del navegador."; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nom del desplegament d'Azure OpenAI. També s'admet AZURE_OPENAI_DEPLOYMENT_NAME."; + "Azure OpenAI key" = "Clau d'Azure OpenAI"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint del recurs Azure OpenAI. També s'admet AZURE_OPENAI_ENDPOINT."; + "Base URL" = "URL base"; + "Base URL for the LLM-API-Key-Proxy instance." = "URL base de la instància LLM-API-Key-Proxy."; + "Browser cookies" = "Galetes del navegador"; + "Cap end" = "Final del límit"; + "Cap start" = "Inici del límit"; + "Capacity End" = "Final de capacitat"; + "Capacity Start" = "Inici de capacitat"; + "Changelog" = "Registre de canvis"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Tria el host de l'API Moonshot/Kimi per a comptes internacionals o de la Xina continental."; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit no pot substituir un compte del sistema iniciat només amb una clau API."; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit no ha trobat autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit no ha pogut llegir l'emmagatzematge de comptes gestionats. Recupera'l abans d'afegir un altre compte."; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit no ha pogut llegir l'autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; + "CodexBar could not read the current system account on this Mac." = "QuotaKit no ha pogut llegir el compte del sistema actual en aquest Mac."; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit no ha pogut substituir l'autenticació activa de Codex en aquest Mac."; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit no ha pogut preservar de manera segura el compte del sistema actual abans de canviar."; + "CodexBar could not save the current system account before switching." = "QuotaKit no ha pogut desar el compte del sistema actual abans de canviar."; + "CodexBar could not update managed account storage." = "QuotaKit no ha pogut actualitzar l'emmagatzematge de comptes gestionats."; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit ha trobat un altre compte gestionat que ja utilitza el compte del sistema actual. Resol el compte duplicat abans de canviar."; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS “%@” per desxifrar galetes del navegador i autenticar el compte. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS el token OAuth de Claude Code per obtenir l'ús de Claude. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie d'Amp per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie d'Augment per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie de Claude per obtenir l'ús web de Claude. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie de Cursor per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie de Factory per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS el token de GitHub Copilot per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la clau API de Kimi K2 per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS el token d'autenticació de Kimi per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS el token API de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie d'OpenAI per obtenir extres del tauler de Codex. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la capçalera Cookie d'OpenCode per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS la clau API de Synthetic per obtenir l'ús. Fes clic a OK per continuar."; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit demanarà a Clauers de macOS el token API de z.ai per obtenir l'ús. Fes clic a OK per continuar."; + "Could not open Cursor login in your browser." = "No s'ha pogut obrir l'inici de sessió de Cursor al navegador."; + "Could not open browser for Antigravity" = "No s'ha pogut obrir el navegador per a Antigravity"; + "Credits used" = "Crèdits usats"; + "Day" = "Dia"; + "Deployment" = "Desplegament"; + "Drag to reorder" = "Arrossega per reordenar"; + "Endpoint" = "Endpoint"; + "Enterprise host" = "Host Enterprise"; + "Extra usage balance: %@" = "Saldo d'ús extra: %@"; + "Keychain Access Required" = "Cal accés a Clauers"; + "Kiro menu bar value" = "Valor de Kiro a la barra de menús"; + "Label" = "Etiqueta"; + "No organizations loaded. Click Refresh after setting your API key." = "No hi ha organitzacions carregades. Fes clic a Actualitza després de configurar la clau API."; + "No output captured." = "No s'ha capturat cap sortida."; + "No system account" = "Sense compte del sistema"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "Obre Augment (tanca sessió i torna a entrar)"; + "Open Codebuff Dashboard" = "Obre el tauler de Codebuff"; + "Open Command Code Settings" = "Obre la configuració de Command Code"; + "Open Crof dashboard" = "Obre el tauler de Crof"; + "Open Manus" = "Obre Manus"; + "Open MiMo Balance" = "Obre el saldo de MiMo"; + "Open Moonshot Console" = "Obre la consola de Moonshot"; + "Open Ollama API Keys" = "Obre les claus API d'Ollama"; + "Open StepFun Platform" = "Obre la plataforma StepFun"; + "Open T3 Chat Settings" = "Obre la configuració de T3 Chat"; + "Open Volcengine Ark Console" = "Obre la consola Volcengine Ark"; + "Open legacy provider docs" = "Obre la documentació del proveïdor heretat"; + "Open projects" = "Obre projectes"; + "Open this URL manually to continue login:\n\n%@" = "Obre aquesta URL manualment per continuar l'inici de sessió:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organització opcional per a comptes vinculats a diverses organitzacions d'Anthropic."; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. S'aplica a la clau Admin API configurada; els comptes de token seleccionats no hereten OPENAI_PROJECT_ID."; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introdueix el host de GitHub Enterprise, per exemple octocorp.ghe.com. Deixa-ho en blanc per a github.com."; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixa-ho en blanc per descobrir i agregar projectes visibles per a la clau API."; + "Org ID (optional)" = "ID d'org. (opcional)"; + "Organizations" = "Organitzacions"; + "Password" = "Contrasenya"; + "%@ authentication is disabled." = "L'autenticació de %@ està desactivada."; + "%@ cookies are disabled." = "Les galetes de %@ estan desactivades."; + "%@ web API access is disabled." = "L'accés a l'API web de %@ està desactivat."; + "Disable %@ dashboard cookie usage." = "Desactiva l'ús de galetes del tauler de %@."; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accés al clauer està desactivat a Avançat, així que la importació de galetes del navegador no està disponible."; + "Manually paste an %@ from a browser session." = "Enganxa manualment un %@ d'una sessió del navegador."; + "Paste a Cookie header captured from %@." = "Enganxa una capçalera Cookie capturada de %@."; + "Paste a Cookie header from %@." = "Enganxa una capçalera Cookie de %@."; + "Paste a Cookie header or cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL de %@."; + "Paste a Cookie header or full cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL completa de %@."; + "Paste a Cookie or Authorization header from %@." = "Enganxa una capçalera Cookie o Authorization de %@."; + "Paste a full cookie header or the %@ value." = "Enganxa una capçalera de galetes completa o el valor %@."; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Enganxa una capçalera Cookie o una captura cURL completa de la configuració de T3 Chat."; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Enganxa la capçalera Cookie d'una sol·licitud a admin.mistral.ai. Ha de contenir una galeta ory_session_*."; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Enganxa l'Oasis-Token d'una sessió iniciada a platform.stepfun.com."; + "Paste the %@ JSON bundle from %@." = "Enganxa el paquet JSON %@ de %@."; + "Paste the %@ value or a full Cookie header." = "Enganxa el valor %@ o una capçalera Cookie completa."; + "Personal account" = "Compte personal"; + "Project ID" = "ID de projecte"; + "Re-auth" = "Reautentica"; + "Re-authenticating…" = "S'està reautenticant…"; + "Refresh Session" = "Actualitza la sessió"; + "Refresh organizations" = "Actualitza organitzacions"; + "Region" = "Regió"; + "Reload" = "Recarrega"; + "Reorder" = "Reordena"; + "Secret access key" = "Clau secreta d'accés"; + "Series" = "Sèrie"; + "Service" = "Servei"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra o amaga crèdits de Kiro, percentatge o tots dos al costat de la icona de la barra de menús."; + "Show usage for organizations you belong to. Personal account is always shown." = "Mostra l'ús de les organitzacions a què pertanys. El compte personal sempre es mostra."; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sessió a cursor.com al navegador i després actualitza Cursor a QuotaKit."; + "Simulated error text" = "Text d'error simulat"; + "StepFun platform account (phone number or email)." = "Compte de la plataforma StepFun (telèfon o correu)."; + "Stored in ~/.codexbar/config.json." = "Desat a ~/.quotakit/config.json."; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Desat a ~/.quotakit/config.json. També s'admet AZURE_OPENAI_API_KEY."; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Desat a ~/.quotakit/config.json. Per a l'API oficial de Kimi, usa Moonshot / Kimi API."; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Desat a ~/.quotakit/config.json. Obtén la clau API a la consola Volcengine Ark."; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Desat a ~/.quotakit/config.json. Obtén la clau a la configuració d'Ollama."; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Desat a ~/.quotakit/config.json. Obtén la clau a console.deepgram.com."; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Desat a ~/.quotakit/config.json. Obtén la clau a elevenlabs.io/app/settings/api-keys."; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Desat a ~/.quotakit/config.json. Obtén la clau a openrouter.ai/settings/keys i defineix-hi un límit de despesa per activar el seguiment de quota."; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Desat a ~/.quotakit/config.json. A Warp, obre Settings > Platform > API Keys i crea'n una."; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Desat a ~/.quotakit/config.json. Les mètriques requereixen accés a Groq Enterprise Prometheus."; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Desat a ~/.quotakit/config.json. Es prefereix OPENAI_ADMIN_KEY; OPENAI_API_KEY encara funciona."; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Desat a ~/.quotakit/config.json. Requereix una clau Anthropic Admin API."; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Desat a ~/.quotakit/config.json. S'usa per a /v1/quota-stats."; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Desat a ~/.quotakit/config.json. També pots proporcionar CODEBUFF_API_KEY o deixar que QuotaKit llegeixi ~/.config/manicode/credentials.json (creat per `codebuff login`)."; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Desat a ~/.quotakit/config.json. També pots proporcionar CROF_API_KEY."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Desat a ~/.quotakit/config.json. També pots proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; + "T3 Chat cookie" = "Galeta de T3 Chat"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "Aquest compte ja no està disponible a QuotaKit. Actualitza la llista de comptes i torna-ho a provar."; + "The browser login did not complete in time. Try Antigravity login again." = "L'inici de sessió del navegador no s'ha completat a temps. Torna a provar l'inici de sessió d'Antigravity."; + "Timed out waiting for Cursor login. %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@ Últim error: %@"; + "Today requests" = "Sol·licituds d'avui"; + "Total (30d): %@ credits" = "Total (30 d): %@ crèdits"; + "Username" = "Nom d'usuari"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un Oasis-Token automàticament."; + "Uses username + password to login and obtain an %@ automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un %@ automàticament."; + "Utilization End" = "Final d'utilització"; + "Utilization Start" = "Inici d'utilització"; + "Verbosity" = "Detall"; + "Windsurf session JSON bundle" = "Paquet JSON de sessió de Windsurf"; + "Workspace ID" = "ID d'espai de treball"; + "Your StepFun platform password. Used to login and obtain a session token." = "La contrasenya de la plataforma StepFun. S'usa per iniciar sessió i obtenir un token de sessió."; + "claude /login exited with status %d." = "claude /login ha sortit amb estat %d."; + "codex login exited with status %d." = "codex login ha sortit amb estat %d."; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no enganxa una captura cURL del tauler d'Abacus AI"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no enganxa el valor de __Secure-next-auth.session-token"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no enganxa el valor del token kimi-auth"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no enganxa només el valor de session_id"; + "Clear" = "Esborra"; + "No matching providers" = "No hi ha proveïdors coincidents"; + "Search providers" = "Cerca proveïdors"; + + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 037eb0122..80d622620 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1,1086 +1,2145 @@ /* English localization for CodexBar (base/fallback) */ " providers" = " providers"; + "(System)" = "(System)"; + "30d" = "30d"; + "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; + "API key" = "API key"; + "API region" = "API region"; + "API token" = "API token"; + "API tokens" = "API tokens"; + "About" = "About"; + "Account" = "Account"; + "Accounts" = "Accounts"; + "Accounts subtitle" = "Accounts subtitle"; + "Active" = "Active"; + "Add" = "Add"; + "Add Workspace" = "Add Workspace"; + "Advanced" = "Advanced"; + "All" = "All"; + "Always allow prompts" = "Always allow prompts"; + "Animation pattern" = "Animation pattern"; + "Antigravity login is managed in the app" = "Antigravity login is managed in the app"; + "Applies only to the Security.framework OAuth keychain reader." = "Applies only to the Security.framework OAuth keychain reader."; + "Auto falls back to the next source if the preferred one fails." = "Auto falls back to the next source if the preferred one fails."; + "Auto uses API first, then falls back to CLI on auth failures." = "Auto uses API first, then falls back to CLI on auth failures."; + "Auto-detect" = "Auto-detect"; + "Auto-refresh is off; use the menu's Refresh command." = "Auto-refresh is off; use the menu's Refresh command."; + "Auto-refresh: hourly · Timeout: 10m" = "Auto-refresh: hourly · Timeout: 10m"; + "Automatic" = "Automatic"; + "Automatic imports browser cookies and WorkOS tokens." = "Automatic imports browser cookies and WorkOS tokens."; + "Automatic imports browser cookies and local storage tokens." = "Automatic imports browser cookies and local storage tokens."; + "Automatic imports browser cookies for dashboard extras." = "Automatic imports browser cookies for dashboard extras."; + "Automatic imports browser cookies for the web API." = "Automatic imports browser cookies for the web API."; + "Automatic imports browser cookies from Model Studio/Bailian." = "Automatic imports browser cookies from Model Studio/Bailian."; + "Automatic imports browser cookies from admin.mistral.ai." = "Automatic imports browser cookies from admin.mistral.ai."; + "Automatic imports browser cookies from opencode.ai." = "Automatic imports browser cookies from opencode.ai."; + "Automatic imports browser cookies or stored sessions." = "Automatic imports browser cookies or stored sessions."; + "Automatic imports browser cookies." = "Automatic imports browser cookies."; + "Automatically imports browser session cookie." = "Automatically imports browser session cookie."; + "Automatically opens CodexBar when you start your Mac." = "Automatically opens QuotaKit when you start your Mac."; + "Automation" = "Automation"; + "Average (\\(label1) + \\(label2))" = "Average (\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "Avoid Keychain prompts"; + "Balance" = "Balance"; + "Battery Saver" = "Battery Saver"; + "Bordered" = "Bordered"; + "Build" = "Build"; + "Built \\(buildTimestamp)" = "Built \\(buildTimestamp)"; + "Buy Credits..." = "Buy Credits..."; + "Buy Credits…" = "Buy Credits…"; + "CLI paths" = "CLI paths"; + "CLI sessions" = "CLI sessions"; + "Caches" = "Caches"; + "Cancel" = "Cancel"; + "Check for Updates…" = "Check for Updates…"; + "Check for updates automatically" = "Check for updates automatically"; + "Check if you like your agents having some fun up there." = "Check if you like your agents having some fun up there."; + "Check provider status" = "Check provider status"; + "Choose Codex workspace" = "Choose Codex workspace"; + "Choose the MiniMax host (global .io or China mainland .com)." = "Choose the MiniMax host (global .io or China mainland .com)."; + "Choose up to " = "Choose up to "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "Choose up to \\(Self.maxOverviewProviders) providers"; + "Choose up to \\(count) providers" = "Choose up to \\(count) providers"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; + "Choose which Codex account CodexBar should follow." = "Choose which Codex account QuotaKit should follow."; + "Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; + "Chrome" = "Chrome"; + "Claude CLI not found" = "Claude CLI not found"; + "Claude binary" = "Claude binary"; + "Claude cookies" = "Claude cookies"; + "Claude login failed" = "Claude login failed"; + "Claude login timed out" = "Claude login timed out"; + "Close" = "Close"; + "Code review" = "Code review"; + "Codex CLI not found" = "Codex CLI not found"; + "Codex account login already running" = "Codex account login already running"; + "Codex binary" = "Codex binary"; + "Codex login failed" = "Codex login failed"; + "Codex login timed out" = "Codex login timed out"; + "CodexBar Lifecycle Keepalive" = "QuotaKit Lifecycle Keepalive"; + "QuotaKit can't show its menu bar icon" = "QuotaKit can't show its menu bar icon"; + "CodexBar could not read managed account storage. " = "QuotaKit could not read managed account storage. "; + "Configure…" = "Configure…"; + "Connected" = "Connected"; + "Controls how much detail is logged." = "Controls how much detail is logged."; + "Cookie header" = "Cookie header"; + "Cookie source" = "Cookie source"; + "Cookie: ..." = "Cookie: ..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value"; + "Cookie: …" = "Cookie: …"; + "CopilotDeviceFlow" = "CopilotDeviceFlow"; + "Cost" = "Cost"; + "Could not add Codex account" = "Could not add Codex account"; + "Could not open Terminal for Gemini" = "Could not open Terminal for Gemini"; + "Could not start claude /login" = "Could not start claude /login"; + "Could not start codex login" = "Could not start codex login"; + "Could not switch system account" = "Could not switch system account"; + "Credits" = "Credits"; + "Credits history" = "Credits history"; + "Cursor login failed" = "Cursor login failed"; + "Custom" = "Custom"; + "Custom Path" = "Custom Path"; + "Daily Routines" = "Daily Routines"; + "Debug" = "Debug"; + "Default" = "Default"; + "Disable Keychain access" = "Disable Keychain access"; + "Disabled" = "Disabled"; + "Dismiss" = "Dismiss"; + "Disconnected" = "Disconnected"; + "Display" = "Display"; + "Display mode" = "Display mode"; + "Display reset times as absolute clock values instead of countdowns." = "Display reset times as absolute clock values instead of countdowns."; + "Done" = "Done"; + "Effective PATH" = "Effective PATH"; + "Email" = "Email"; + "Enable Merge Icons to configure Overview tab providers." = "Enable Merge Icons to configure Overview tab providers."; + "Enable file logging" = "Enable file logging"; + "Enabled" = "Enabled"; + "Error" = "Error"; + "Error simulation" = "Error simulation"; + "Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; + "Failed" = "Failed"; + "False" = "False"; + "Fetch strategy attempts" = "Fetch strategy attempts"; + "Fetching" = "Fetching"; + "Field" = "Field"; + "Field subtitle" = "Field subtitle"; + "Finish the current managed account change before switching the system account." = "Finish the current managed account change before switching the system account."; + "Force animation on next refresh" = "Force animation on next refresh"; + "Gateway region" = "Gateway region"; + "Gemini CLI not found" = "Gemini CLI not found"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, surfacing incidents in the icon and menu."; + "General" = "General"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "GitHub Copilot Login"; + "GitHub Login" = "GitHub Login"; + "Hide details" = "Hide details"; + "Hide personal information" = "Hide personal information"; + "Historical tracking" = "Historical tracking"; + "How often CodexBar polls providers in the background." = "How often QuotaKit polls providers in the background."; + "Inactive" = "Inactive"; + "Install CLI" = "Install CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Install the Codex CLI (npm i -g @openai/codex) and try again."; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."; + "JetBrains AI is ready" = "JetBrains AI is ready"; + "JetBrains IDE" = "JetBrains IDE"; + "Keep CLI sessions alive" = "Keep CLI sessions alive"; + "Keyboard shortcut" = "Keyboard shortcut"; + "Keychain access" = "Keychain access"; + "Keychain prompt policy" = "Keychain prompt policy"; + "Last \\(name) fetch failed:" = "Last \\(name) fetch failed:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:"; + "Last attempt" = "Last attempt"; + "Link" = "Link"; + "Loading animations" = "Loading animations"; + "Loading…" = "Loading…"; + "Local" = "Local"; + "Logging" = "Logging"; + "Login failed" = "Login failed"; + "Login shell PATH (startup capture)" = "Login shell PATH (startup capture)"; + "Login timed out" = "Login timed out"; + "MCP details" = "MCP details"; + "Managed Codex accounts unavailable" = "Managed Codex accounts unavailable"; + "Managed account storage is unreadable. Live account access is still available, " = "Managed account storage is unreadable. Live account access is still available, "; + "Manual" = "Manual"; + "May your tokens never run out—keep agent limits in view." = "May your tokens never run out—keep agent limits in view."; + "Menu bar" = "Menu bar"; + "Menu bar auto-shows the provider closest to its rate limit." = "Menu bar auto-shows the provider closest to its rate limit."; + "Menu bar metric" = "Menu bar metric"; + "Menu bar shows percent" = "Menu bar shows percent"; + "Menu content" = "Menu content"; + "Merge Icons" = "Merge Icons"; + "Never prompt" = "Never prompt"; + "No" = "No"; + "No Codex accounts detected yet." = "No Codex accounts detected yet."; + "No JetBrains IDE detected" = "No JetBrains IDE detected"; + "No cost history data." = "No cost history data."; + "No data available" = "No data available"; + "No data yet" = "No data yet"; + "No enabled providers available for Overview." = "No enabled providers available for Overview."; + "No providers selected" = "No providers selected"; + "No token accounts yet." = "No token accounts yet."; + "No usage breakdown data." = "No usage breakdown data."; + "None" = "None"; + "Notifications" = "Notifications"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "Notifies when the 5-hour session quota hits 0% and when it becomes "; + "OK" = "OK"; + "Obscure email addresses in the menu bar and menu UI." = "Obscure email addresses in the menu bar and menu UI."; + "Off" = "Off"; + "Offline" = "Offline"; + "On" = "On"; + "Online" = "Online"; + "Only on user action" = "Only on user action"; + "Open" = "Open"; + "Open API Keys" = "Open API Keys"; + "Open Amp Settings" = "Open Amp Settings"; + "Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity to sign in, then refresh QuotaKit."; + "Open Browser" = "Open Browser"; + "Open Coding Plan" = "Open Coding Plan"; + "Open Console" = "Open Console"; + "Open Dashboard" = "Open Dashboard"; + "Open Mistral Admin" = "Open Mistral Admin"; + "Open Menu Bar Settings" = "Open Menu Bar Settings"; + "Open Ollama Settings" = "Open Ollama Settings"; + "Open Terminal" = "Open Terminal"; + "Open Usage Page" = "Open Usage Page"; + "Open Warp API Key Guide" = "Open Warp API Key Guide"; + "Open menu" = "Open menu"; + "Open token file" = "Open token file"; + "OpenAI cookies" = "OpenAI cookies"; + "OpenAI web extras" = "OpenAI web extras"; + "Option A" = "Option A"; + "Option B" = "Option B"; + "Optional override if workspace lookup fails." = "Optional override if workspace lookup fails."; + "Options" = "Options"; + "Override auto-detection with a custom IDE base path" = "Override auto-detection with a custom IDE base path"; + "Overview" = "Overview"; + "Overview rows always follow provider order." = "Overview rows always follow provider order."; + "Overview tab providers" = "Overview tab providers"; + "Paste API key…" = "Paste API key…"; + "Paste API token…" = "Paste API token…"; + "Paste key…" = "Paste key…"; + "Paste sessionKey or OAuth token…" = "Paste sessionKey or OAuth token…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "Paste the Cookie header from a request to admin.mistral.ai. "; + "Paste token…" = "Paste token…"; + "Personal" = "Personal"; + "Picker" = "Picker"; + "Picker subtitle" = "Picker subtitle"; + "Placeholder" = "Placeholder"; + "Plan" = "Plan"; + "Play full-screen confetti when weekly usage resets." = "Play full-screen confetti when weekly usage resets."; + "Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude status pages and Google Workspace for "; + "Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled."; + "Primary (API key limit)" = "Primary (API key limit)"; + "Primary (\\(label))" = "Primary (\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "Primary (\\(metadata.sessionLabel))"; + "Probe logs" = "Probe logs"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "Progress bars fill as you consume quota (instead of showing remaining)."; + "Provider" = "Provider"; + "Providers" = "Providers"; + "Quit CodexBar" = "Quit QuotaKit"; + "Random (default)" = "Random (default)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Reads local usage logs. Shows today + the selected history window in the menu."; + "Refresh" = "Refresh"; + "Refresh cadence" = "Refresh cadence"; + "Remote" = "Remote"; + "Remove" = "Remove"; + "Remove Codex account?" = "Remove Codex account?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Remove \\(account.email) from QuotaKit? Its managed Codex home will be deleted."; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Remove \\(email) from QuotaKit? Its managed Codex home will be deleted."; + "Remove selected account" = "Remove selected account"; + "Replace critter bars with provider branding icons and a percentage." = "Replace critter bars with provider branding icons and a percentage."; + "Replay selected animation" = "Replay selected animation"; + "Requires authentication via GitHub Device Flow." = "Requires authentication via GitHub Device Flow."; + "Resets: \\(reset)" = "Resets: \\(reset)"; + "Rolling five-hour limit" = "Rolling five-hour limit"; + "Search hourly" = "Search hourly"; + "Secondary (\\(label))" = "Secondary (\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "Secondary (\\(metadata.weeklyLabel))"; + "Select a provider" = "Select a provider"; + "Select the IDE to monitor" = "Select the IDE to monitor"; + "Session quota notifications" = "Session quota notifications"; + "Session tokens" = "Session tokens"; + "Settings" = "Settings"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "Show Codex Credits and Claude Extra usage sections in the menu."; + "Show Debug Settings" = "Show Debug Settings"; + "Show all token accounts" = "Show all token accounts"; + "Show cost summary" = "Show cost summary"; + "Show credits + extra usage" = "Show credits + extra usage"; + "Show details" = "Show details"; + "Show most-used provider" = "Show most-used provider"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Show provider icons in the switcher (otherwise show a weekly progress line)."; + "Show reset time as clock" = "Show reset time as clock"; + "Show usage as used" = "Show usage as used"; + "Sign in via button below" = "Sign in via button below"; + "Skip teardown between probes (debug-only)." = "Skip teardown between probes (debug-only)."; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stack token accounts in the menu (otherwise show an account switcher bar)."; + "Start at Login" = "Start at Login"; + "Status" = "Status"; + "Store Claude sessionKey cookies or OAuth access tokens." = "Store Claude sessionKey cookies or OAuth access tokens."; + "Store multiple Abacus AI Cookie headers." = "Store multiple Abacus AI Cookie headers."; + "Store multiple Augment Cookie headers." = "Store multiple Augment Cookie headers."; + "Store multiple Cursor Cookie headers." = "Store multiple Cursor Cookie headers."; + "Store multiple Factory Cookie headers." = "Store multiple Factory Cookie headers."; + "Store multiple MiniMax Cookie headers." = "Store multiple MiniMax Cookie headers."; + "Store multiple Mistral Cookie headers." = "Store multiple Mistral Cookie headers."; + "Store multiple Ollama Cookie headers." = "Store multiple Ollama Cookie headers."; + "Store multiple OpenCode Cookie headers." = "Store multiple OpenCode Cookie headers."; + "Store multiple OpenCode Go Cookie headers." = "Store multiple OpenCode Go Cookie headers."; + "Stored in the CodexBar config file." = "Stored in the QuotaKit config file."; + "Stored in ~/.codexbar/config.json. " = "Stored in ~/.quotakit/config.json. "; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stored in ~/.quotakit/config.json. Generate one at kimi-k2.ai."; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stored in ~/.quotakit/config.json. Paste the key from the Synthetic dashboard."; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stored in ~/.quotakit/config.json. Paste your Coding Plan API key from Model Studio."; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stored in ~/.quotakit/config.json. Paste your MiniMax API key."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stored in ~/.quotakit/config.json. You can also provide KILO_API_KEY or "; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores local Codex usage history (8 weeks) to personalize Pace predictions."; + "Subscription Utilization" = "Subscription Utilization"; + "Surprise me" = "Surprise me"; + "Switcher shows icons" = "Switcher shows icons"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "System"; + "Temporarily shows the loading animation after the next refresh." = "Temporarily shows the loading animation after the next refresh."; + "Tertiary (\\(label))" = "Tertiary (\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "Tertiary (\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "The default Codex account on this Mac."; + "Toggle" = "Toggle"; + "Toggle subtitle" = "Toggle subtitle"; + "Token" = "Token"; + "Trigger the menu bar menu from anywhere." = "Trigger the menu bar menu from anywhere."; + "True" = "True"; + "Twitter" = "Twitter"; + "Unsupported" = "Unsupported"; + "Update Channel" = "Update Channel"; + "Updated" = "Updated"; + "Updates unavailable in this build." = "Updates unavailable in this build."; + "Usage" = "Usage"; + "Usage breakdown" = "Usage breakdown"; + "Usage history (30 days)" = "Usage history"; + "Usage source" = "Usage source"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."; + "Use a single menu bar icon with a provider switcher." = "Use a single menu bar icon with a provider switcher."; + "Use international or China mainland console gateways for quota fetches." = "Use international or China mainland console gateways for quota fetches."; + "Version" = "Version"; + "Version \\(self.versionString)" = "Version \\(self.versionString)"; + "Version \\(version)" = "Version \\(version)"; + "Version \\(versionString)" = "Version \\(versionString)"; + "Vertex AI Login" = "Vertex AI Login"; + "Wait for the current managed Codex login to finish before adding another account." = "Wait for the current managed Codex login to finish before adding another account."; + "Waiting for Authentication..." = "Waiting for Authentication..."; + "Website" = "Website"; + "Weekly limit confetti" = "Weekly limit confetti"; + "Weekly token limit" = "Weekly token limit"; + "Weekly usage" = "Weekly usage"; + "Weekly usage unavailable for this account." = "Weekly usage unavailable for this account."; + "Window: \\(window)" = "Window: \\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "Write logs to \\(self.fileLogPath) for debugging."; + "Yes" = "Yes"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + "\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name): fetching…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name): last attempt \\(when)"; + "\\(name): no data yet" = "\\(name): no data yet"; + "\\(name): unsupported" = "\\(name): unsupported"; + "all browsers" = "all browsers"; + "available again." = "available again."; + "built_format" = "Built %@"; + "copilot_complete_in_browser" = "Complete sign in in your browser."; + "copilot_device_code" = "Device code copied to clipboard: %1$@\n\nVerify at: %2$@"; + "copilot_device_code_copied" = "Device code copied."; + "copilot_verify_at" = "Verify at %@"; + "copilot_waiting_text" = "Complete sign in in your browser.\nThis window closes automatically when sign-in completes."; + "copilot_window_closes_auto" = "This window closes automatically when sign-in completes."; + "cost_status_error" = "%1$@: %2$@"; + "cost_status_fetching" = "%1$@: fetching… %2$@"; + "cost_status_last_attempt" = "%1$@: last attempt %2$@"; + "cost_status_no_data" = "%@: no data yet"; + "cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@: unsupported"; + "credits_remaining" = "Credits: %@"; + "cursor_on_demand" = "On-demand: %@"; + "cursor_on_demand_with_limit" = "On-demand: %1$@ / %2$@"; + "extra_usage_format" = "Extra usage: %1$@ / %2$@"; + "jetbrains_detected_generate" = "Detected: %@. Use the AI assistant once to generate quota data, then refresh QuotaKit."; + "jetbrains_detected_select" = "Detected: %@. Select your preferred IDE in Settings, then refresh QuotaKit."; + "last_fetch_failed_with_provider" = "Last %@ fetch failed:"; + "last_spend" = "Last spend: %@"; + "mcp_model_usage" = "%1$@: %2$@"; + "mcp_resets" = "Resets: %@"; + "mcp_window" = "Window: %@"; + "metric_average" = "Average (%1$@ + %2$@)"; + "metric_primary" = "Primary (%@)"; + "metric_secondary" = "Secondary (%@)"; + "metric_tertiary" = "Tertiary (%@)"; + "multiple_workspaces_found" = "QuotaKit found multiple workspaces for %@. Please choose the workspace to add."; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "Choose up to %@ providers"; + "remove_account_message" = "Remove %@ from QuotaKit? Its managed Codex home will be deleted."; + "version_format" = "Version %@"; + "vertex_ai_login_instructions" = "To track Vertex AI usage, authenticate with Google Cloud.\n\n1. Open Terminal\n2. Run: gcloud auth application-default login\n3. Follow the browser prompts to sign in\n4. Set your project: gcloud config set project PROJECT_ID\n\nOpen Terminal now?"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID."; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + /* General Pane */ "section_system" = "System"; + "section_usage" = "Usage"; + "section_automation" = "Automation"; + "language_title" = "Language"; + "language_subtitle" = "Change the display language. Requires app restart to take full effect."; + "language_system" = "System"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_dutch" = "Nederlands"; + "language_swedish" = "Svenska"; + +"language_french" = "French"; + +"language_ukrainian" = "Українська"; + "start_at_login_title" = "Start at Login"; + "start_at_login_subtitle" = "Automatically opens QuotaKit when you start your Mac."; + "show_cost_summary" = "Show cost summary"; + "show_cost_summary_subtitle" = "Reads local usage logs. Shows today + the selected history window in the menu."; + "cost_history_days_title" = "History window: %d days"; + "cost_auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m"; + "refresh_cadence_title" = "Refresh cadence"; + "refresh_cadence_subtitle" = "How often QuotaKit polls providers in the background."; + "manual_refresh_hint" = "Auto-refresh is off; use the menu's Refresh command."; + "check_provider_status_title" = "Check provider status"; + "check_provider_status_subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; + "session_quota_notifications_title" = "Session quota notifications"; + "session_quota_notifications_subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; + "quota_warning_notifications_title" = "Quota warning notifications"; + "quota_warning_notifications_subtitle" = "Warns when session or weekly quota remaining crosses configured thresholds."; + "quota_warnings_title" = "Quota warnings"; + "quota_warning_session" = "session"; + "quota_warning_session_capitalized" = "Session"; + "quota_warning_weekly" = "weekly"; + "quota_warning_weekly_capitalized" = "Weekly"; + "quota_warning_notification_title" = "%1$@ %2$@ quota low"; + "quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; + "quota_warning_notification_body_with_account" = "Account %1$@. %2$@ left. Reached your %3$d%% %4$@ warning threshold."; + "session_depleted_notification_title" = "%@ session depleted"; + "session_depleted_notification_body" = "0% left. Will notify when it's available again."; + "session_restored_notification_title" = "%@ session restored"; + "session_restored_notification_body" = "Session quota is available again."; + "quota_warning_warn_at" = "Warn at"; + "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; + "quota_warning_sound" = "Play notification sound"; + "quota_warning_provider_inherits" = "Uses the global quota warning settings unless a window is customized here."; + "quota_warning_customize_thresholds" = "Customize %@ thresholds"; + "quota_warning_enable_warnings" = "Enable %@ warnings"; + "quota_warning_window_warn_at" = "%@ warn at"; + "quota_warning_off" = "Off"; + "quota_warning_inherited" = "Inherited: %@"; + "quota_warning_depleted_only" = "depleted only"; + "quota_warning_upper" = "Upper"; + "quota_warning_lower" = "Lower"; + "apply" = "Apply"; + "quit_app" = "Quit QuotaKit"; + /* Tab titles */ "tab_general" = "General"; + "tab_mobile" = "Mobile"; + "tab_providers" = "Providers"; + "tab_display" = "Display"; + "tab_advanced" = "Advanced"; + "tab_about" = "About"; + "tab_debug" = "Debug"; + /* Providers Pane */ "select_a_provider" = "Select a provider"; + "cancel" = "Cancel"; + "last_fetch_failed" = "last fetch failed"; + "usage_not_fetched_yet" = "usage not fetched yet"; + "managed_account_storage_unreadable" = "Managed account storage is unreadable. Live account access is still available, but managed add, re-auth, and remove actions are disabled until the store is recoverable."; + "remove_codex_account_title" = "Remove Codex account?"; + "remove" = "Remove"; + "managed_login_already_running" = "A managed Codex login is already running. Wait for it to finish before adding or re-authenticating another account."; + "managed_login_failed" = "Managed Codex login did not complete. Verify that `codex --version` works in Terminal. If macOS blocked or moved `codex` to Trash, remove stale duplicate installs, run `npm install -g --include=optional @openai/codex@latest`, then try again."; + "codex_login_output" = "codex login output:"; + "managed_login_missing_email" = "Codex login completed, but no account email was available. Try again after confirming the account is fully signed in."; + "login_success_notification_title" = "%@ login successful"; + "login_success_notification_body" = "You can return to the app; authentication finished."; + "workspace_selection_cancelled" = "QuotaKit found multiple workspaces, but no workspace was selected."; + "unsafe_managed_home" = "QuotaKit refused to modify an unexpected managed home path: %@"; + "menu_bar_metric_title" = "Menu bar metric"; + "menu_bar_metric_subtitle" = "Choose which window drives the menu bar percent."; + "menu_bar_metric_subtitle_deepseek" = "Shows the DeepSeek balance in the menu bar."; + "menu_bar_metric_subtitle_moonshot" = "Shows the Moonshot / Kimi API balance in the menu bar."; + "menu_bar_metric_subtitle_mistral" = "Shows current-month Mistral API spend in the menu bar."; + "menu_bar_metric_subtitle_kimik2" = "Shows Kimi K2 API-key credits in the menu bar."; + "automatic" = "Automatic"; + "primary_api_key_limit" = "Primary (API key limit)"; + /* Display Pane */ "section_menu_bar" = "Menu bar"; + "merge_icons_title" = "Merge Icons"; + "merge_icons_subtitle" = "Use a single menu bar icon with a provider switcher."; + "switcher_shows_icons_title" = "Switcher shows icons"; + "switcher_shows_icons_subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; + "show_most_used_provider_title" = "Show most-used provider"; + "show_most_used_provider_subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; + "menu_bar_shows_percent_title" = "Menu bar shows percent"; + "menu_bar_shows_percent_subtitle" = "Replace critter bars with provider branding icons and a percentage."; + "display_mode_title" = "Display mode"; + "display_mode_subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; + "section_menu_content" = "Menu content"; + "show_usage_as_used_title" = "Show usage as used"; + "show_usage_as_used_subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; + "show_quota_warning_markers_title" = "Show quota warning markers"; + "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; + "weekly_progress_work_days_title" = "Weekly progress work days"; + "weekly_progress_work_days_subtitle" = "Draw day-boundary tick marks on weekly usage bars."; + "show_reset_time_as_clock_title" = "Show reset time as clock"; + "show_reset_time_as_clock_subtitle" = "Display reset times as absolute clock values instead of countdowns."; + "show_provider_changelog_links_title" = "Show provider changelog links"; + "show_provider_changelog_links_subtitle" = "Adds release-notes links for supported CLI-backed providers to the menu."; + "show_credits_extra_usage_title" = "Show credits + extra usage"; + "show_credits_extra_usage_subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; + "show_all_token_accounts_title" = "Show all token accounts"; + "show_all_token_accounts_subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; + "multi_account_layout_title" = "Multi-account layout"; + "multi_account_layout_subtitle" = "Choose segmented account switching or stacked account cards."; + "multi_account_layout_segmented" = "Segmented"; + "multi_account_layout_stacked" = "Stacked"; + "overview_tab_providers_title" = "Overview tab providers"; + "configure" = "Configure…"; + "overview_enable_merge_icons_hint" = "Enable Merge Icons to configure Overview tab providers."; + "overview_no_providers_hint" = "No enabled providers available for Overview."; + "overview_rows_follow_order" = "Overview rows always follow provider order."; + "overview_no_providers_selected" = "No providers selected"; + /* Advanced Pane */ "section_keyboard_shortcut" = "Keyboard shortcut"; + "open_menu_shortcut_title" = "Open menu"; + "open_menu_shortcut_subtitle" = "Trigger the menu bar menu from anywhere."; + "install_cli" = "Install CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "No writable bin dirs found."; + "show_debug_settings_title" = "Show Debug Settings"; + "show_debug_settings_subtitle" = "Expose troubleshooting tools in the Debug tab."; + "surprise_me_title" = "Surprise me"; + "surprise_me_subtitle" = "Check if you like your agents having some fun up there."; + "weekly_limit_confetti_title" = "Weekly limit confetti"; + "weekly_limit_confetti_subtitle" = "Play full-screen confetti when weekly usage resets."; + "hide_personal_info_title" = "Hide personal information"; + "hide_personal_info_subtitle" = "Obscure email addresses in the menu bar and menu UI."; + "show_provider_storage_usage_title" = "Show provider storage usage"; + "show_provider_storage_usage_subtitle" = "Show local disk usage in menus. Scans known provider-owned paths in the background."; + "section_keychain_access" = "Keychain access"; + "keychain_access_caption" = "Disable all Keychain reads and writes. Use this if macOS keeps prompting for 'Chrome/Brave/Edge Safe Storage' even after clicking Always Allow. Browser cookie import is unavailable while enabled; paste Cookie headers manually in Providers. Claude/Codex OAuth via the CLI still works."; + "disable_keychain_access_title" = "Disable Keychain access"; + "disable_keychain_access_subtitle" = "Prevents any Keychain access while enabled."; + /* About Pane */ "about_tagline" = "May your tokens never run out—keep agent limits in view."; + "link_github" = "GitHub"; + "link_website" = "Website"; + "link_twitter" = "Twitter"; + "link_email" = "Email"; + "check_updates_auto" = "Check for updates automatically"; + "update_channel" = "Update Channel"; + "check_for_updates" = "Check for Updates…"; + "updates_unavailable" = "Updates unavailable in this build."; + "copyright" = "© 2026 Peter Steinberger. MIT License."; + /* Debug Pane */ "section_logging" = "Logging"; + "enable_file_logging" = "Enable file logging"; + "enable_file_logging_subtitle" = "Write logs to %@ for debugging."; + "verbosity_title" = "Verbosity"; + "verbosity_subtitle" = "Controls how much detail is logged."; + "open_log_file" = "Open log file"; + "force_animation_next_refresh" = "Force animation on next refresh"; + "force_animation_next_refresh_subtitle" = "Temporarily shows the loading animation after the next refresh."; + "section_loading_animations" = "Loading animations"; + "loading_animations_caption" = "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior."; + "animation_random_default" = "Random (default)"; + "replay_selected_animation" = "Replay selected animation"; + "blink_now" = "Blink now"; + "section_probe_logs" = "Probe logs"; + "probe_logs_caption" = "Fetch the latest probe output for debugging; Copy keeps the full text."; + "fetch_log" = "Fetch log"; + "copy" = "Copy"; + "save_to_file" = "Save to file"; + "load_parse_dump" = "Load parse dump"; + "rerun_provider_autodetect" = "Re-run provider autodetect"; + "loading" = "Loading…"; + "no_log_yet_fetch" = "No log yet. Fetch to load."; + "section_fetch_strategy" = "Fetch strategy attempts"; + "fetch_strategy_caption" = "Last fetch pipeline decisions and errors for a provider."; + "section_openai_cookies" = "OpenAI cookies"; + "openai_cookies_caption" = "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt."; + "no_log_yet" = "No log yet. Update OpenAI cookies in Providers → Codex to run an import."; + "section_caches" = "Caches"; + "caches_caption" = "Clear cached cost scan results or browser cookie caches."; + "clear_cookie_cache" = "Clear cookie cache"; + "clear_cost_cache" = "Clear cost cache"; + "section_notifications" = "Notifications"; + "notifications_caption" = "Trigger test notifications for the 5-hour session window (depleted/restored)."; + "post_depleted" = "Post depleted"; + "post_restored" = "Post restored"; + "section_cli_sessions" = "CLI sessions"; + "cli_sessions_caption" = "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured."; + "keep_cli_sessions_alive" = "Keep CLI sessions alive"; + "keep_cli_sessions_alive_subtitle" = "Skip teardown between probes (debug-only)."; + "reset_cli_sessions" = "Reset CLI sessions"; + "section_error_simulation" = "Error simulation"; + "error_simulation_caption" = "Inject a fake error message into the menu card for layout testing."; + "set_menu_error" = "Set menu error"; + "clear_menu_error" = "Clear menu error"; + "set_cost_error" = "Set cost error"; + "clear_cost_error" = "Clear cost error"; + "section_cli_paths" = "CLI paths"; + "cli_paths_caption" = "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout)."; + "codex_binary" = "Codex binary"; + "claude_binary" = "Claude binary"; + "effective_path" = "Effective PATH"; + "unavailable" = "Unavailable"; + "login_shell_path" = "Login shell PATH (startup capture)"; + "cleared" = "Cleared."; + "no_fetch_attempts" = "No fetch attempts yet."; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on."; + /* Metric preferences */ "metric_pref_automatic" = "Automatic"; + "metric_pref_primary" = "Primary"; + "metric_pref_secondary" = "Secondary"; + "metric_pref_tertiary" = "Tertiary"; + "metric_pref_extra_usage" = "Extra usage"; + "metric_pref_average" = "Average"; + /* Display modes */ "display_mode_percent" = "Percent"; + "display_mode_pace" = "Pace"; + "display_mode_both" = "Both"; + "display_mode_percent_desc" = "Show remaining/used percentage (e.g. 45%)"; + "display_mode_pace_desc" = "Show pace indicator (e.g. +5%)"; + "display_mode_both_desc" = "Show both percentage and pace (e.g. 45% · +5%)"; + /* Provider status */ "status_operational" = "Operational"; + "status_partial_outage" = "Partial outage"; + "status_major_outage" = "Major outage"; + "status_critical_issue" = "Critical issue"; + "status_maintenance" = "Maintenance"; + "status_unknown" = "Status unknown"; + /* Refresh frequency */ "refresh_manual" = "Manual"; + "refresh_1min" = "1 min"; + "refresh_2min" = "2 min"; + "refresh_5min" = "5 min"; + "refresh_15min" = "15 min"; + "refresh_30min" = "30 min"; + // === Fork-only Mac UI strings (Mobile pane) — added 2026-05-12 === "mobile_section_icloud_sync" = "iCloud Sync"; + "mobile_toggle_sync_title" = "Sync usage to iCloud"; + "mobile_toggle_sync_subtitle" = "Pushes usage data to iCloud so the iOS companion app can display it."; + "mobile_section_push" = "iOS Push Notifications"; + "mobile_toggle_push_title" = "Push notifications to iOS"; + "mobile_toggle_push_subtitle" = "When a session quota is depleted or restored, send a visible alert push to the iOS companion app via iCloud. This is independent of Mac local notifications — you can keep Mac quiet but still get alerts on your iPhone."; + "mobile_section_mock_data" = "Debug · Mock Provider Data"; + "mobile_toggle_mock_title" = "Inject mock provider data"; + "mobile_toggle_mock_subtitle" = "Pushes 60 synthetic providers across 50 IDs (6 rich mocks for codex/claude/perplexity multi-account paths, 52 simple mocks including 7 multi-account second-tab pairs for openai/deepseek/antigravity/manus/copilot/venice/stepfun, 5 v0.27.0 single-account providers for grok/groq/elevenlabs/deepgram/llmproxy and 3 v0.28/v0.29 single-account providers for azureopenai/alibabatokenplan/t3chat, 2 unknown-ID mocks for fallback rendering) on every sync. All mock emails use the `.test` TLD so iPhone (1.5.2+) renders them with a MOCK badge. Toggle off and CloudKit automatically purges them within ~1 cycle. Default OFF."; + "mobile_mock_reference_header" = "Reference — most-tested 8 mocks (35 simple mocks omitted for brevity):"; + "mobile_mock_cost_note" = "Mocks add ~$85 to your 30-day cost dashboard while active. Toggle off to restore real numbers."; + "mobile_section_dev_test" = "DEV — iOS Push Test"; + "mobile_dev_test_intro" = "Writes a real `QuotaTransition` record to CloudKit, which fires the same alert push the iOS app would receive in production. Subject to the toggle above (must be ON)."; + "mobile_dev_depleted" = "Depleted"; + "mobile_dev_restored" = "Restored"; + "mobile_dev_warning" = "Warning"; + "mobile_dev_verify_push" = "Verify Push Setup"; + "mobile_sync_status_syncing" = "Syncing…"; + "mobile_sync_status_last_sync_format" = "Last sync: %@"; + "mobile_sync_status_last_attempt_format" = "Last attempt: %@"; + "mobile_sync_status_no_sync" = "No sync yet"; + "mobile_button_sync_now" = "Sync Now"; + /* Additional keys */ "not_found" = "Not found"; + /* Cost estimation */ "cost_header_estimated" = "Cost (estimated)"; + "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; + "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant."; + "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings."; + "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API token not found. Set apiKey in ~/.quotakit/config.json or Z_AI_API_KEY."; + "Missing DeepSeek API key." = "Missing DeepSeek API key."; + "%@ is unavailable in the current environment." = "%@ is unavailable in the current environment."; + "All Systems Operational" = "All Systems Operational"; + "Last 30 days" = "Last 30 days"; + "Last 30 days:" = "Last 30 days:"; + "This month" = "This month"; + "Store multiple OpenAI API keys." = "Store multiple OpenAI API keys."; + "Admin API key" = "Admin API key"; + "Open billing" = "Open billing"; + "Google accounts" = "Google accounts"; + "Store multiple Antigravity Google OAuth accounts for quick switching." = "Store multiple Antigravity Google OAuth accounts for quick switching."; + "Add Google Account" = "Add Google Account"; + "Open Token Plan" = "Open Token Plan"; + "Text Generation" = "Text Generation"; + "Text to Speech" = "Text to Speech"; + "Music Generation" = "Music Generation"; + "Image Generation" = "Image Generation"; + "No local data found" = "No local data found"; + "Credits unavailable; keep Codex running to refresh." = "Credits unavailable; keep Codex running to refresh."; + "No available fetch strategy for minimax." = "No available fetch strategy for minimax."; + "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant QuotaKit Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the QuotaKit menu (Add / switch account)."; + "No OpenCode session cookies found in browsers." = "No OpenCode session cookies found in browsers."; + "No available fetch strategy for %@." = "No available fetch strategy for %@."; + "Today" = "Today"; + "Today tokens" = "Today tokens"; + "30d cost" = "30d cost"; + "30d tokens" = "30d tokens"; + "Latest tokens" = "Latest tokens"; + "Top model" = "Top model"; + "Storage" = "Storage"; + "Add Account..." = "Add Account..."; + "Usage Dashboard" = "Usage Dashboard"; + "Status Page" = "Status Page"; + "Settings..." = "Settings..."; + "About CodexBar" = "About QuotaKit"; + "Quit" = "Quit"; + "Last %d day" = "Last %d day"; + "Last %d days" = "Last %d days"; + "%@ tokens" = "%@ tokens"; + "Latest billing day" = "Latest billing day"; + "Latest billing day (%@)" = "Latest billing day (%@)"; + "%@ left" = "%@ left"; + "Resets %@" = "Resets %@"; + "Resets in %@" = "Resets in %@"; + "Resets now" = "Resets now"; + "Lasts until reset" = "Lasts until reset"; + "Updated %@" = "Updated %@"; + "Updated %@h ago" = "Updated %@h ago"; + "Updated %@m ago" = "Updated %@m ago"; + "Updated just now" = "Updated just now"; + "Projected empty in %@" = "Projected empty in %@"; + "Runs out in %@" = "Runs out in %@"; + "Pace: %@" = "Pace: %@"; + "Pace: %@ · %@" = "Pace: %@ · %@"; + "%@ · %@" = "%@ · %@"; + "≈ %d%% run-out risk" = "≈ %d%% run-out risk"; + "%d%% in deficit" = "%d%% in deficit"; + "%d%% in reserve" = "%d%% in reserve"; + "usage_percent_suffix_left" = "left"; + "usage_percent_suffix_used" = "used"; + "Store multiple DeepSeek API keys." = "Store multiple DeepSeek API keys."; + "This week" = "This week"; + "Week" = "Week"; + "Month" = "Month"; + "Models" = "Models"; + "24h tokens" = "24h tokens"; + "Latest hour" = "Latest hour"; + "Peak hour" = "Peak hour"; + "Top method" = "Top method"; + "30d cash" = "30d cash"; + "30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; + "AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; + "Rate limit: %d / %@" = "Rate limit: %d / %@"; + "Key remaining" = "Key remaining"; + "No limit set for the API key" = "No limit set for the API key"; + "API key limit unavailable right now" = "API key limit unavailable right now"; + "This month: %@ tokens" = "This month: %@ tokens"; + "No utilization data yet." = "No utilization data yet."; + "No %@ utilization data yet." = "No %@ utilization data yet."; + "%@: %@%% used" = "%@: %@%% used"; + "%dd" = "%dd"; + "today" = "today"; + "just now" = "just now"; + "On pace" = "On pace"; + "Runs out now" = "Runs out now"; + "Projected empty now" = "Projected empty now"; + "Switch Account..." = "Switch Account..."; + "Update ready, restart now?" = "Update ready, restart now?"; + "Daily" = "Daily"; + "Hourly Tokens" = "Hourly Tokens"; + "No data" = "No data"; + "No usage breakdown data available." = "No usage breakdown data available."; + "Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; + "Today: %@" = "Today: %@"; + "Today: %@ tokens" = "Today: %@ tokens"; + "Last 30 days: %@ · %@ tokens" = "Last 30 days: %@ · %@ tokens"; + "Last 30 days: %@" = "Last 30 days: %@"; + "Est. total (30d): %@" = "Est. total (30d): %@"; + "Est. total (%@): %@" = "Est. total (%@): %@"; + "Hover a bar for details" = "Hover a bar for details"; + "%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; + "No providers selected for Overview." = "No providers selected for Overview."; + "No overview data available." = "No overview data available."; + "Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto uses the local IDE API first, then Google OAuth when the IDE is closed."; + "Login with Google" = "Login with Google"; + /* Popup panels */ "No usage configured." = "No usage configured."; + "Quota" = "Quota"; + "tokens" = "tokens"; + "requests" = "requests"; + "Latest" = "Latest"; + "Monthly" = "Monthly"; + "Sonnet" = "Sonnet"; + "Overages" = "Overages"; + "Activity" = "Activity"; + "Copied" = "Copied"; + "Copy error" = "Copy error"; + "Copy path" = "Copy path"; + "Extra usage spent" = "Extra usage spent"; + "Credits remaining" = "Credits remaining"; + "Using CLI fallback" = "Using CLI fallback"; + "Balance updates in near-real time (up to 5 min lag)" = "Balance updates in near-real time (up to 5 min lag)"; + "Daily billing data finalizes at 07:00 UTC" = "Daily billing data finalizes at 07:00 UTC"; + "%@ of %@ credits left" = "%@ of %@ credits left"; + "%@ of %@ bonus credits left" = "%@ of %@ bonus credits left"; + "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ remaining)"; + "%@/%@ left" = "%@/%@ left"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "Regenerates %@"; + "used after next regen" = "used after next regen"; + "after next regen" = "after next regen"; + "Near full" = "Near full"; + "Full in ~1 regen" = "Full in ~1 regen"; + "Full in ~%.0f regens" = "Full in ~%.0f regens"; + "Overage usage" = "Overage usage"; + "Overage cost" = "Overage cost"; + "credits" = "credits"; + "Zen balance" = "Zen balance"; + "API spend" = "API spend"; + "Extra usage" = "Extra usage"; + "Quota usage" = "Quota usage"; + "%.0f%% used" = "%.0f%% used"; + "Usage history (today)" = "Usage history (today)"; + "Usage history (%d days)" = "Usage history (%d days)"; + "%d percent remaining" = "%d percent remaining"; + "Unknown" = "Unknown"; + "stale data" = "stale data"; + "No credits history data." = "No credits history data."; + "No credits history data available." = "No credits history data available."; + "Credits history chart" = "Credits history chart"; + "%d days of credits data" = "%d days of credits data"; + "Usage breakdown chart" = "Usage breakdown chart"; + "%d days of usage data across %d services" = "%d days of usage data across %d services"; + "Cost history chart" = "Cost history chart"; + "%d days of cost data" = "%d days of cost data"; + "Plan utilization chart" = "Plan utilization chart"; + "%d utilization samples" = "%d utilization samples"; + "Hourly Usage" = "Hourly Usage"; + "Usage remaining" = "Usage remaining"; + "Usage used" = "Usage used"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "API key verified. Ollama does not expose Cloud quota limits through the API."; + "Last 30 days: %@ tokens" = "Last 30 days: %@ tokens"; + "7d spend" = "7d spend"; + "30d spend" = "30d spend"; + "Cache read" = "Cache read"; + "Claude Admin API 30 day spend trend" = "Claude Admin API 30 day spend trend"; + "OpenRouter API key spend trend" = "OpenRouter API key spend trend"; + "z.ai hourly token trend" = "z.ai hourly token trend"; + "MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; + "Today cash" = "Today cash"; + "DeepSeek 30 day token usage trend" = "DeepSeek 30 day token usage trend"; + "cache-hit input" = "cache-hit input"; + "cache-miss input" = "cache-miss input"; + "output" = "output"; + "Requests" = "Requests"; + "Reported by OpenAI Admin API organization usage." = "Reported by OpenAI Admin API organization usage."; + "Reported by Mistral billing usage." = "Reported by Mistral billing usage."; + "Google OAuth" = "Google OAuth"; + "Add accounts via GitHub OAuth Device Flow on the selected host." = "Add accounts via GitHub OAuth Device Flow on the selected host."; + "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override."; + "Manual cleanup: past sessions" = "Manual cleanup: past sessions"; + "Clearing removes past resume, continue, and rewind history." = "Clearing removes past resume, continue, and rewind history."; + "Manual cleanup: file checkpoints" = "Manual cleanup: file checkpoints"; + "Clearing removes checkpoint restore data for previous edits." = "Clearing removes checkpoint restore data for previous edits."; + "Manual cleanup: saved plans" = "Manual cleanup: saved plans"; + "Clearing removes old plan-mode files." = "Clearing removes old plan-mode files."; + "Manual cleanup: debug logs" = "Manual cleanup: debug logs"; + "Clearing removes past debug logs." = "Clearing removes past debug logs."; + "Manual cleanup: attachment cache" = "Manual cleanup: attachment cache"; + "Clearing removes cached large pastes or attached images." = "Clearing removes cached large pastes or attached images."; + "Manual cleanup: session metadata" = "Manual cleanup: session metadata"; + "Clearing removes per-session environment metadata." = "Clearing removes per-session environment metadata."; + "Manual cleanup: shell snapshots" = "Manual cleanup: shell snapshots"; + "Clearing removes leftover runtime shell snapshot files." = "Clearing removes leftover runtime shell snapshot files."; + "Manual cleanup: legacy todos" = "Manual cleanup: legacy todos"; + "Clearing removes legacy per-session task lists." = "Clearing removes legacy per-session task lists."; + "Manual cleanup: sessions" = "Manual cleanup: sessions"; + "Clearing removes past Codex session history." = "Clearing removes past Codex session history."; + "Manual cleanup: archived sessions" = "Manual cleanup: archived sessions"; + "Clearing removes archived Codex session history." = "Clearing removes archived Codex session history."; + "Manual cleanup: cache" = "Manual cleanup: cache"; + "Clearing removes provider-owned cached data." = "Clearing removes provider-owned cached data."; + "Manual cleanup: logs" = "Manual cleanup: logs"; + "Clearing removes local diagnostic logs." = "Clearing removes local diagnostic logs."; + "Manual cleanup: file history" = "Manual cleanup: file history"; + "Clearing removes local edit checkpoint history." = "Clearing removes local edit checkpoint history."; + "Manual cleanup: temporary data" = "Manual cleanup: temporary data"; + "Clearing removes local temporary provider data." = "Clearing removes local temporary provider data."; + "Total: %@" = "Total: %@"; + "%d more items" = "%d more items"; + "Cleanup ideas" = "Cleanup ideas"; + "%d unreadable item(s) skipped" = "%d unreadable item(s) skipped"; + "API key limit" = "API key limit"; + "Auth" = "Auth"; + "Auto" = "Auto"; + "Disabled — no recent data" = "Disabled — no recent data"; + "Limits not available" = "Limits not available"; + "No usage yet" = "No usage yet"; + "Not fetched yet" = "Not fetched yet"; + "Refreshing" = "Refreshing"; + "Session" = "Session"; + "Source" = "Source"; + "State" = "State"; + "Unavailable" = "Unavailable"; + "Weekly" = "Weekly"; + "not detected" = "not detected"; + "Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; + "minimax_usage_amount_format" = "Usage: %@ / %@"; + "minimax_used_percent_format" = "Used %@"; + "minimax_service_text_generation" = "Text Generation"; + "minimax_service_text_to_speech" = "Text to Speech"; + "minimax_service_music_generation" = "Music Generation"; + "minimax_service_image_generation" = "Image Generation"; + "minimax_service_lyrics_generation" = "Lyrics generation"; + "minimax_service_coding_plan_vlm" = "Coding plan VLM"; + "minimax_service_coding_plan_search" = "Coding plan search"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ is waiting for permission"; + "%@ requests" = "%@ requests"; + "%@: %@ credits" = "%@: %@ credits"; + "30d requests" = "30d requests"; + "4 days" = "4 days"; + "5 days" = "5 days"; + "7 days" = "7 days"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "API key verifies Ollama Cloud access; cookies still expose quota limits."; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID."; + "AWS region. Can also be set with AWS_REGION." = "AWS region. Can also be set with AWS_REGION."; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY."; + "Access key ID" = "Access key ID"; + "Add Account" = "Add Account"; + "Adding Account…" = "Adding Account…"; + "Antigravity login failed" = "Antigravity login failed"; + "Antigravity login timed out" = "Antigravity login timed out"; + "Auth source" = "Auth source"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Automatic imports Chrome browser cookies from Xiaomi MiMo."; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatic imports Windsurf session data from Chromium browser localStorage."; + "Automatic imports browser cookies from Bailian." = "Automatic imports browser cookies from Bailian."; + "Automatically imports browser cookies." = "Automatically imports browser cookies."; + "Automatically imports browser session cookies." = "Automatically imports browser session cookies."; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported."; + "Azure OpenAI key" = "Azure OpenAI key"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported."; + "Base URL" = "Base URL"; + "Base URL for the LLM-API-Key-Proxy instance." = "Base URL for the LLM-API-Key-Proxy instance."; + "Browser cookies" = "Browser cookies"; + "Cap end" = "Cap end"; + "Cap start" = "Cap start"; + "Capacity End" = "Capacity End"; + "Capacity Start" = "Capacity Start"; + "Changelog" = "Changelog"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Choose the Moonshot/Kimi API host for international or China mainland accounts."; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit can't replace a system account that is signed in with an API key only setup."; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit could not find saved auth for that account. Re-authenticate it and try again."; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit could not read managed account storage. Recover the store before adding another account."; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit could not read saved auth for that account. Re-authenticate it and try again."; + "CodexBar could not read the current system account on this Mac." = "QuotaKit could not read the current system account on this Mac."; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit could not replace the live Codex auth on this Mac."; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit could not safely preserve the current system account before switching."; + "CodexBar could not save the current system account before switching." = "QuotaKit could not save the current system account before switching."; + "CodexBar could not update managed account storage." = "QuotaKit could not update managed account storage."; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit found another managed account that already uses the current system account. Resolve the duplicate account before switching."; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue."; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue."; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue."; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue."; + "Could not open Cursor login in your browser." = "Could not open Cursor login in your browser."; + "Could not open browser for Antigravity" = "Could not open browser for Antigravity"; + "Credits used" = "Credits used"; + "Day" = "Day"; + "Deployment" = "Deployment"; + "Drag to reorder" = "Drag to reorder"; + "Endpoint" = "Endpoint"; + "Enterprise host" = "Enterprise host"; + "Extra usage balance: %@" = "Extra usage balance: %@"; + "Keychain Access Required" = "Keychain Access Required"; + "Kiro menu bar value" = "Kiro menu bar value"; + "Label" = "Label"; + "No organizations loaded. Click Refresh after setting your API key." = "No organizations loaded. Click Refresh after setting your API key."; + "No output captured." = "No output captured."; + "No system account" = "No system account"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "Open Augment (Log Out & Back In)"; + "Open Codebuff Dashboard" = "Open Codebuff Dashboard"; + "Open Command Code Settings" = "Open Command Code Settings"; + "Open Crof dashboard" = "Open Crof dashboard"; + "Open Manus" = "Open Manus"; + "Open MiMo Balance" = "Open MiMo Balance"; + "Open Moonshot Console" = "Open Moonshot Console"; + "Open Ollama API Keys" = "Open Ollama API Keys"; + "Open StepFun Platform" = "Open StepFun Platform"; + "Open T3 Chat Settings" = "Open T3 Chat Settings"; + "Open Volcengine Ark Console" = "Open Volcengine Ark Console"; + "Open legacy provider docs" = "Open legacy provider docs"; + "Open projects" = "Open projects"; + "Open this URL manually to continue login:\n\n%@" = "Open this URL manually to continue login:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optional organization ID for accounts linked to multiple Anthropic organizations."; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID."; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com."; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optional. Leave blank to discover and aggregate projects visible to the API key."; + "Org ID (optional)" = "Org ID (optional)"; + "Organizations" = "Organizations"; + "Password" = "Password"; + "%@ authentication is disabled." = "%@ authentication is disabled."; + "%@ cookies are disabled." = "%@ cookies are disabled."; + "%@ web API access is disabled." = "%@ web API access is disabled."; + "Disable %@ dashboard cookie usage." = "Disable %@ dashboard cookie usage."; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain access is disabled in Advanced, so browser cookie import is unavailable."; + "Manually paste an %@ from a browser session." = "Manually paste an %@ from a browser session."; + "Paste a Cookie header captured from %@." = "Paste a Cookie header captured from %@."; + "Paste a Cookie header from %@." = "Paste a Cookie header from %@."; + "Paste a Cookie header or cURL capture from %@." = "Paste a Cookie header or cURL capture from %@."; + "Paste a Cookie header or full cURL capture from %@." = "Paste a Cookie header or full cURL capture from %@."; + "Paste a Cookie or Authorization header from %@." = "Paste a Cookie or Authorization header from %@."; + "Paste a full cookie header or the %@ value." = "Paste a full cookie header or the %@ value."; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Paste a Cookie header or full cURL capture from T3 Chat settings."; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie."; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com."; + "Paste the %@ JSON bundle from %@." = "Paste the %@ JSON bundle from %@."; + "Paste the %@ value or a full Cookie header." = "Paste the %@ value or a full Cookie header."; + "Personal account" = "Personal account"; + "Project ID" = "Project ID"; + "Re-auth" = "Re-auth"; + "Re-authenticating…" = "Re-authenticating…"; + "Refresh Session" = "Refresh Session"; + "Refresh organizations" = "Refresh organizations"; + "Region" = "Region"; + "Reload" = "Reload"; + "Reorder" = "Reorder"; + "Secret access key" = "Secret access key"; + "Series" = "Series"; + "Service" = "Service"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Show or hide Kiro credits, percent, or both next to the menu bar icon."; + "Show usage for organizations you belong to. Personal account is always shown." = "Show usage for organizations you belong to. Personal account is always shown."; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Sign in to cursor.com in your browser, then refresh Cursor in QuotaKit."; + "Simulated error text" = "Simulated error text"; + "StepFun platform account (phone number or email)." = "StepFun platform account (phone number or email)."; + "Stored in ~/.codexbar/config.json." = "Stored in ~/.quotakit/config.json."; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Stored in ~/.quotakit/config.json. AZURE_OPENAI_API_KEY is also supported."; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Stored in ~/.quotakit/config.json. For the official Kimi API, use Moonshot / Kimi API."; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Stored in ~/.quotakit/config.json. Get your API key from the Volcengine Ark console."; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Stored in ~/.quotakit/config.json. Get your key from Ollama settings."; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Stored in ~/.quotakit/config.json. Get your key from console.deepgram.com."; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Stored in ~/.quotakit/config.json. Get your key from elevenlabs.io/app/settings/api-keys."; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Stored in ~/.quotakit/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking."; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Stored in ~/.quotakit/config.json. In Warp, open Settings > Platform > API Keys, then create one."; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Stored in ~/.quotakit/config.json. Metrics require Groq Enterprise Prometheus access."; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Stored in ~/.quotakit/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works."; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Stored in ~/.quotakit/config.json. Requires an Anthropic Admin API key."; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Stored in ~/.quotakit/config.json. Used for /v1/quota-stats."; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Stored in ~/.quotakit/config.json. You can also provide CODEBUFF_API_KEY or let QuotaKit read ~/.config/manicode/credentials.json (created by `codebuff login`)."; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stored in ~/.quotakit/config.json. You can also provide CROF_API_KEY."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stored in ~/.quotakit/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)."; + "T3 Chat cookie" = "T3 Chat cookie"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "That account is no longer available in QuotaKit. Refresh the account list and try again."; + "The browser login did not complete in time. Try Antigravity login again." = "The browser login did not complete in time. Try Antigravity login again."; + "Timed out waiting for Cursor login. %@" = "Timed out waiting for Cursor login. %@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "Timed out waiting for Cursor login. %@ Last error: %@"; + "Today requests" = "Today requests"; + "Total (30d): %@ credits" = "Total (30d): %@ credits"; + "Username" = "Username"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "Uses username + password to login and obtain an Oasis-Token automatically."; + "Uses username + password to login and obtain an %@ automatically." = "Uses username + password to login and obtain an %@ automatically."; + "Utilization End" = "Utilization End"; + "Utilization Start" = "Utilization Start"; + "Verbosity" = "Verbosity"; + "Windsurf session JSON bundle" = "Windsurf session JSON bundle"; + "Workspace ID" = "Workspace ID"; + "Your StepFun platform password. Used to login and obtain a session token." = "Your StepFun platform password. Used to login and obtain a session token."; + "claude /login exited with status %d." = "claude /login exited with status %d."; + "codex login exited with status %d." = "codex login exited with status %d."; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nor paste the __Secure-next-auth.session-token value"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nor paste the kimi-auth token value"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nor paste just the session_id value"; + "Clear" = "Clear"; + "No matching providers" = "No matching providers"; + "Search providers" = "Search providers"; + + +"language_vietnamese" = "Vietnamese"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 741011afd..4cae52265 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1,918 +1,1814 @@ /* Spanish localization for CodexBar */ " providers" = " proveedores"; + "(System)" = "(Sistema)"; + "30d" = "30 d"; + "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; + "API key" = "Clave de API"; + "API region" = "Región de API"; + "API token" = "Token de API"; + "API tokens" = "Tokens de API"; + "About" = "Acerca de"; + "Account" = "Cuenta"; + "Accounts" = "Cuentas"; + "Accounts subtitle" = "Subtítulo de cuentas"; + "Active" = "Activo"; + "Add" = "Añadir"; + "Add Workspace" = "Añadir espacio de trabajo"; + "Advanced" = "Avanzado"; + "All" = "Todo"; + "Always allow prompts" = "Permitir siempre las solicitudes"; + "Animation pattern" = "Patrón de animación"; + "Antigravity login is managed in the app" = "El inicio de sesión de Antigravity se gestiona en la app"; + "Applies only to the Security.framework OAuth keychain reader." = "Solo se aplica al lector de Llavero OAuth de Security.framework."; + "Auto falls back to the next source if the preferred one fails." = "Auto recurre a la siguiente fuente si la preferida falla."; + "Auto uses API first, then falls back to CLI on auth failures." = "Auto usa primero la API y recurre a la CLI si falla la autenticación."; + "Auto-detect" = "Detección automática"; + "Auto-refresh is off; use the menu's Refresh command." = "La actualización automática está desactivada; usa el comando Actualizar del menú."; + "Auto-refresh: hourly · Timeout: 10m" = "Actualización automática: cada hora · Tiempo de espera: 10 m"; + "Automatic" = "Automático"; + "Automatic imports browser cookies and WorkOS tokens." = "El modo automático importa cookies del navegador y tokens de WorkOS."; + "Automatic imports browser cookies and local storage tokens." = "El modo automático importa cookies del navegador y tokens del almacenamiento local."; + "Automatic imports browser cookies for dashboard extras." = "El modo automático importa cookies del navegador para los extras del panel."; + "Automatic imports browser cookies for the web API." = "El modo automático importa cookies del navegador para la API web."; + "Automatic imports browser cookies from Model Studio/Bailian." = "El modo automático importa cookies del navegador desde Model Studio/Bailian."; + "Automatic imports browser cookies from admin.mistral.ai." = "El modo automático importa cookies del navegador desde admin.mistral.ai."; + "Automatic imports browser cookies from opencode.ai." = "El modo automático importa cookies del navegador desde opencode.ai."; + "Automatic imports browser cookies or stored sessions." = "El modo automático importa cookies del navegador o sesiones guardadas."; + "Automatic imports browser cookies." = "El modo automático importa cookies del navegador."; + "Automatically imports browser session cookie." = "Importa automáticamente la cookie de sesión del navegador."; + "Automatically opens CodexBar when you start your Mac." = "Abre QuotaKit automáticamente al iniciar tu Mac."; + "Automation" = "Automatización"; + "Average (\\(label1) + \\(label2))" = "Promedio (\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Promedio (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "Evitar solicitudes del Llavero"; + "Balance" = "Saldo"; + "Battery Saver" = "Ahorro de batería"; + "Bordered" = "Con borde"; + "Build" = "Compilación"; + "Built \\(buildTimestamp)" = "Compilado \\(buildTimestamp)"; + "Buy Credits..." = "Comprar créditos..."; + "Buy Credits…" = "Comprar créditos…"; + "CLI paths" = "Rutas de la CLI"; + "CLI sessions" = "Sesiones de la CLI"; + "Caches" = "Cachés"; + "Cancel" = "Cancelar"; + "Check for Updates…" = "Buscar actualizaciones…"; + "Check for updates automatically" = "Buscar actualizaciones automáticamente"; + "Check if you like your agents having some fun up there." = "Actívalo si te gusta que tus agentes se diviertan ahí arriba."; + "Check provider status" = "Comprobar estado del proveedor"; + "Choose Codex workspace" = "Elegir espacio de trabajo de Codex"; + "Choose the MiniMax host (global .io or China mainland .com)." = "Elige el host de MiniMax (global .io o China continental .com)."; + "Choose up to " = "Elige hasta "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "Elige hasta \\(Self.maxOverviewProviders) proveedores"; + "Choose up to \\(count) providers" = "Elige hasta \\(count) proveedores"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Elige qué mostrar en la barra de menús (Ritmo muestra el uso frente al previsto)."; + "Choose which Codex account CodexBar should follow." = "Elige qué cuenta de Codex debe seguir QuotaKit."; + "Choose which window drives the menu bar percent." = "Elige qué ventana determina el porcentaje de la barra de menús."; + "Chrome" = "Chrome"; + "Claude CLI not found" = "No se encontró la CLI de Claude"; + "Claude binary" = "Binario de Claude"; + "Claude cookies" = "Cookies de Claude"; + "Claude login failed" = "El inicio de sesión de Claude falló"; + "Claude login timed out" = "El inicio de sesión de Claude agotó el tiempo de espera"; + "Close" = "Cerrar"; + "Codex CLI not found" = "No se encontró la CLI de Codex"; + "Codex account login already running" = "Ya hay un inicio de sesión de cuenta de Codex en curso"; + "Codex binary" = "Binario de Codex"; + "Codex login failed" = "El inicio de sesión de Codex falló"; + "Codex login timed out" = "El inicio de sesión de Codex agotó el tiempo de espera"; + "CodexBar Lifecycle Keepalive" = "Mantenimiento del ciclo de vida de QuotaKit"; + "QuotaKit can't show its menu bar icon" = "QuotaKit no puede mostrar su icono en la barra de menús"; + "CodexBar could not read managed account storage. " = "QuotaKit no pudo leer el almacenamiento de cuentas gestionadas. "; + "Configure…" = "Configurar…"; + "Connected" = "Conectado"; + "Controls how much detail is logged." = "Controla cuánto detalle se registra."; + "Cookie header" = "Cabecera de cookie"; + "Cookie source" = "Origen de la cookie"; + "Cookie: ..." = "Cookie: ..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\no pega una captura cURL del panel de Abacus AI"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\no pega el valor de __Secure-next-auth.session-token"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\no pega el valor del token kimi-auth"; + "Cookie: …" = "Cookie: …"; + "CopilotDeviceFlow" = "CopilotDeviceFlow"; + "Cost" = "Coste"; + "Could not add Codex account" = "No se pudo añadir la cuenta de Codex"; + "Could not open Terminal for Gemini" = "No se pudo abrir la Terminal para Gemini"; + "Could not start claude /login" = "No se pudo iniciar claude /login"; + "Could not start codex login" = "No se pudo iniciar codex login"; + "Could not switch system account" = "No se pudo cambiar la cuenta del sistema"; + "Credits" = "Créditos"; + "Credits history" = "Historial de créditos"; + "Cursor login failed" = "El inicio de sesión de Cursor falló"; + "Custom" = "Personalizado"; + "Custom Path" = "Ruta personalizada"; + "Daily Routines" = "Rutinas diarias"; + "Debug" = "Depuración"; + "Default" = "Predeterminado"; + "Disable Keychain access" = "Desactivar el acceso al Llavero"; + "Disabled" = "Desactivado"; + "Dismiss" = "Descartar"; + "Disconnected" = "Desconectado"; + "Display" = "Pantalla"; + "Display mode" = "Modo de visualización"; + "Display reset times as absolute clock values instead of countdowns." = "Mostrar las horas de reinicio como valores de reloj absolutos en lugar de cuentas atrás."; + "Done" = "Listo"; + "Effective PATH" = "PATH efectivo"; + "Email" = "Correo electrónico"; + "Enable Merge Icons to configure Overview tab providers." = "Activa Combinar iconos para configurar los proveedores de la pestaña Resumen."; + "Enable file logging" = "Activar registro en archivo"; + "Enabled" = "Activado"; + "Error" = "Error"; + "Error simulation" = "Simulación de errores"; + "Expose troubleshooting tools in the Debug tab." = "Muestra herramientas de diagnóstico en la pestaña Depuración."; + "Failed" = "Falló"; + "False" = "Falso"; + "Fetch strategy attempts" = "Intentos de estrategia de obtención"; + "Fetching" = "Obteniendo"; + "Field" = "Campo"; + "Field subtitle" = "Subtítulo del campo"; + "Finish the current managed account change before switching the system account." = "Termina el cambio de cuenta gestionada actual antes de cambiar la cuenta del sistema."; + "Force animation on next refresh" = "Forzar animación en la próxima actualización"; + "Gateway region" = "Región de la pasarela"; + "Gemini CLI not found" = "No se encontró la CLI de Gemini"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, mostrando incidencias en el icono y el menú."; + "General" = "General"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "Inicio de sesión de GitHub Copilot"; + "GitHub Login" = "Inicio de sesión de GitHub"; + "Hide details" = "Ocultar detalles"; + "Hide personal information" = "Ocultar información personal"; + "Historical tracking" = "Seguimiento histórico"; + "How often CodexBar polls providers in the background." = "Con qué frecuencia QuotaKit consulta a los proveedores en segundo plano."; + "Inactive" = "Inactivo"; + "Install CLI" = "Instalar CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instala la CLI de Claude (npm i -g @anthropic-ai/claude-code) e inténtalo de nuevo."; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instala la CLI de Codex (npm i -g @openai/codex) e inténtalo de nuevo."; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instala la CLI de Gemini (npm i -g @google/gemini-cli) e inténtalo de nuevo."; + "JetBrains AI is ready" = "JetBrains AI está listo"; + "JetBrains IDE" = "IDE de JetBrains"; + "Keep CLI sessions alive" = "Mantener activas las sesiones de la CLI"; + "Keyboard shortcut" = "Atajo de teclado"; + "Keychain access" = "Acceso al Llavero"; + "Keychain prompt policy" = "Política de solicitudes del Llavero"; + "Last \\(name) fetch failed:" = "La última obtención de \\(name) falló:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "La última obtención de \\(self.store.metadata(for: self.provider).displayName) falló:"; + "Last attempt" = "Último intento"; + "Link" = "Enlace"; + "Loading animations" = "Animaciones de carga"; + "Loading…" = "Cargando…"; + "Local" = "Local"; + "Logging" = "Registro"; + "Login failed" = "El inicio de sesión falló"; + "Login shell PATH (startup capture)" = "PATH del shell de inicio (captura al arrancar)"; + "Login timed out" = "El inicio de sesión agotó el tiempo de espera"; + "MCP details" = "Detalles de MCP"; + "Managed Codex accounts unavailable" = "Cuentas gestionadas de Codex no disponibles"; + "Managed account storage is unreadable. Live account access is still available, " = "El almacenamiento de cuentas gestionadas no se puede leer. El acceso a cuentas en vivo sigue disponible, "; + "Manual" = "Manual"; + "May your tokens never run out—keep agent limits in view." = "Que tus tokens nunca se agoten: mantén los límites de tus agentes a la vista."; + "Menu bar" = "Barra de menús"; + "Menu bar auto-shows the provider closest to its rate limit." = "La barra de menús muestra automáticamente el proveedor más cercano a su límite."; + "Menu bar metric" = "Métrica de la barra de menús"; + "Menu bar shows percent" = "La barra de menús muestra el porcentaje"; + "Menu content" = "Contenido del menú"; + "Merge Icons" = "Combinar iconos"; + "Never prompt" = "No solicitar nunca"; + "No" = "No"; + "No Codex accounts detected yet." = "Aún no se han detectado cuentas de Codex."; + "No JetBrains IDE detected" = "No se detectó ningún IDE de JetBrains"; + "No cost history data." = "No hay datos de historial de coste."; + "No credits history data." = "No hay datos de historial de créditos."; + "No data available" = "No hay datos disponibles"; + "No data yet" = "Aún no hay datos"; + "No enabled providers available for Overview." = "No hay proveedores activados disponibles para Resumen."; + "No providers selected" = "No hay proveedores seleccionados"; + "No token accounts yet." = "Aún no hay cuentas con token."; + "No usage breakdown data." = "No hay datos de desglose de uso."; + "None" = "Ninguno"; + "Notifications" = "Notificaciones"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa cuando la cuota de sesión de 5 horas llega al 0 % y cuando vuelve a estar "; + "OK" = "Aceptar"; + "Obscure email addresses in the menu bar and menu UI." = "Oculta las direcciones de correo en la barra de menús y la interfaz del menú."; + "Off" = "Desactivado"; + "Offline" = "Sin conexión"; + "On" = "Activado"; + "Online" = "En línea"; + "Only on user action" = "Solo en acciones del usuario"; + "Open" = "Abrir"; + "Open API Keys" = "Abrir claves de API"; + "Open Amp Settings" = "Abrir ajustes de Amp"; + "Open Antigravity to sign in, then refresh CodexBar." = "Abre Antigravity para iniciar sesión y luego actualiza QuotaKit."; + "Open Browser" = "Abrir navegador"; + "Open Coding Plan" = "Abrir plan de programación"; + "Open Console" = "Abrir Consola"; + "Open Dashboard" = "Abrir panel"; + "Open Mistral Admin" = "Abrir administración de Mistral"; + "Open Menu Bar Settings" = "Abrir ajustes de la barra de menús"; + "Open Ollama Settings" = "Abrir ajustes de Ollama"; + "Open Terminal" = "Abrir Terminal"; + "Open Usage Page" = "Abrir página de uso"; + "Open Warp API Key Guide" = "Abrir la guía de la clave de API de Warp"; + "Open menu" = "Abrir menú"; + "Open token file" = "Abrir archivo de token"; + "OpenAI cookies" = "Cookies de OpenAI"; + "OpenAI web extras" = "Extras web de OpenAI"; + "Option A" = "Opción A"; + "Option B" = "Opción B"; + "Optional override if workspace lookup fails." = "Anulación opcional si falla la búsqueda del espacio de trabajo."; + "Options" = "Opciones"; + "Override auto-detection with a custom IDE base path" = "Anular la detección automática con una ruta base de IDE personalizada"; + "Overview" = "Resumen"; + "Overview rows always follow provider order." = "Las filas de Resumen siempre siguen el orden de los proveedores."; + "Overview tab providers" = "Proveedores de la pestaña Resumen"; + "Paste API key…" = "Pega la clave de API…"; + "Paste API token…" = "Pega el token de API…"; + "Paste key…" = "Pega la clave…"; + "Paste sessionKey or OAuth token…" = "Pega la sessionKey o el token OAuth…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "Pega la cabecera Cookie de una petición a admin.mistral.ai. "; + "Paste token…" = "Pega el token…"; + "Personal" = "Personal"; + "Picker" = "Selector"; + "Picker subtitle" = "Subtítulo del selector"; + "Placeholder" = "Marcador de posición"; + "Plan" = "Plan"; + "Play full-screen confetti when weekly usage resets." = "Mostrar confeti a pantalla completa cuando se reinicia el uso semanal."; + "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta las páginas de estado de OpenAI/Claude y Google Workspace para "; + "Prevents any Keychain access while enabled." = "Impide cualquier acceso al Llavero mientras esté activado."; + "Primary (API key limit)" = "Principal (límite de la clave de API)"; + "Primary (\\(label))" = "Principal (\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "Principal (\\(metadata.sessionLabel))"; + "Probe logs" = "Registros de sondeo"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "Las barras de progreso se llenan a medida que consumes la cuota (en lugar de mostrar lo restante)."; + "Provider" = "Proveedor"; + "Providers" = "Proveedores"; + "Quit CodexBar" = "Salir de QuotaKit"; + "Random (default)" = "Aleatorio (predeterminado)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lee los registros de uso locales. Muestra el coste de hoy + la ventana de historial seleccionada en el menú."; + "Refresh" = "Actualizar"; + "Refresh cadence" = "Frecuencia de actualización"; + "Remote" = "Remoto"; + "Remove" = "Eliminar"; + "Remove Codex account?" = "¿Eliminar la cuenta de Codex?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "¿Eliminar \\(account.email) de QuotaKit? Su directorio Codex gestionado se borrará."; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "¿Eliminar \\(email) de QuotaKit? Su directorio Codex gestionado se borrará."; + "Remove selected account" = "Eliminar la cuenta seleccionada"; + "Replace critter bars with provider branding icons and a percentage." = "Sustituir las barras de bichitos por iconos de marca del proveedor y un porcentaje."; + "Replay selected animation" = "Reproducir la animación seleccionada"; + "Requires authentication via GitHub Device Flow." = "Requiere autenticación mediante el flujo de dispositivo de GitHub."; + "Resets: \\(reset)" = "Se reinicia: \\(reset)"; + "Rolling five-hour limit" = "Límite móvil de cinco horas"; + "Search hourly" = "Búsquedas por hora"; + "Secondary (\\(label))" = "Secundario (\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "Secundario (\\(metadata.weeklyLabel))"; + "Select a provider" = "Selecciona un proveedor"; + "Select the IDE to monitor" = "Selecciona el IDE a monitorizar"; + "Session quota notifications" = "Notificaciones de cuota de sesión"; + "Session tokens" = "Tokens de sesión"; + "Settings" = "Ajustes"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "Mostrar las secciones de Créditos de Codex y Uso adicional de Claude en el menú."; + "Show Debug Settings" = "Mostrar ajustes de depuración"; + "Show all token accounts" = "Mostrar todas las cuentas con token"; + "Show cost summary" = "Mostrar resumen de coste"; + "Show credits + extra usage" = "Mostrar créditos + uso adicional"; + "Show details" = "Mostrar detalles"; + "Show most-used provider" = "Mostrar el proveedor más usado"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostrar los iconos de proveedor en el selector (de lo contrario, mostrar una línea de progreso semanal)."; + "Show reset time as clock" = "Mostrar la hora de reinicio como reloj"; + "Show usage as used" = "Mostrar el uso como consumido"; + "Sign in via button below" = "Inicia sesión con el botón de abajo"; + "Skip teardown between probes (debug-only)." = "Omitir el cierre entre sondeos (solo depuración)."; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apilar las cuentas con token en el menú (de lo contrario, mostrar una barra de cambio de cuenta)."; + "Start at Login" = "Abrir al iniciar sesión"; + "Status" = "Estado"; + "Store Claude sessionKey cookies or OAuth access tokens." = "Almacena cookies sessionKey de Claude o tokens de acceso OAuth."; + "Store multiple Abacus AI Cookie headers." = "Almacena varias cabeceras Cookie de Abacus AI."; + "Store multiple Augment Cookie headers." = "Almacena varias cabeceras Cookie de Augment."; + "Store multiple Cursor Cookie headers." = "Almacena varias cabeceras Cookie de Cursor."; + "Store multiple Factory Cookie headers." = "Almacena varias cabeceras Cookie de Factory."; + "Store multiple MiniMax Cookie headers." = "Almacena varias cabeceras Cookie de MiniMax."; + "Store multiple Mistral Cookie headers." = "Almacena varias cabeceras Cookie de Mistral."; + "Store multiple Ollama Cookie headers." = "Almacena varias cabeceras Cookie de Ollama."; + "Store multiple OpenCode Cookie headers." = "Almacena varias cabeceras Cookie de OpenCode."; + "Store multiple OpenCode Go Cookie headers." = "Almacena varias cabeceras Cookie de OpenCode Go."; + "Stored in the CodexBar config file." = "Almacenado en el archivo de configuración de QuotaKit."; + "Stored in ~/.codexbar/config.json. " = "Almacenado en ~/.quotakit/config.json. "; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Almacenado en ~/.quotakit/config.json. Genera una en kimi-k2.ai."; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Almacenado en ~/.quotakit/config.json. Pega la clave del panel de Synthetic."; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Almacenado en ~/.quotakit/config.json. Pega tu clave de API del plan de programación desde Model Studio."; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Almacenado en ~/.quotakit/config.json. Pega tu clave de API de MiniMax."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Almacenado en ~/.quotakit/config.json. También puedes proporcionar KILO_API_KEY o "; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Almacena el historial de uso local de Codex (8 semanas) para personalizar las predicciones de Ritmo."; + "Subscription Utilization" = "Uso de la suscripción"; + "Surprise me" = "Sorpréndeme"; + "Switcher shows icons" = "El selector muestra iconos"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "Sistema"; + "Temporarily shows the loading animation after the next refresh." = "Muestra temporalmente la animación de carga tras la próxima actualización."; + "Tertiary (\\(label))" = "Terciario (\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "Terciario (\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "La cuenta de Codex predeterminada en este Mac."; + "Toggle" = "Interruptor"; + "Toggle subtitle" = "Subtítulo del interruptor"; + "Token" = "Token"; + "Trigger the menu bar menu from anywhere." = "Abrir el menú de la barra de menús desde cualquier lugar."; + "True" = "Verdadero"; + "Twitter" = "Twitter"; + "Unsupported" = "No compatible"; + "Update Channel" = "Canal de actualizaciones"; + "Updated" = "Actualizado"; + "Updates unavailable in this build." = "Actualizaciones no disponibles en esta compilación."; + "Usage" = "Uso"; + "Usage breakdown" = "Desglose de uso"; + "Usage history (30 days)" = "Historial de uso"; + "Usage source" = "Origen del uso"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usar BigModel para los endpoints de China continental (open.bigmodel.cn)."; + "Use a single menu bar icon with a provider switcher." = "Usar un único icono en la barra de menús con un selector de proveedor."; + "Use international or China mainland console gateways for quota fetches." = "Usar las pasarelas de consola internacionales o de China continental para obtener la cuota."; + "Version" = "Versión"; + "Version \\(self.versionString)" = "Versión \\(self.versionString)"; + "Version \\(version)" = "Versión \\(version)"; + "Version \\(versionString)" = "Versión \\(versionString)"; + "Vertex AI Login" = "Inicio de sesión de Vertex AI"; + "Wait for the current managed Codex login to finish before adding another account." = "Espera a que termine el inicio de sesión gestionado de Codex actual antes de añadir otra cuenta."; + "Waiting for Authentication..." = "Esperando la autenticación..."; + "Website" = "Sitio web"; + "Weekly limit confetti" = "Confeti del límite semanal"; + "Weekly token limit" = "Límite semanal de tokens"; + "Weekly usage" = "Uso semanal"; + "Weekly usage unavailable for this account." = "Uso semanal no disponible para esta cuenta."; + "Window: \\(window)" = "Ventana: \\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "Escribir registros en \\(self.fileLogPath) para depuración."; + "Yes" = "Sí"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + "\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 d \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name): obteniendo…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name): último intento \\(when)"; + "\\(name): no data yet" = "\\(name): aún sin datos"; + "\\(name): unsupported" = "\\(name): no compatible"; + "all browsers" = "todos los navegadores"; + "available again." = "disponible de nuevo."; + "built_format" = "Compilación %@"; + "copilot_complete_in_browser" = "Completa el inicio de sesión en tu navegador."; + "copilot_device_code" = "Código de dispositivo copiado al portapapeles: %1$@\n\nVerifícalo en: %2$@"; + "copilot_device_code_copied" = "Código de dispositivo copiado."; + "copilot_verify_at" = "Verifícalo en %@"; + "copilot_waiting_text" = "Completa el inicio de sesión en tu navegador.\nEsta ventana se cierra automáticamente cuando finaliza el inicio de sesión."; + "copilot_window_closes_auto" = "Esta ventana se cierra automáticamente cuando finaliza el inicio de sesión."; + "cost_status_error" = "%1$@: %2$@"; + "cost_status_fetching" = "%1$@: obteniendo… %2$@"; + "cost_status_last_attempt" = "%1$@: último intento %2$@"; + "cost_status_no_data" = "%@: aún sin datos"; + "cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@: no compatible"; + "credits_remaining" = "Créditos: %@"; + "cursor_on_demand" = "Bajo demanda: %@"; + "cursor_on_demand_with_limit" = "Bajo demanda: %1$@ / %2$@"; + "extra_usage_format" = "Uso adicional: %1$@ / %2$@"; + "jetbrains_detected_generate" = "Detectado: %@. Usa el asistente de IA una vez para generar datos de cuota y luego actualiza QuotaKit."; + "jetbrains_detected_select" = "Detectado: %@. Selecciona tu IDE preferido en Ajustes y luego actualiza QuotaKit."; + "last_fetch_failed_with_provider" = "La última obtención de %@ falló:"; + "last_spend" = "Último gasto: %@"; + "mcp_model_usage" = "%1$@: %2$@"; + "mcp_resets" = "Se reinicia: %@"; + "mcp_window" = "Ventana: %@"; + "metric_average" = "Promedio (%1$@ + %2$@)"; + "metric_primary" = "Principal (%@)"; + "metric_secondary" = "Secundario (%@)"; + "metric_tertiary" = "Terciario (%@)"; + "multiple_workspaces_found" = "QuotaKit encontró varios espacios de trabajo para %@. Elige el espacio de trabajo que añadir."; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "Elige hasta %@ proveedores"; + "remove_account_message" = "¿Eliminar %@ de QuotaKit? Su directorio Codex gestionado se borrará."; + "version_format" = "Versión %@"; + "vertex_ai_login_instructions" = "Para hacer seguimiento del uso de Vertex AI, autentícate con Google Cloud.\n\n1. Abre la Terminal\n2. Ejecuta: gcloud auth application-default login\n3. Sigue las indicaciones del navegador para iniciar sesión\n4. Define tu proyecto: gcloud config set project PROJECT_ID\n\n¿Abrir la Terminal ahora?"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID está definido, pero solo opencode, opencodego y deepgram admiten workspaceID."; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licencia MIT."; + /* General Pane */ "section_system" = "Sistema"; + "section_usage" = "Uso"; + "section_automation" = "Automatización"; + "language_title" = "Idioma"; + "language_subtitle" = "Cambia el idioma de la interfaz. Requiere reiniciar la app para aplicarse por completo."; + "language_system" = "Sistema"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "Sueco"; + +"language_dutch" = "Nederlands"; + +"language_french" = "Francés"; + +"language_ukrainian" = "Ucraniano"; + "start_at_login_title" = "Abrir al iniciar sesión"; + "start_at_login_subtitle" = "Abre QuotaKit automáticamente al iniciar tu Mac."; + "show_cost_summary" = "Mostrar resumen de coste"; + "show_cost_summary_subtitle" = "Lee los registros de uso locales. Muestra el coste de hoy + la ventana de historial seleccionada en el menú."; + "cost_history_days_title" = "Ventana de historial: %d días"; + "cost_auto_refresh_info" = "Actualización automática: cada hora · Tiempo de espera: 10 m"; + "refresh_cadence_title" = "Frecuencia de actualización"; + "refresh_cadence_subtitle" = "Con qué frecuencia QuotaKit consulta a los proveedores en segundo plano."; + "manual_refresh_hint" = "La actualización automática está desactivada; usa el comando Actualizar del menú."; + "check_provider_status_title" = "Comprobar estado del proveedor"; + "check_provider_status_subtitle" = "Consulta las páginas de estado de OpenAI/Claude y Google Workspace para Gemini/Antigravity, mostrando incidencias en el icono y el menú."; + "session_quota_notifications_title" = "Notificaciones de cuota de sesión"; + "session_quota_notifications_subtitle" = "Avisa cuando la cuota de sesión de 5 horas llega al 0 % y cuando vuelve a estar disponible."; + "quota_warning_notifications_title" = "Notificaciones de aviso de cuota"; + "quota_warning_notifications_subtitle" = "Avisa cuando la cuota restante de sesión o semanal cruza los umbrales configurados."; + "quota_warnings_title" = "Avisos de cuota"; + "quota_warning_session" = "sesión"; + "quota_warning_session_capitalized" = "Sesión"; + "quota_warning_weekly" = "semanal"; + "quota_warning_weekly_capitalized" = "Semanal"; + "quota_warning_warn_at" = "Avisar al"; + "quota_warning_global_threshold_subtitle" = "Porcentajes restantes para las ventanas de sesión y semanal, salvo que un proveedor los anule."; + "quota_warning_sound" = "Reproducir sonido de notificación"; + "quota_warning_provider_inherits" = "Usa los ajustes globales de aviso de cuota salvo que se personalice una ventana aquí."; + "quota_warning_customize_thresholds" = "Personalizar umbrales de %@"; + "quota_warning_enable_warnings" = "Activar avisos de %@"; + "quota_warning_window_warn_at" = "%@ avisar al"; + "quota_warning_off" = "Desactivado"; + "quota_warning_inherited" = "Heredado: %@"; + "quota_warning_depleted_only" = "solo agotado"; + "quota_warning_upper" = "Superior"; + "quota_warning_lower" = "Inferior"; + "apply" = "Aplicar"; + "quit_app" = "Salir de QuotaKit"; + /* Tab titles */ "tab_general" = "General"; + "tab_providers" = "Proveedores"; + "tab_display" = "Pantalla"; + "tab_advanced" = "Avanzado"; + "tab_about" = "Acerca de"; + "tab_debug" = "Depuración"; + /* Providers Pane */ "select_a_provider" = "Selecciona un proveedor"; + "cancel" = "Cancelar"; + "last_fetch_failed" = "la última obtención falló"; + "usage_not_fetched_yet" = "uso aún no obtenido"; + "managed_account_storage_unreadable" = "El almacenamiento de cuentas gestionadas no se puede leer. El acceso a cuentas en vivo sigue disponible, pero las acciones de añadir, reautenticar y eliminar cuentas gestionadas están desactivadas hasta que el almacén se pueda recuperar."; + "remove_codex_account_title" = "¿Eliminar la cuenta de Codex?"; + "remove" = "Eliminar"; + "managed_login_already_running" = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir o reautenticar otra cuenta."; + "managed_login_failed" = "El inicio de sesión gestionado de Codex no se completó. Comprueba que `codex --version` funciona en la Terminal. Si macOS bloqueó o movió `codex` a la Papelera, elimina instalaciones duplicadas obsoletas, ejecuta `npm install -g --include=optional @openai/codex@latest` y vuelve a intentarlo."; + "codex_login_output" = "Salida de codex login:"; + "managed_login_missing_email" = "El inicio de sesión de Codex se completó, pero no había ningún correo de cuenta disponible. Inténtalo de nuevo tras confirmar que la cuenta tiene la sesión totalmente iniciada."; + "workspace_selection_cancelled" = "QuotaKit encontró varios espacios de trabajo, pero no se seleccionó ninguno."; + "unsafe_managed_home" = "QuotaKit se negó a modificar una ruta de directorio gestionado inesperada: %@"; + "menu_bar_metric_title" = "Métrica de la barra de menús"; + "menu_bar_metric_subtitle" = "Elige qué ventana determina el porcentaje de la barra de menús."; + "menu_bar_metric_subtitle_deepseek" = "Muestra el saldo de DeepSeek en la barra de menús."; + "menu_bar_metric_subtitle_moonshot" = "Muestra el saldo de la API de Moonshot / Kimi en la barra de menús."; + "menu_bar_metric_subtitle_mistral" = "Muestra el gasto de la API de Mistral del mes actual en la barra de menús."; + "menu_bar_metric_subtitle_kimik2" = "Muestra los créditos de la clave de API de Kimi K2 en la barra de menús."; + "automatic" = "Automático"; + "primary_api_key_limit" = "Principal (límite de la clave de API)"; + /* Display Pane */ "section_menu_bar" = "Barra de menús"; + "merge_icons_title" = "Combinar iconos"; + "merge_icons_subtitle" = "Usar un único icono en la barra de menús con un selector de proveedor."; + "switcher_shows_icons_title" = "El selector muestra iconos"; + "switcher_shows_icons_subtitle" = "Mostrar los iconos de proveedor en el selector (de lo contrario, mostrar una línea de progreso semanal)."; + "show_most_used_provider_title" = "Mostrar el proveedor más usado"; + "show_most_used_provider_subtitle" = "La barra de menús muestra automáticamente el proveedor más cercano a su límite."; + "menu_bar_shows_percent_title" = "La barra de menús muestra el porcentaje"; + "menu_bar_shows_percent_subtitle" = "Sustituir las barras de bichitos por iconos de marca del proveedor y un porcentaje."; + "display_mode_title" = "Modo de visualización"; + "display_mode_subtitle" = "Elige qué mostrar en la barra de menús (Ritmo muestra el uso frente al previsto)."; + "section_menu_content" = "Contenido del menú"; + "show_usage_as_used_title" = "Mostrar el uso como consumido"; + "show_usage_as_used_subtitle" = "Las barras de progreso se llenan a medida que consumes la cuota (en lugar de mostrar lo restante)."; + "show_quota_warning_markers_title" = "Mostrar marcadores de aviso de cuota"; + "show_quota_warning_markers_subtitle" = "Dibuja marcas de umbral en las barras de uso cuando hay avisos de cuota configurados."; + "show_reset_time_as_clock_title" = "Mostrar la hora de reinicio como reloj"; + "show_reset_time_as_clock_subtitle" = "Mostrar las horas de reinicio como valores de reloj absolutos en lugar de cuentas atrás."; + "show_provider_changelog_links_title" = "Mostrar enlaces al registro de cambios del proveedor"; + "show_provider_changelog_links_subtitle" = "Añade al menú enlaces a las notas de versión de los proveedores compatibles basados en CLI."; + "show_credits_extra_usage_title" = "Mostrar créditos + uso adicional"; + "show_credits_extra_usage_subtitle" = "Mostrar las secciones de Créditos de Codex y Uso adicional de Claude en el menú."; + "show_all_token_accounts_title" = "Mostrar todas las cuentas con token"; + "show_all_token_accounts_subtitle" = "Apilar las cuentas con token en el menú (de lo contrario, mostrar una barra de cambio de cuenta)."; + "multi_account_layout_title" = "Diseño multicuenta"; + "multi_account_layout_subtitle" = "Elige cambio de cuenta segmentado o tarjetas de cuenta apiladas."; + "multi_account_layout_segmented" = "Segmentado"; + "multi_account_layout_stacked" = "Apilado"; + "overview_tab_providers_title" = "Proveedores de la pestaña Resumen"; + "configure" = "Configurar…"; + "overview_enable_merge_icons_hint" = "Activa Combinar iconos para configurar los proveedores de la pestaña Resumen."; + "overview_no_providers_hint" = "No hay proveedores activados disponibles para Resumen."; + "overview_rows_follow_order" = "Las filas de Resumen siempre siguen el orden de los proveedores."; + "overview_no_providers_selected" = "No hay proveedores seleccionados"; + /* Advanced Pane */ "section_keyboard_shortcut" = "Atajo de teclado"; + "open_menu_shortcut_title" = "Abrir menú"; + "open_menu_shortcut_subtitle" = "Abrir el menú de la barra de menús desde cualquier lugar."; + "install_cli" = "Instalar CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "No se encontraron directorios bin con permiso de escritura."; + "show_debug_settings_title" = "Mostrar ajustes de depuración"; + "show_debug_settings_subtitle" = "Muestra herramientas de diagnóstico en la pestaña Depuración."; + "surprise_me_title" = "Sorpréndeme"; + "surprise_me_subtitle" = "Actívalo si te gusta que tus agentes se diviertan ahí arriba."; + "weekly_limit_confetti_title" = "Confeti del límite semanal"; + "weekly_limit_confetti_subtitle" = "Mostrar confeti a pantalla completa cuando se reinicia el uso semanal."; + "hide_personal_info_title" = "Ocultar información personal"; + "hide_personal_info_subtitle" = "Oculta las direcciones de correo en la barra de menús y la interfaz del menú."; + "show_provider_storage_usage_title" = "Mostrar uso de almacenamiento del proveedor"; + "show_provider_storage_usage_subtitle" = "Muestra el uso de disco local en los menús. Analiza en segundo plano las rutas conocidas del proveedor."; + "section_keychain_access" = "Acceso al Llavero"; + "keychain_access_caption" = "Desactiva todas las lecturas y escrituras del Llavero. La importación de cookies del navegador no estará disponible; pega las cabeceras Cookie manualmente en Proveedores."; + "disable_keychain_access_title" = "Desactivar el acceso al Llavero"; + "disable_keychain_access_subtitle" = "Impide cualquier acceso al Llavero mientras esté activado."; + /* About Pane */ "about_tagline" = "Que tus tokens nunca se agoten: mantén los límites de tus agentes a la vista."; + "link_github" = "GitHub"; + "link_website" = "Sitio web"; + "link_twitter" = "Twitter"; + "link_email" = "Correo electrónico"; + "check_updates_auto" = "Buscar actualizaciones automáticamente"; + "update_channel" = "Canal de actualizaciones"; + "check_for_updates" = "Buscar actualizaciones…"; + "updates_unavailable" = "Actualizaciones no disponibles en esta compilación."; + "copyright" = "© 2026 Peter Steinberger. Licencia MIT."; + /* Debug Pane */ "section_logging" = "Registro"; + "enable_file_logging" = "Activar registro en archivo"; + "enable_file_logging_subtitle" = "Escribir registros en %@ para depuración."; + "verbosity_title" = "Nivel de detalle"; + "verbosity_subtitle" = "Controla cuánto detalle se registra."; + "open_log_file" = "Abrir archivo de registro"; + "force_animation_next_refresh" = "Forzar animación en la próxima actualización"; + "force_animation_next_refresh_subtitle" = "Muestra temporalmente la animación de carga tras la próxima actualización."; + "section_loading_animations" = "Animaciones de carga"; + "loading_animations_caption" = "Elige un patrón y reprodúcelo en la barra de menús. «Aleatorio» mantiene el comportamiento actual."; + "animation_random_default" = "Aleatorio (predeterminado)"; + "replay_selected_animation" = "Reproducir la animación seleccionada"; + "blink_now" = "Parpadear ahora"; + "section_probe_logs" = "Registros de sondeo"; + "probe_logs_caption" = "Obtén la salida de sondeo más reciente para depuración; Copiar conserva el texto completo."; + "fetch_log" = "Obtener registro"; + "copy" = "Copiar"; + "save_to_file" = "Guardar en archivo"; + "load_parse_dump" = "Cargar volcado de análisis"; + "rerun_provider_autodetect" = "Reejecutar la autodetección de proveedores"; + "loading" = "Cargando…"; + "no_log_yet_fetch" = "Aún no hay registro. Obtén para cargar."; + "section_fetch_strategy" = "Intentos de estrategia de obtención"; + "fetch_strategy_caption" = "Últimas decisiones y errores del flujo de obtención de un proveedor."; + "section_openai_cookies" = "Cookies de OpenAI"; + "openai_cookies_caption" = "Registros de importación de cookies y extracción con WebKit del último intento de cookies de OpenAI."; + "no_log_yet" = "Aún no hay registro. Actualiza las cookies de OpenAI en Proveedores → Codex para ejecutar una importación."; + "section_caches" = "Cachés"; + "caches_caption" = "Borra los resultados de análisis de coste en caché o las cachés de cookies del navegador."; + "clear_cookie_cache" = "Borrar caché de cookies"; + "clear_cost_cache" = "Borrar caché de coste"; + "section_notifications" = "Notificaciones"; + "notifications_caption" = "Lanza notificaciones de prueba para la ventana de sesión de 5 horas (agotada/restaurada)."; + "post_depleted" = "Enviar agotada"; + "post_restored" = "Enviar restaurada"; + "section_cli_sessions" = "Sesiones de la CLI"; + "cli_sessions_caption" = "Mantén activas las sesiones de la CLI de Codex/Claude tras un sondeo. Por defecto se cierran cuando se capturan los datos."; + "keep_cli_sessions_alive" = "Mantener activas las sesiones de la CLI"; + "keep_cli_sessions_alive_subtitle" = "Omitir el cierre entre sondeos (solo depuración)."; + "reset_cli_sessions" = "Reiniciar sesiones de la CLI"; + "section_error_simulation" = "Simulación de errores"; + "error_simulation_caption" = "Inyecta un mensaje de error falso en la tarjeta del menú para probar el diseño."; + "set_menu_error" = "Establecer error de menú"; + "clear_menu_error" = "Borrar error de menú"; + "set_cost_error" = "Establecer error de coste"; + "clear_cost_error" = "Borrar error de coste"; + "section_cli_paths" = "Rutas de la CLI"; + "cli_paths_caption" = "Binario de Codex resuelto y capas de PATH; captura del PATH de inicio de sesión al arrancar (tiempo de espera corto)."; + "codex_binary" = "Binario de Codex"; + "claude_binary" = "Binario de Claude"; + "effective_path" = "PATH efectivo"; + "unavailable" = "No disponible"; + "login_shell_path" = "PATH del shell de inicio (captura al arrancar)"; + "cleared" = "Borrado."; + "no_fetch_attempts" = "Aún no hay intentos de obtención."; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe puede bloquear apps de la barra de menús en Ajustes del Sistema → Barra de menús → Permitir en la barra de menús. QuotaKit está en ejecución, pero macOS podría estar ocultando su icono. Abre los ajustes de la barra de menús y activa QuotaKit."; + /* Metric preferences */ "metric_pref_automatic" = "Automático"; + "metric_pref_primary" = "Principal"; + "metric_pref_secondary" = "Secundario"; + "metric_pref_tertiary" = "Terciario"; + "metric_pref_extra_usage" = "Uso adicional"; + "metric_pref_average" = "Promedio"; + /* Display modes */ "display_mode_percent" = "Porcentaje"; + "display_mode_pace" = "Ritmo"; + "display_mode_both" = "Ambos"; + "display_mode_percent_desc" = "Mostrar el porcentaje restante/usado (p. ej. 45 %)"; + "display_mode_pace_desc" = "Mostrar el indicador de ritmo (p. ej. +5 %)"; + "display_mode_both_desc" = "Mostrar porcentaje y ritmo (p. ej. 45 % · +5 %)"; + /* Provider status */ "status_operational" = "Operativo"; + "status_partial_outage" = "Interrupción parcial"; + "status_major_outage" = "Interrupción grave"; + "status_critical_issue" = "Problema crítico"; + "status_maintenance" = "Mantenimiento"; + "status_unknown" = "Estado desconocido"; + /* Refresh frequency */ "refresh_manual" = "Manual"; + "refresh_1min" = "1 min"; + "refresh_2min" = "2 min"; + "refresh_5min" = "5 min"; + "refresh_15min" = "15 min"; + "refresh_30min" = "30 min"; + /* Additional keys */ "not_found" = "No encontrado"; + /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; + "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; + /* Popup panels */ "No usage configured." = "No hay uso configurado."; + "Quota" = "Cuota"; + "tokens" = "tokens"; + "requests" = "solicitudes"; + "Latest" = "Último"; + "Monthly" = "Mensual"; + "Sonnet" = "Sonnet"; + "Auth" = "Autenticación"; + "Overages" = "Excesos"; + "Activity" = "Actividad"; + "Copied" = "Copiado"; + "Copy error" = "Error al copiar"; + "Copy path" = "Copiar ruta"; + "Extra usage spent" = "Gasto de uso adicional"; + "Credits remaining" = "Créditos restantes"; + "Using CLI fallback" = "Usando alternativa de CLI"; + "Balance updates in near-real time (up to 5 min lag)" = "El saldo se actualiza casi en tiempo real (hasta 5 min de retraso)"; + "Daily billing data finalizes at 07:00 UTC" = "Los datos diarios de facturación se cierran a las 07:00 UTC"; + "%@ of %@ credits left" = "Quedan %@ de %@ créditos"; + "%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos de bonificación"; + "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; + "%@/%@ left" = "%@/%@ restante"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "Se regenera %@"; + "used after next regen" = "usado tras la próxima regeneración"; + "after next regen" = "tras la próxima regeneración"; + "Near full" = "Casi lleno"; + "Full in ~1 regen" = "Lleno en ~1 regeneración"; + "Full in ~%.0f regens" = "Lleno en ~%.0f regeneraciones"; + "Overage usage" = "Uso excedente"; + "Overage cost" = "Coste excedente"; + "credits" = "créditos"; + "Zen balance" = "Saldo Zen"; + "API spend" = "Gasto de API"; + "Extra usage" = "Uso adicional"; + "Quota usage" = "Uso de cuota"; + "%.0f%% used" = "%.0f%% usado"; + "Usage history (today)" = "Historial de uso (hoy)"; + "Usage history (%d days)" = "Historial de uso (%d días)"; + "%d percent remaining" = "%d%% restante"; + "Unknown" = "Desconocido"; + "stale data" = "datos obsoletos"; + "No credits history data available." = "No hay datos de historial de créditos disponibles."; + "Credits history chart" = "Gráfico de historial de créditos"; + "%d days of credits data" = "%d días de datos de créditos"; + "Usage breakdown chart" = "Gráfico de desglose de uso"; + "%d days of usage data across %d services" = "%d días de datos de uso en %d servicios"; + "Cost history chart" = "Gráfico de historial de costes"; + "%d days of cost data" = "%d días de datos de costes"; + "Plan utilization chart" = "Gráfico de uso del plan"; + "%d utilization samples" = "%d muestras de uso"; + "Hourly Usage" = "Uso por hora"; + "Usage remaining" = "Uso restante"; + "Usage used" = "Uso utilizado"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "Clave de API verificada. Ollama no expone los límites de cuota de Cloud mediante la API."; + "Last 30 days: %@ tokens" = "Últimos 30 días: %@ tokens"; + "7d spend" = "Gasto 7 d"; + "30d spend" = "Gasto 30 d"; + "Cache read" = "Lectura de caché"; + "Claude Admin API 30 day spend trend" = "Tendencia de gasto de 30 días de Claude Admin API"; + "OpenRouter API key spend trend" = "Tendencia de gasto de la clave API de OpenRouter"; + "z.ai hourly token trend" = "Tendencia horaria de tokens de z.ai"; + "MiniMax 30 day token usage trend" = "Tendencia de uso de tokens de 30 días de MiniMax"; + "Today cash" = "Efectivo de hoy"; + "DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de 30 días de DeepSeek"; + "cache-hit input" = "entrada con acierto de caché"; + "cache-miss input" = "entrada sin acierto de caché"; + "output" = "salida"; + "Requests" = "Solicitudes"; + "Reported by OpenAI Admin API organization usage." = "Informado por el uso de la organización en OpenAI Admin API."; + "Reported by Mistral billing usage." = "Informado por el uso de facturación de Mistral."; + "Today" = "Hoy"; + "Today tokens" = "Tokens de hoy"; + "30d cost" = "Coste 30 d"; + "30d tokens" = "Tokens 30 d"; + "Latest tokens" = "Tokens recientes"; + "Top model" = "Modelo principal"; + "Storage" = "Almacenamiento"; + "No data" = "Sin datos"; + "Last %d days" = "Últimos %d días"; + "%@ tokens" = "%@ tokens"; + "Latest billing day" = "Último día de facturación"; + "Latest billing day (%@)" = "Último día de facturación (%@)"; + "This week" = "Esta semana"; + "This month" = "Este mes"; + "Week" = "Semana"; + "Month" = "Mes"; + "Models" = "Modelos"; + "24h tokens" = "Tokens 24 h"; + "Latest hour" = "Última hora"; + "Peak hour" = "Hora pico"; + "Top method" = "Método principal"; + "30d cash" = "Efectivo 30 d"; + "30d billing history from MiniMax web session" = "Historial de facturación de 30 días de la sesión web de MiniMax"; + "AWS Cost Explorer billing can lag." = "La facturación de AWS Cost Explorer puede retrasarse."; + "Rate limit: %d / %@" = "Límite de tasa: %d / %@"; + "Key remaining" = "Clave restante"; + "No limit set for the API key" = "No hay límite configurado para la clave API"; + "API key limit unavailable right now" = "El límite de la clave API no está disponible ahora"; + "Today: %@ · %@ tokens" = "Hoy: %@ · %@ tokens"; + "Today: %@" = "Hoy: %@"; + "Today: %@ tokens" = "Hoy: %@ tokens"; + "This month: %@ tokens" = "Este mes: %@ tokens"; + "API key limit" = "Límite de clave API"; + "Limits not available" = "Límites no disponibles"; + "No usage yet" = "Aún no hay uso"; + "Not fetched yet" = "Aún no obtenido"; + "Code review" = "Revisión de código"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ espera permiso"; + "%@ requests" = "%@ solicitudes"; + "%@: %@ credits" = "%@: %@ créditos"; + "30d requests" = "Solicitudes de 30 d"; + "4 days" = "4 días"; + "5 days" = "5 días"; + "7 days" = "7 días"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clave API verifica el acceso a Ollama Cloud; las cookies aún muestran los límites de cuota."; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clave de acceso de AWS. También puede definirse con AWS_ACCESS_KEY_ID."; + "AWS region. Can also be set with AWS_REGION." = "Región de AWS. También puede definirse con AWS_REGION."; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clave secreta de AWS. También puede definirse con AWS_SECRET_ACCESS_KEY."; + "Access key ID" = "ID de clave de acceso"; + "Add Account" = "Añadir cuenta"; + "Adding Account…" = "Añadiendo cuenta…"; + "Antigravity login failed" = "Error al iniciar sesión en Antigravity"; + "Antigravity login timed out" = "El inicio de sesión en Antigravity agotó el tiempo"; + "Auth source" = "Fuente de autenticación"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies de Chrome desde Xiaomi MiMo."; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automáticamente datos de sesión de Windsurf desde localStorage de Chromium."; + "Automatic imports browser cookies from Bailian." = "Importa automáticamente cookies del navegador desde Bailian."; + "Automatically imports browser cookies." = "Importa automáticamente cookies del navegador."; + "Automatically imports browser session cookies." = "Importa automáticamente cookies de sesión del navegador."; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nombre de despliegue de Azure OpenAI. También se admite AZURE_OPENAI_DEPLOYMENT_NAME."; + "Azure OpenAI key" = "Clave de Azure OpenAI"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint del recurso de Azure OpenAI. También se admite AZURE_OPENAI_ENDPOINT."; + "Base URL" = "URL base"; + "Base URL for the LLM-API-Key-Proxy instance." = "URL base de la instancia de LLM-API-Key-Proxy."; + "Browser cookies" = "Cookies del navegador"; + "Cap end" = "Fin del límite"; + "Cap start" = "Inicio del límite"; + "Capacity End" = "Fin de capacidad"; + "Capacity Start" = "Inicio de capacidad"; + "Changelog" = "Registro de cambios"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Elige el host de la API Moonshot/Kimi para cuentas internacionales o de China continental."; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit no puede reemplazar una cuenta del sistema iniciada solo con una clave API."; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit no encontró autenticación guardada para esa cuenta. Vuelve a autenticarla e inténtalo de nuevo."; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit no pudo leer el almacenamiento de cuentas gestionadas. Recupera el almacén antes de añadir otra cuenta."; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit no pudo leer la autenticación guardada para esa cuenta. Vuelve a autenticarla e inténtalo de nuevo."; + "CodexBar could not read the current system account on this Mac." = "QuotaKit no pudo leer la cuenta del sistema actual en este Mac."; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit no pudo reemplazar la autenticación activa de Codex en este Mac."; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit no pudo preservar con seguridad la cuenta del sistema actual antes de cambiar."; + "CodexBar could not save the current system account before switching." = "QuotaKit no pudo guardar la cuenta del sistema actual antes de cambiar."; + "CodexBar could not update managed account storage." = "QuotaKit no pudo actualizar el almacenamiento de cuentas gestionadas."; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit encontró otra cuenta gestionada que ya usa la cuenta del sistema actual. Resuelve la cuenta duplicada antes de cambiar."; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS “%@” para descifrar cookies del navegador y autenticar tu cuenta. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS el token OAuth de Claude Code para obtener tu uso de Claude. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de Amp para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de Augment para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de Claude para obtener el uso web de Claude. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de Cursor para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de Factory para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu token de GitHub Copilot para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu clave API de Kimi K2 para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu token de autenticación de Kimi para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu token API de MiniMax para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de MiniMax para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de OpenAI para obtener extras del panel de Codex. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu cabecera Cookie de OpenCode para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu clave API de Synthetic para obtener el uso. Haz clic en OK para continuar."; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit pedirá a Llaveros de macOS tu token API de z.ai para obtener el uso. Haz clic en OK para continuar."; + "Could not open Cursor login in your browser." = "No se pudo abrir el inicio de sesión de Cursor en el navegador."; + "Could not open browser for Antigravity" = "No se pudo abrir el navegador para Antigravity"; + "Credits used" = "Créditos usados"; + "Day" = "Día"; + "Deployment" = "Despliegue"; + "Drag to reorder" = "Arrastra para reordenar"; + "Endpoint" = "Endpoint"; + "Enterprise host" = "Host Enterprise"; + "Extra usage balance: %@" = "Saldo de uso extra: %@"; + "Keychain Access Required" = "Se requiere acceso a Llaveros"; + "Kiro menu bar value" = "Valor de Kiro en la barra de menús"; + "Label" = "Etiqueta"; + "No organizations loaded. Click Refresh after setting your API key." = "No hay organizaciones cargadas. Haz clic en Actualizar después de configurar tu clave API."; + "No output captured." = "No se capturó salida."; + "No system account" = "Sin cuenta del sistema"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "Abrir Augment (cerrar sesión y volver a entrar)"; + "Open Codebuff Dashboard" = "Abrir panel de Codebuff"; + "Open Command Code Settings" = "Abrir ajustes de Command Code"; + "Open Crof dashboard" = "Abrir panel de Crof"; + "Open Manus" = "Abrir Manus"; + "Open MiMo Balance" = "Abrir saldo de MiMo"; + "Open Moonshot Console" = "Abrir consola de Moonshot"; + "Open Ollama API Keys" = "Abrir claves API de Ollama"; + "Open StepFun Platform" = "Abrir plataforma StepFun"; + "Open T3 Chat Settings" = "Abrir ajustes de T3 Chat"; + "Open Volcengine Ark Console" = "Abrir consola Volcengine Ark"; + "Open legacy provider docs" = "Abrir documentación del proveedor heredado"; + "Open projects" = "Abrir proyectos"; + "Open this URL manually to continue login:\n\n%@" = "Abre esta URL manualmente para continuar el inicio de sesión:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organización opcional para cuentas vinculadas a varias organizaciones de Anthropic."; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Se aplica a la clave Admin API configurada; las cuentas de token seleccionadas no heredan OPENAI_PROJECT_ID."; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introduce tu host de GitHub Enterprise, por ejemplo octocorp.ghe.com. Déjalo vacío para github.com."; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Déjalo vacío para descubrir y agregar proyectos visibles para la clave API."; + "Org ID (optional)" = "ID de org. (opcional)"; + "Organizations" = "Organizaciones"; + "Password" = "Contraseña"; + "%@ authentication is disabled." = "La autenticación de %@ está desactivada."; + "%@ cookies are disabled." = "Las cookies de %@ están desactivadas."; + "%@ web API access is disabled." = "El acceso a la API web de %@ está desactivado."; + "Disable %@ dashboard cookie usage." = "Desactiva el uso de cookies del panel de %@."; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "El acceso al llavero está desactivado en Avanzado, así que la importación de cookies del navegador no está disponible."; + "Manually paste an %@ from a browser session." = "Pega manualmente un %@ de una sesión del navegador."; + "Paste a Cookie header captured from %@." = "Pega una cabecera Cookie capturada desde %@."; + "Paste a Cookie header from %@." = "Pega una cabecera Cookie de %@."; + "Paste a Cookie header or cURL capture from %@." = "Pega una cabecera Cookie o una captura cURL de %@."; + "Paste a Cookie header or full cURL capture from %@." = "Pega una cabecera Cookie o una captura cURL completa de %@."; + "Paste a Cookie or Authorization header from %@." = "Pega una cabecera Cookie o Authorization de %@."; + "Paste a full cookie header or the %@ value." = "Pega una cabecera de cookies completa o el valor %@."; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Pega una cabecera Cookie o una captura cURL completa desde los ajustes de T3 Chat."; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Pega la cabecera Cookie de una solicitud a admin.mistral.ai. Debe contener una cookie ory_session_*."; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Pega el Oasis-Token de una sesión iniciada en platform.stepfun.com."; + "Paste the %@ JSON bundle from %@." = "Pega el paquete JSON %@ de %@."; + "Paste the %@ value or a full Cookie header." = "Pega el valor %@ o una cabecera Cookie completa."; + "Personal account" = "Cuenta personal"; + "Project ID" = "ID de proyecto"; + "Re-auth" = "Reautenticar"; + "Re-authenticating…" = "Reautenticando…"; + "Refresh Session" = "Actualizar sesión"; + "Refresh organizations" = "Actualizar organizaciones"; + "Region" = "Región"; + "Reload" = "Recargar"; + "Reorder" = "Reordenar"; + "Secret access key" = "Clave de acceso secreta"; + "Series" = "Serie"; + "Service" = "Servicio"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Muestra u oculta créditos de Kiro, porcentaje o ambos junto al icono de la barra de menús."; + "Show usage for organizations you belong to. Personal account is always shown." = "Muestra el uso de las organizaciones a las que perteneces. La cuenta personal siempre se muestra."; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sesión en cursor.com en el navegador y luego actualiza Cursor en QuotaKit."; + "Simulated error text" = "Texto de error simulado"; + "StepFun platform account (phone number or email)." = "Cuenta de la plataforma StepFun (teléfono o correo)."; + "Stored in ~/.codexbar/config.json." = "Guardado en ~/.quotakit/config.json."; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Guardado en ~/.quotakit/config.json. También se admite AZURE_OPENAI_API_KEY."; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Guardado en ~/.quotakit/config.json. Para la API oficial de Kimi, usa Moonshot / Kimi API."; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Guardado en ~/.quotakit/config.json. Obtén tu clave API en la consola Volcengine Ark."; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Guardado en ~/.quotakit/config.json. Obtén tu clave en los ajustes de Ollama."; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Guardado en ~/.quotakit/config.json. Obtén tu clave en console.deepgram.com."; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Guardado en ~/.quotakit/config.json. Obtén tu clave en elevenlabs.io/app/settings/api-keys."; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Guardado en ~/.quotakit/config.json. Obtén tu clave en openrouter.ai/settings/keys y define allí un límite de gasto para activar el seguimiento de cuota."; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Guardado en ~/.quotakit/config.json. En Warp, abre Settings > Platform > API Keys y crea una."; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Guardado en ~/.quotakit/config.json. Las métricas requieren acceso a Groq Enterprise Prometheus."; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Guardado en ~/.quotakit/config.json. Se prefiere OPENAI_ADMIN_KEY; OPENAI_API_KEY también funciona."; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Guardado en ~/.quotakit/config.json. Requiere una clave Anthropic Admin API."; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Guardado en ~/.quotakit/config.json. Se usa para /v1/quota-stats."; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Guardado en ~/.quotakit/config.json. También puedes proporcionar CODEBUFF_API_KEY o dejar que QuotaKit lea ~/.config/manicode/credentials.json (creado por `codebuff login`)."; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Guardado en ~/.quotakit/config.json. También puedes proporcionar CROF_API_KEY."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Guardado en ~/.quotakit/config.json. También puedes proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; + "T3 Chat cookie" = "Cookie de T3 Chat"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "Esa cuenta ya no está disponible en QuotaKit. Actualiza la lista de cuentas e inténtalo de nuevo."; + "The browser login did not complete in time. Try Antigravity login again." = "El inicio de sesión del navegador no terminó a tiempo. Intenta iniciar sesión en Antigravity de nuevo."; + "Timed out waiting for Cursor login. %@" = "Se agotó el tiempo esperando el inicio de sesión de Cursor. %@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "Se agotó el tiempo esperando el inicio de sesión de Cursor. %@ Último error: %@"; + "Today requests" = "Solicitudes de hoy"; + "Total (30d): %@ credits" = "Total (30 d): %@ créditos"; + "Username" = "Usuario"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "Usa usuario y contraseña para iniciar sesión y obtener un Oasis-Token automáticamente."; + "Uses username + password to login and obtain an %@ automatically." = "Usa usuario y contraseña para iniciar sesión y obtener un %@ automáticamente."; + "Utilization End" = "Fin de utilización"; + "Utilization Start" = "Inicio de utilización"; + "Verbosity" = "Detalle"; + "Windsurf session JSON bundle" = "Paquete JSON de sesión de Windsurf"; + "Workspace ID" = "ID de espacio de trabajo"; + "Your StepFun platform password. Used to login and obtain a session token." = "Tu contraseña de la plataforma StepFun. Se usa para iniciar sesión y obtener un token de sesión."; + "claude /login exited with status %d." = "claude /login salió con estado %d."; + "codex login exited with status %d." = "codex login salió con estado %d."; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no pega una captura cURL del panel de Abacus AI"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no pega el valor de __Secure-next-auth.session-token"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no pega el valor del token kimi-auth"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no pega solo el valor de session_id"; + "Clear" = "Borrar"; + "No matching providers" = "No hay proveedores coincidentes"; + "Search providers" = "Buscar proveedores"; + + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings new file mode 100644 index 000000000..539fcf8da --- /dev/null +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,2097 @@ +/* English localization for CodexBar (base/fallback) */ + +" providers" = " fournisseurs"; + +"(System)" = "(System)"; + +"30d" = "30d"; + +"A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; + +"API key" = "Clé API"; + +"API region" = "Région API"; + +"API token" = "Jeton API"; + +"API tokens" = "Jetons API"; + +"About" = "À propos"; + +"Account" = "Compte"; + +"Accounts" = "Comptes"; + +"Accounts subtitle" = "Sous-titre des comptes"; + +"Active" = "Actif"; + +"Add" = "Ajouter"; + +"Add Workspace" = "Ajouter un espace de travail"; + +"Advanced" = "Avancé"; + +"All" = "Tout"; + +"Always allow prompts" = "Toujours autoriser les invites"; + +"Animation pattern" = "Modèle d'animation"; + +"Antigravity login is managed in the app" = "La connexion antigravité est gérée dans l’app"; + +"Applies only to the Security.framework OAuth keychain reader." = "S’applique uniquement au lecteur de Trousseau OAuth Security.framework."; + +"Auto falls back to the next source if the preferred one fails." = "Revient automatiquement à la source suivante si la source préférée échoue."; + +"Auto uses API first, then falls back to CLI on auth failures." = "Auto utilise d'abord l'API, puis revient à la CLI en cas d'échec d'authentification."; + +"Auto-detect" = "Auto-detect"; + +"Auto-refresh is off; use the menu's Refresh command." = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; + +"Auto-refresh: hourly · Timeout: 10m" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; + +"Automatic" = "Automatique"; + +"Automatic imports browser cookies and WorkOS tokens." = "Importe automatiquement les cookies du navigateur et les jetons WorkOS."; + +"Automatic imports browser cookies and local storage tokens." = "Importe automatiquement les cookies du navigateur et les jetons de stockage local."; + +"Automatic imports browser cookies for dashboard extras." = "Importe automatiquement les cookies du navigateur pour les extras du tableau de bord."; + +"Automatic imports browser cookies for the web API." = "Importe automatiquement les cookies du navigateur pour l'API Web."; + +"Automatic imports browser cookies from Model Studio/Bailian." = "Importe automatiquement les cookies du navigateur depuis Model Studio/Bailian."; + +"Automatic imports browser cookies from admin.mistral.ai." = "Importe automatiquement les cookies du navigateur depuis admin.mistral.ai."; + +"Automatic imports browser cookies from opencode.ai." = "Importe automatiquement les cookies du navigateur depuis opencode.ai."; + +"Automatic imports browser cookies or stored sessions." = "Importe automatiquement les cookies du navigateur ou les sessions stockées."; + +"Automatic imports browser cookies." = "Importe automatiquement les cookies du navigateur."; + +"Automatically imports browser session cookie." = "Importe automatiquement le cookie de session du navigateur."; + +"Automatically opens CodexBar when you start your Mac." = "Ouvre automatiquement QuotaKit lorsque vous démarrez votre Mac."; + +"Automation" = "Automatisation"; + +"Average (\\(label1) + \\(label2))" = "Moyenne (\\(label1) + \\(label2))"; + +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Moyenne (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + +"Avoid Keychain prompts" = "Éviter les invites du Trousseau"; + +"Balance" = "Balance"; + +"Battery Saver" = "Économiseur de batterie"; + +"Bordered" = "Bordé"; + +"Build" = "Version"; + +"Built \\(buildTimestamp)" = "Construit \\(buildTimestamp)"; + +"Buy Credits..." = "Acheter des crédits..."; + +"Buy Credits…" = "Acheter des crédits…"; + +"CLI paths" = "Chemins CLI"; + +"CLI sessions" = "Sessions CLI"; + +"Caches" = "Caches"; + +"Cancel" = "Annuler"; + +"Check for Updates…" = "Rechercher les mises à jour…"; + +"Check for updates automatically" = "Rechercher automatiquement les mises à jour"; + +"Check if you like your agents having some fun up there." = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; + +"Check provider status" = "Vérifier le statut du fournisseur"; + +"Choose Codex workspace" = "Choisissez l'espace de travail Codex"; + +"Choose the MiniMax host (global .io or China mainland .com)." = "Choisissez l'hôte MiniMax (global .io ou Chine continentale .com)."; + +"Choose up to " = "Choisissez jusqu'à "; + +"Choose up to \\(Self.maxOverviewProviders) providers" = "Choisissez jusqu'à \\(Self.maxOverviewProviders) fournisseurs"; + +"Choose up to \\(count) providers" = "Choisissez jusqu'à \\(count) fournisseurs"; + +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; + +"Choose which Codex account CodexBar should follow." = "Choisissez quel compte Codex QuotaKit doit suivre."; + +"Choose which window drives the menu bar percent." = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; + +"Chrome" = "Chrome"; + +"Claude CLI not found" = "Claude CLI introuvable"; + +"Claude binary" = "Binaire Claude"; + +"Claude cookies" = "cookies Claude"; + +"Claude login failed" = "La connexion de Claude a échoué"; + +"Claude login timed out" = "La connexion de Claude a expiré"; + +"Close" = "Fermer"; + +"Code review" = "Revue de code"; + +"Codex CLI not found" = "Codex CLI introuvable"; + +"Codex account login already running" = "La connexion au compte Codex est déjà en cours"; + +"Codex binary" = "Binaire Codex"; + +"Codex login failed" = "Échec de la connexion au Codex"; + +"Codex login timed out" = "La connexion au Codex a expiré"; + +"CodexBar Lifecycle Keepalive" = "Cycle de vie de QuotaKit Keepalive"; + +"CodexBar can't show its menu bar icon" = "QuotaKit ne peut pas afficher l'icône de sa barre de menus"; + +"CodexBar could not read managed account storage. " = "QuotaKit n'a pas pu lire le stockage du compte géré."; + +"Configure…" = "Configurer…"; + +"Connected" = "Connecté"; + +"Controls how much detail is logged." = "Contrôle la quantité de détails enregistrés."; + +"Cookie header" = "En-tête du cookie"; + +"Cookie source" = "Source des cookies"; + +"Cookie: ..." = "Cookie : …"; + +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : \\u{2026}\\\n\\\nou collez une capture cURL à partir du tableau de bord Abacus AI"; + +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur __Secure-next-auth.session-token"; + +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur du jeton kimi-auth"; + +"Cookie: …" = "Cookie : …"; + +"CopilotDeviceFlow" = "CopilotDeviceFlow"; + +"Cost" = "Coût"; + +"Could not add Codex account" = "Impossible d'ajouter un compte Codex"; + +"Could not open Terminal for Gemini" = "Impossible d'ouvrir le terminal pour Gemini"; + +"Could not start claude /login" = "Impossible de démarrer Claude /connexion"; + +"Could not start codex login" = "Impossible de démarrer la connexion à Codex"; + +"Could not switch system account" = "Impossible de changer de compte système"; + +"Credits" = "Crédits"; + +"Credits history" = "Historique des crédits"; + +"Cursor login failed" = "La connexion au curseur a échoué"; + +"Custom" = "Personnalisé"; + +"Custom Path" = "Chemin personnalisé"; + +"Daily Routines" = "Routines quotidiennes"; + +"Debug" = "Débogage"; + +"Default" = "Par défaut"; + +"Disable Keychain access" = "Désactiver l'accès au Trousseau"; + +"Disabled" = "Désactivé"; + +"Dismiss" = "Ignorer"; + +"Disconnected" = "Déconnecté"; + +"Display" = "Affichage"; + +"Display mode" = "Mode d'affichage"; + +"Display reset times as absolute clock values instead of countdowns." = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; + +"Done" = "Terminé"; + +"Effective PATH" = "CHEMIN efficace"; + +"Email" = "E-mail"; + +"Enable Merge Icons to configure Overview tab providers." = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; + +"Enable file logging" = "Activer la journalisation des fichiers"; + +"Enabled" = "Activé"; + +"Error" = "Erreur"; + +"Error simulation" = "Simulation d'erreur"; + +"Expose troubleshooting tools in the Debug tab." = "Exposez les outils de dépannage dans l’onglet Débogage."; + +"Failed" = "Échec"; + +"False" = "False"; + +"Fetch strategy attempts" = "Récupérer les tentatives de stratégie"; + +"Fetching" = "Récupération"; + +"Field" = "Champ"; + +"Field subtitle" = "Sous-titre du champ"; + +"Finish the current managed account change before switching the system account." = "Terminez la modification du compte géré actuel avant de changer de compte système."; + +"Force animation on next refresh" = "Forcer l'animation au prochain rafraîchissement"; + +"Gateway region" = "Région passerelle"; + +"Gemini CLI not found" = "Gemini CLI introuvable"; + +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, signale les incidents dans l'icône et le menu."; + +"General" = "Général"; + +"GitHub" = "GitHub"; + +"GitHub Copilot Login" = "Connexion à GitHub Copilot"; + +"GitHub Login" = "Connexion à GitHub"; + +"Hide details" = "Masquer les détails"; + +"Hide personal information" = "Masquer les informations personnelles"; + +"Historical tracking" = "Suivi historique"; + +"How often CodexBar polls providers in the background." = "À quelle fréquence QuotaKit interroge les fournisseurs en arrière-plan."; + +"Inactive" = "Inactif"; + +"Install CLI" = "Installer la CLI"; + +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installez la CLI Claude (npm i -g @anthropic-ai/claude-code) et réessayez."; + +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installez la CLI Codex (npm i -g @openai/codex) et réessayez."; + +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installez la CLI Gemini (npm i -g @google/gemini-cli) et réessayez."; + +"JetBrains AI is ready" = "L'IA JetBrains est prête"; + +"JetBrains IDE" = "EDI JetBrains"; + +"Keep CLI sessions alive" = "Maintenir les sessions CLI en vie"; + +"Keyboard shortcut" = "Raccourci clavier"; + +"Keychain access" = "Accès au Trousseau"; + +"Keychain prompt policy" = "Politique d'invite du Trousseau"; + +"Last \\(name) fetch failed:" = "La dernière récupération de \\(name) a échoué :"; + +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "La dernière récupération de \\(self.store.metadata(for: self.provider).displayName) a échoué :"; + +"Last attempt" = "Dernière tentative"; + +"Link" = "Lien"; + +"Loading animations" = "Chargement des animations"; + +"Loading…" = "Chargement…"; + +"Local" = "Local"; + +"Logging" = "Journalisation"; + +"Login failed" = "La connexion a échoué"; + +"Login shell PATH (startup capture)" = "CHEMIN du shell de connexion (capture de démarrage)"; + +"Login timed out" = "La connexion a expiré"; + +"MCP details" = "Détails du MCP"; + +"Managed Codex accounts unavailable" = "Comptes Codex gérés indisponibles"; + +"Managed account storage is unreadable. Live account access is still available, " = "Le stockage du compte géré est illisible. L'accès au compte en direct est toujours disponible,"; + +"Manual" = "Manuel"; + +"May your tokens never run out—keep agent limits in view." = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; + +"Menu bar" = "Barre de menus"; + +"Menu bar auto-shows the provider closest to its rate limit." = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; + +"Menu bar metric" = "Métrique de la barre de menus"; + +"Menu bar shows percent" = "La barre de menu affiche le pourcentage"; + +"Menu content" = "Contenu des menus"; + +"Merge Icons" = "Fusionner les icônes"; + +"Never prompt" = "Ne jamais demander"; + +"No" = "Non"; + +"No Codex accounts detected yet." = "Aucun compte Codex détecté pour l'instant."; + +"No JetBrains IDE detected" = "Aucun IDE JetBrains détecté"; + +"No cost history data." = "Aucune donnée historique des coûts."; + +"No data available" = "Aucune donnée disponible"; + +"No data yet" = "Aucune donnée pour l'instant"; + +"No enabled providers available for Overview." = "Aucun fournisseur activé disponible pour la présentation."; + +"No providers selected" = "Aucun fournisseur sélectionné"; + +"No token accounts yet." = "Aucun compte symbolique pour l'instant."; + +"No usage breakdown data." = "Aucune donnée de répartition d'utilisation."; + +"None" = "Aucun"; + +"Notifications" = "Notifications"; + +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avertit lorsque le quota de session de 5 heures atteint 0 % et lorsqu'il devient"; + +"OK" = "OK"; + +"Obscure email addresses in the menu bar and menu UI." = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; + +"Off" = "Désactivé"; + +"Offline" = "Hors ligne"; + +"On" = "Activé"; + +"Online" = "En ligne"; + +"Only on user action" = "Uniquement sur l'action de l'utilisateur"; + +"Open" = "Ouvrir"; + +"Open API Keys" = "Clés API ouvertes"; + +"Open Amp Settings" = "Ouvrir les paramètres de l'ampli"; + +"Open Antigravity to sign in, then refresh CodexBar." = "Ouvrez Antigravity pour vous connecter, puis actualisez QuotaKit."; + +"Open Browser" = "Ouvrir le navigateur"; + +"Open Coding Plan" = "Plan de codage ouvert"; + +"Open Console" = "Ouvrir la console"; + +"Open Dashboard" = "Ouvrir le tableau de bord"; + +"Open Mistral Admin" = "Ouvrir l'administrateur Mistral"; + +"Open Menu Bar Settings" = "Ouvrir les paramètres de la barre de menu"; + +"Open Ollama Settings" = "Ouvrir les paramètres Ollama"; + +"Open Terminal" = "Terminal ouvert"; + +"Open Usage Page" = "Ouvrir la page d'utilisation"; + +"Open Warp API Key Guide" = "Guide des clés de l'API Open Warp"; + +"Open menu" = "Ouvrir le menu"; + +"Open token file" = "Ouvrir le fichier de jeton"; + +"OpenAI cookies" = "Cookies OpenAI"; + +"OpenAI web extras" = "Extras Web OpenAI"; + +"Option A" = "Option A"; + +"Option B" = "Option B"; + +"Optional override if workspace lookup fails." = "Remplacement facultatif si la recherche d’espace de travail échoue."; + +"Options" = "Options"; + +"Override auto-detection with a custom IDE base path" = "Remplacer la détection automatique par un chemin de base IDE personnalisé"; + +"Overview" = "Aperçu"; + +"Overview rows always follow provider order." = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; + +"Overview tab providers" = "Fournisseurs d'onglets de présentation"; + +"Paste API key…" = "Coller la clé API…"; + +"Paste API token…" = "Coller le jeton API…"; + +"Paste key…" = "Coller la clé…"; + +"Paste sessionKey or OAuth token…" = "Collez sessionKey ou le jeton OAuth…"; + +"Paste the Cookie header from a request to admin.mistral.ai. " = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai."; + +"Paste token…" = "Coller le jeton…"; + +"Personal" = "Personnel"; + +"Picker" = "Sélecteur"; + +"Picker subtitle" = "Sous-titre du sélecteur"; + +"Placeholder" = "Espace réservé"; + +"Plan" = "Forfait"; + +"Play full-screen confetti when weekly usage resets." = "Jouez des confettis en plein écran lorsque l'utilisation hebdomadaire est réinitialisée."; + +"Polls OpenAI/Claude status pages and Google Workspace for " = "Sonde les pages d'état OpenAI/Claude et Google Workspace pour"; + +"Prevents any Keychain access while enabled." = "Empêche tout accès au Trousseau lorsqu'il est activé."; + +"Primary (API key limit)" = "Primaire (limite de clé API)"; + +"Primary (\\(label))" = "Primaire (\\(label))"; + +"Primary (\\(metadata.sessionLabel))" = "Primaire (\\(metadata.sessionLabel))"; + +"Probe logs" = "Journaux de sonde"; + +"Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progression se remplissent à mesure que vous consommez le quota (au lieu d'afficher le reste)."; + +"Provider" = "Fournisseur"; + +"Providers" = "Fournisseurs"; + +"Quit CodexBar" = "Quitter QuotaKit"; + +"Random (default)" = "Aléatoire (par défaut)"; + +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lit les journaux d'utilisation locaux. Affiche aujourd'hui + la fenêtre d'historique sélectionnée dans le menu."; + +"Refresh" = "Actualiser"; + +"Refresh cadence" = "Cadence de rafraîchissement"; + +"Remote" = "Distant"; + +"Remove" = "Supprimer"; + +"Remove Codex account?" = "Supprimer le compte Codex ?"; + +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(account.email) de QuotaKit ? Sa maison Codex gérée sera supprimée."; + +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(email) de QuotaKit ? Sa maison Codex gérée sera supprimée."; + +"Remove selected account" = "Supprimer le compte sélectionné"; + +"Replace critter bars with provider branding icons and a percentage." = "Remplacez les barres de créatures par des icônes de marque du fournisseur et un pourcentage."; + +"Replay selected animation" = "Rejouer l'animation sélectionnée"; + +"Requires authentication via GitHub Device Flow." = "Nécessite une authentification via GitHub Device Flow."; + +"Resets: \\(reset)" = "Réinitialisation : \\(reset)"; + +"Rolling five-hour limit" = "Limite mobile de cinq heures"; + +"Search hourly" = "Recherche horaire"; + +"Secondary (\\(label))" = "Secondaire (\\(label))"; + +"Secondary (\\(metadata.weeklyLabel))" = "Secondaire (\\(metadata.weeklyLabel))"; + +"Select a provider" = "Sélectionnez un fournisseur"; + +"Select the IDE to monitor" = "Sélectionnez l'IDE à surveiller"; + +"Session quota notifications" = "Notifications de quota de session"; + +"Session tokens" = "Jetons de session"; + +"Settings" = "Réglages"; + +"Show Codex Credits and Claude Extra usage sections in the menu." = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; + +"Show Debug Settings" = "Afficher les paramètres de débogage"; + +"Show all token accounts" = "Afficher tous les comptes de jetons"; + +"Show cost summary" = "Afficher le récapitulatif des coûts"; + +"Show credits + extra usage" = "Afficher les crédits + utilisation supplémentaire"; + +"Show details" = "Afficher les détails"; + +"Show most-used provider" = "Afficher le fournisseur le plus utilisé"; + +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Afficher les icônes des fournisseurs dans le sélecteur (sinon, afficher une ligne de progression hebdomadaire)."; + +"Show reset time as clock" = "Afficher l'heure de réinitialisation sous forme d'horloge"; + +"Show usage as used" = "Afficher l'utilisation telle qu'utilisée"; + +"Sign in via button below" = "Connectez-vous via le bouton ci-dessous"; + +"Skip teardown between probes (debug-only)." = "Ignorer le démontage entre les sondes (débogage uniquement)."; + +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Empilez les comptes de jetons dans le menu (sinon, affichez une barre de changement de compte)."; + +"Start at Login" = "Commencez par la connexion"; + +"Status" = "Statut"; + +"Store Claude sessionKey cookies or OAuth access tokens." = "Stockez les cookies sessionKey de Claude ou les jetons d'accès OAuth."; + +"Store multiple Abacus AI Cookie headers." = "Stockez plusieurs en-têtes Abacus AI Cookie."; + +"Store multiple Augment Cookie headers." = "Stockez plusieurs en-têtes de cookies d’augmentation."; + +"Store multiple Cursor Cookie headers." = "Stockez plusieurs en-têtes de cookies de curseur."; + +"Store multiple Factory Cookie headers." = "Stockez plusieurs en-têtes Factory Cookie."; + +"Store multiple MiniMax Cookie headers." = "Stockez plusieurs en-têtes MiniMax Cookie."; + +"Store multiple Mistral Cookie headers." = "Stockez plusieurs en-têtes Mistral Cookie."; + +"Store multiple Ollama Cookie headers." = "Stockez plusieurs en-têtes Ollama Cookie."; + +"Store multiple OpenCode Cookie headers." = "Stockez plusieurs en-têtes de cookies OpenCode."; + +"Store multiple OpenCode Go Cookie headers." = "Stockez plusieurs en-têtes OpenCode Go Cookie."; + +"Stored in the CodexBar config file." = "Stocké dans le fichier de configuration QuotaKit."; + +"Stored in ~/.codexbar/config.json. " = "Stocké dans ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stocké dans ~/.quotakit/config.json. Générez-en un sur kimi-k2.ai."; + +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stocké dans ~/.quotakit/config.json. Collez la clé du tableau de bord synthétique."; + +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stocké dans ~/.quotakit/config.json. Collez la clé API de votre plan de codage depuis Model Studio."; + +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stocké dans ~/.quotakit/config.json. Collez votre clé API MiniMax."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stocké dans ~/.quotakit/config.json. Vous pouvez également fournir KILO_API_KEY ou"; + +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stocke l’historique d’utilisation local du Codex (8 semaines) pour personnaliser les prédictions Pace."; + +"Subscription Utilization" = "Utilisation de l'abonnement"; + +"Surprise me" = "Surprenez-moi"; + +"Switcher shows icons" = "Le commutateur affiche des icônes"; + +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Lien symbolique QuotaKitCLI vers /usr/local/bin et /opt/homebrew/bin en tant que quotakit."; + +"System" = "Système"; + +"Temporarily shows the loading animation after the next refresh." = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; + +"Tertiary (\\(label))" = "Tertiaire (\\(label))"; + +"Tertiary (\\(tertiaryTitle))" = "Tertiaire (\\(tertiaryTitle))"; + +"The default Codex account on this Mac." = "Le compte Codex par défaut sur ce Mac."; + +"Toggle" = "Basculer"; + +"Toggle subtitle" = "Basculer le sous-titre"; + +"Token" = "Jeton"; + +"Trigger the menu bar menu from anywhere." = "Déclenchez le menu de la barre de menus depuis n'importe où."; + +"True" = "Vrai"; + +"Twitter" = "Twitter"; + +"Unsupported" = "Non pris en charge"; + +"Update Channel" = "Mettre à jour la chaîne"; + +"Updated" = "Mis à jour"; + +"Updates unavailable in this build." = "Mises à jour non disponibles dans cette version."; + +"Usage" = "Utilisation"; + +"Usage breakdown" = "Répartition de l'utilisation"; + +"Usage history (30 days)" = "Historique d'utilisation"; + +"Usage source" = "Source d'utilisation"; + +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Utilisez BigModel pour les points de terminaison de la Chine continentale (open.bigmodel.cn)."; + +"Use a single menu bar icon with a provider switcher." = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; + +"Use international or China mainland console gateways for quota fetches." = "Utilisez les passerelles de console internationales ou chinoises pour les récupérations de quotas."; + +"Version" = "Version"; + +"Version \\(self.versionString)" = "Version \\(self.versionString)"; + +"Version \\(version)" = "Version \\(version)"; + +"Version \\(versionString)" = "Version \\(versionString)"; + +"Vertex AI Login" = "Connexion à Vertex AI"; + +"Wait for the current managed Codex login to finish before adding another account." = "Attendez la fin de la connexion Codex gérée actuelle avant d'ajouter un autre compte."; + +"Waiting for Authentication..." = "En attente d'authentification..."; + +"Website" = "Site web"; + +"Weekly limit confetti" = "Confettis de limite hebdomadaire"; + +"Weekly token limit" = "Limite hebdomadaire de jetons"; + +"Weekly usage" = "Utilisation hebdomadaire"; + +"Weekly usage unavailable for this account." = "Utilisation hebdomadaire indisponible pour ce compte."; + +"Window: \\(window)" = "Fenêtre : \\(window)"; + +"Write logs to \\(self.fileLogPath) for debugging." = "Écrivez les journaux dans \\(self.fileLogPath) pour le débogage."; + +"Yes" = "Oui"; + +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode) : \\(usage)"; + +"\\(name): \\(truncated)" = "\\(name) : \\(truncated)"; + +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name) : \\(updated) · 30j \\(cost)"; + +"\\(name): fetching…\\(elapsed)" = "\\(name) : récupération de…\\(elapsed)"; + +"\\(name): last attempt \\(when)" = "\\(name) : dernière tentative \\(when)"; + +"\\(name): no data yet" = "\\(name) : aucune donnée pour l'instant"; + +"\\(name): unsupported" = "\\(name) : non pris en charge"; + +"all browsers" = "tous les navigateurs"; + +"available again." = "à nouveau disponible."; + +"built_format" = "Construit %@"; + +"copilot_complete_in_browser" = "Connectez-vous complètement dans votre navigateur."; + +"copilot_device_code" = "Code de l'appareil copié dans le presse-papier : %1$@\n\nVérifiez à : %2$@"; + +"copilot_device_code_copied" = "Code de l'appareil copié."; + +"copilot_verify_at" = "Vérifiez à %@"; + +"copilot_waiting_text" = "Terminez la connexion dans votre navigateur.\nCette fenêtre se ferme automatiquement une fois la connexion terminée."; + +"copilot_window_closes_auto" = "Cette fenêtre se ferme automatiquement une fois la connexion terminée."; + +"cost_status_error" = "%1$@ : %2$@"; + +"cost_status_fetching" = "%1$@ : récupération de … %2$@"; + +"cost_status_last_attempt" = "%1$@ : dernière tentative %2$@"; + +"cost_status_no_data" = "%@ : aucune donnée pour l'instant"; + +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; + +"cost_status_unsupported" = "%@ : non pris en charge"; + +"credits_remaining" = "Crédits : %@"; + +"cursor_on_demand" = "À la demande : %@"; + +"cursor_on_demand_with_limit" = "À la demande : %1$@ / %2$@"; + +"extra_usage_format" = "Utilisation supplémentaire : %1$@ / %2$@"; + +"jetbrains_detected_generate" = "Détecté : %@. Utilisez l'assistant IA une fois pour générer des données de quota, puis actualisez QuotaKit."; + +"jetbrains_detected_select" = "Détecté : %@. Sélectionnez votre IDE préféré dans Paramètres, puis actualisez QuotaKit."; + +"last_fetch_failed_with_provider" = "La dernière récupération de %@ a échoué :"; + +"last_spend" = "Dernière dépense : %@"; + +"mcp_model_usage" = "%1$@ : %2$@"; + +"mcp_resets" = "Réinitialisation : %@"; + +"mcp_window" = "Fenêtre : %@"; + +"metric_average" = "Moyenne (%1$@ + %2$@)"; + +"metric_primary" = "Primaire (%@)"; + +"metric_secondary" = "Secondaire (%@)"; + +"metric_tertiary" = "Tertiaire (%@)"; + +"multiple_workspaces_found" = "QuotaKit a trouvé plusieurs espaces de travail pour %@. Veuillez choisir l'espace de travail à ajouter."; + +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + +"overview_choose_providers" = "Choisissez jusqu'à %@ fournisseurs"; + +"remove_account_message" = "Supprimer %@ de QuotaKit ? Sa maison Codex gérée sera supprimée."; + +"version_format" = "Version %@"; + +"vertex_ai_login_instructions" = "Pour suivre l'utilisation de Vertex AI, authentifiez-vous auprès de Google Cloud.\n\n1. Ouvrez le terminal\n2. Exécutez : gcloud auth application-default login\n3. Suivez les invites du navigateur pour vous connecter\n4. Définissez votre projet : gcloud config set project PROJECT_ID\n\nOuvrir le terminal maintenant ?"; + +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID est défini mais seuls opencode, opencodego et deepgram prennent en charge workspaceID."; + +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licence MIT."; + + +/* General Pane */ +"section_system" = "Système"; + +"section_usage" = "Utilisation"; + +"section_automation" = "Automatisation"; + +"language_title" = "Langue"; + +"language_subtitle" = "Change la langue d'affichage. Nécessite de redémarrer l'app pour une prise en compte complète."; + +"language_system" = "Système"; + +"language_english" = "Anglais"; + +"language_spanish" = "Espagnol"; + +"language_catalan" = "Catalan"; + +"language_chinese_simplified" = "Chinois simplifié"; + +"language_chinese_traditional" = "Chinois traditionnel"; + +"language_portuguese_brazilian" = "Portugais (Brésil)"; + +"language_swedish" = "Suédois"; + +"language_french" = "Français"; + +"language_dutch" = "Néerlandais"; + +"language_ukrainian" = "Ukrainien"; + +"language_vietnamese" = "Vietnamien"; + +"start_at_login_title" = "Lancer à l'ouverture de session"; + +"start_at_login_subtitle" = "Ouvre automatiquement QuotaKit au démarrage de votre Mac."; + +"show_cost_summary" = "Afficher le récapitulatif des coûts"; + +"show_cost_summary_subtitle" = "Lit les journaux d'utilisation locaux. Affiche le coût d'aujourd'hui et de la période sélectionnée dans le menu."; + +"cost_history_days_title" = "Fenêtre d'historique : %d jours"; + +"cost_auto_refresh_info" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; + +"refresh_cadence_title" = "Fréquence d'actualisation"; + +"refresh_cadence_subtitle" = "Définit la fréquence à laquelle QuotaKit interroge les fournisseurs en arrière-plan."; + +"manual_refresh_hint" = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; + +"check_provider_status_title" = "Vérifier l'état des fournisseurs"; + +"check_provider_status_subtitle" = "Interroge les pages d'état OpenAI/Claude et Google Workspace pour Gemini/Antigravity, et affiche les incidents dans l'icône et le menu."; + +"session_quota_notifications_title" = "Notifications de quota de session"; + +"session_quota_notifications_subtitle" = "Vous avertit lorsque le quota de session sur 5 heures atteint 0 % puis lorsqu'il redevient disponible."; + +"quota_warning_notifications_title" = "Alertes de quota"; + +"quota_warning_notifications_subtitle" = "Vous avertit lorsque le quota restant (session ou hebdomadaire) franchit les seuils configurés."; + +"quota_warnings_title" = "Alertes de quota"; + +"quota_warning_session" = "session"; + +"quota_warning_session_capitalized" = "Session"; + +"quota_warning_weekly" = "hebdomadaire"; + +"quota_warning_weekly_capitalized" = "Hebdomadaire"; + +"quota_warning_notification_title" = "%1$@ %2$@ : quota faible"; + +"quota_warning_notification_body" = "Il reste %1$@. Seuil d'alerte %2$d %% (%3$@) atteint."; + +"quota_warning_notification_body_with_account" = "Compte %1$@. Il reste %2$@. Seuil d'alerte %3$d %% (%4$@) atteint."; + +"session_depleted_notification_title" = "Session %@ épuisée"; + +"session_depleted_notification_body" = "0 % restant. Vous serez notifié quand elle redeviendra disponible."; + +"session_restored_notification_title" = "Session %@ rétablie"; + +"session_restored_notification_body" = "Le quota de session est à nouveau disponible."; + +"quota_warning_warn_at" = "Avertir à"; + +"quota_warning_global_threshold_subtitle" = "Pourcentages restants pour les fenêtres de session et hebdomadaires, sauf si un fournisseur les remplace."; + +"quota_warning_sound" = "Lire un son de notification"; + +"quota_warning_provider_inherits" = "Utilise les réglages globaux d'alerte de quota, sauf personnalisation de cette fenêtre."; + +"quota_warning_customize_thresholds" = "Personnaliser les seuils %@"; + +"quota_warning_enable_warnings" = "Activer les alertes %@"; + +"quota_warning_window_warn_at" = "Alerte %@"; + +"quota_warning_off" = "Désactivé"; + +"quota_warning_inherited" = "Hérité : %@"; + +"quota_warning_depleted_only" = "uniquement à l'épuisement"; + +"quota_warning_upper" = "Seuil haut"; + +"quota_warning_lower" = "Seuil bas"; + +"apply" = "Appliquer"; + +"quit_app" = "Quitter QuotaKit"; + + +/* Tab titles */ +"tab_general" = "Général"; + +"tab_providers" = "Fournisseurs"; + +"tab_display" = "Affichage"; + +"tab_advanced" = "Avancé"; + +"tab_about" = "À propos"; + +"tab_debug" = "Débogage"; + + +/* Providers Pane */ +"select_a_provider" = "Sélectionner un fournisseur"; + +"cancel" = "Annuler"; + +"last_fetch_failed" = "dernière récupération échouée"; + +"usage_not_fetched_yet" = "utilisation pas encore récupérée"; + +"managed_account_storage_unreadable" = "Le stockage du compte géré est illisible. L'accès au compte réel est toujours disponible, mais les actions gérées d'ajout, de réauthentification et de suppression sont désactivées jusqu'à ce que le magasin soit récupérable."; + +"remove_codex_account_title" = "Supprimer le compte Codex ?"; + +"remove" = "Supprimer"; + +"managed_login_already_running" = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez la fin avant d'ajouter ou de ré-authentifier un autre compte."; + +"managed_login_failed" = "La connexion au Codex géré n'a pas abouti. Vérifiez que `codex --version` fonctionne dans Terminal. Si macOS a bloqué ou déplacé « codex » vers la corbeille, supprimez les installations en double obsolètes, exécutez « npm install -g --include=optional @openai/codex@latest », puis réessayez."; + +"codex_login_output" = "Résultat de connexion à Codex :"; + +"managed_login_missing_email" = "Connexion au Codex terminée, mais aucune adresse e-mail du compte n'était disponible. Réessayez après avoir confirmé que le compte est entièrement connecté."; + +"login_success_notification_title" = "%@ connexion réussie"; + +"login_success_notification_body" = "Vous pouvez revenir à l’app ; authentification terminée."; + +"workspace_selection_cancelled" = "QuotaKit a trouvé plusieurs espaces de travail, mais aucun espace de travail n'a été sélectionné."; + +"unsafe_managed_home" = "QuotaKit a refusé de modifier un chemin d'accès à la maison géré inattendu : %@"; + +"menu_bar_metric_title" = "Métrique de la barre de menus"; + +"menu_bar_metric_subtitle" = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; + +"menu_bar_metric_subtitle_deepseek" = "Affiche le solde DeepSeek dans la barre de menu."; + +"menu_bar_metric_subtitle_moonshot" = "Affiche le solde de l'API Moonshot / Kimi dans la barre de menu."; + +"menu_bar_metric_subtitle_mistral" = "Affiche les dépenses de l'API Mistral du mois en cours dans la barre de menu."; + +"menu_bar_metric_subtitle_kimik2" = "Affiche les crédits de la clé API Kimi K2 dans la barre de menu."; + +"automatic" = "Automatique"; + +"primary_api_key_limit" = "Primaire (limite de clé API)"; + + +/* Display Pane */ +"section_menu_bar" = "Barre de menus"; + +"merge_icons_title" = "Fusionner les icônes"; + +"merge_icons_subtitle" = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; + +"switcher_shows_icons_title" = "Le commutateur affiche des icônes"; + +"switcher_shows_icons_subtitle" = "Afficher les icônes des fournisseurs dans le sélecteur (sinon, afficher une ligne de progression hebdomadaire)."; + +"show_most_used_provider_title" = "Afficher le fournisseur le plus utilisé"; + +"show_most_used_provider_subtitle" = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; + +"menu_bar_shows_percent_title" = "La barre de menu affiche le pourcentage"; + +"menu_bar_shows_percent_subtitle" = "Remplacez les barres de créatures par des icônes de marque du fournisseur et un pourcentage."; + +"display_mode_title" = "Mode d'affichage"; + +"display_mode_subtitle" = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; + +"section_menu_content" = "Contenu des menus"; + +"show_usage_as_used_title" = "Afficher l'utilisation telle qu'utilisée"; + +"show_usage_as_used_subtitle" = "Les barres de progression se remplissent à mesure que vous consommez le quota (au lieu d'afficher le reste)."; + +"show_quota_warning_markers_title" = "Afficher les marqueurs d'avertissement de quota"; + +"show_quota_warning_markers_subtitle" = "Dessinez des coches de seuil sur les barres d’utilisation lorsque des avertissements de quota sont configurés."; + +"weekly_progress_work_days_title" = "Jours de travail hebdomadaires"; + +"weekly_progress_work_days_subtitle" = "Dessinez des graduations journalières sur les barres d’utilisation hebdomadaire."; + +"show_reset_time_as_clock_title" = "Afficher l'heure de réinitialisation sous forme d'horloge"; + +"show_reset_time_as_clock_subtitle" = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; + +"show_provider_changelog_links_title" = "Afficher les liens du journal des modifications du fournisseur"; + +"show_provider_changelog_links_subtitle" = "Ajoute au menu des liens de notes de version pour les fournisseurs pris en charge par CLI."; + +"show_credits_extra_usage_title" = "Afficher les crédits + utilisation supplémentaire"; + +"show_credits_extra_usage_subtitle" = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; + +"show_all_token_accounts_title" = "Afficher tous les comptes de jetons"; + +"show_all_token_accounts_subtitle" = "Empilez les comptes de jetons dans le menu (sinon, affichez une barre de changement de compte)."; + +"multi_account_layout_title" = "Disposition multi-comptes"; + +"multi_account_layout_subtitle" = "Choisissez un changement de compte segmenté ou des cartes de compte empilées."; + +"multi_account_layout_segmented" = "Segmenté"; + +"multi_account_layout_stacked" = "Empilé"; + +"overview_tab_providers_title" = "Fournisseurs d'onglets de présentation"; + +"configure" = "Configurer…"; + +"overview_enable_merge_icons_hint" = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; + +"overview_no_providers_hint" = "Aucun fournisseur activé disponible pour la présentation."; + +"overview_rows_follow_order" = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; + +"overview_no_providers_selected" = "Aucun fournisseur sélectionné"; + + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Raccourci clavier"; + +"open_menu_shortcut_title" = "Ouvrir le menu"; + +"open_menu_shortcut_subtitle" = "Déclenchez le menu de la barre de menus depuis n'importe où."; + +"install_cli" = "Installer la CLI"; + +"install_cli_subtitle" = "Lien symbolique QuotaKitCLI vers /usr/local/bin et /opt/homebrew/bin en tant que quotakit."; + +"cli_not_found" = "QuotaKitCLI introuvable dans l'ensemble d'applications."; + +"no_writable_bin_dirs" = "Aucun répertoire bin inscriptible trouvé."; + +"show_debug_settings_title" = "Afficher les paramètres de débogage"; + +"show_debug_settings_subtitle" = "Exposez les outils de dépannage dans l’onglet Débogage."; + +"surprise_me_title" = "Surprenez-moi"; + +"surprise_me_subtitle" = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; + +"weekly_limit_confetti_title" = "Confettis de limite hebdomadaire"; + +"weekly_limit_confetti_subtitle" = "Jouez des confettis en plein écran lorsque l'utilisation hebdomadaire est réinitialisée."; + +"hide_personal_info_title" = "Masquer les informations personnelles"; + +"hide_personal_info_subtitle" = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; + +"show_provider_storage_usage_title" = "Afficher l'utilisation du stockage du fournisseur"; + +"show_provider_storage_usage_subtitle" = "Afficher l'utilisation du disque local dans les menus. Analyse les chemins connus appartenant au fournisseur en arrière-plan."; + +"section_keychain_access" = "Accès au Trousseau"; + +"keychain_access_caption" = "Désactivez toutes les lectures et écritures du Trousseau. Utilisez-le si macOS continue de demander « Chrome/Brave/Edge Safe Storage » même après avoir cliqué sur Toujours autoriser. L'importation des cookies du navigateur n'est pas disponible lorsqu'elle est activée ; collez manuellement les en-têtes de cookies dans les fournisseurs. Claude/Codex OAuth via la CLI fonctionne toujours."; + +"disable_keychain_access_title" = "Désactiver l'accès au Trousseau"; + +"disable_keychain_access_subtitle" = "Empêche tout accès au Trousseau lorsqu'il est activé."; + + +/* About Pane */ +"about_tagline" = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; + +"link_github" = "GitHub"; + +"link_website" = "Site web"; + +"link_twitter" = "X/Twitter"; + +"link_email" = "E-mail"; + +"check_updates_auto" = "Rechercher automatiquement les mises à jour"; + +"update_channel" = "Mettre à jour la chaîne"; + +"check_for_updates" = "Rechercher les mises à jour…"; + +"updates_unavailable" = "Mises à jour non disponibles dans cette version."; + +"copyright" = "© 2026 Peter Steinberger. Licence MIT."; + + +/* Debug Pane */ +"section_logging" = "Journalisation"; + +"enable_file_logging" = "Activer la journalisation des fichiers"; + +"enable_file_logging_subtitle" = "Écrivez les journaux dans %@ pour le débogage."; + +"verbosity_title" = "Niveau de verbosité"; + +"verbosity_subtitle" = "Contrôle la quantité de détails enregistrés."; + +"open_log_file" = "Ouvrir le fichier journal"; + +"force_animation_next_refresh" = "Forcer l'animation au prochain rafraîchissement"; + +"force_animation_next_refresh_subtitle" = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; + +"section_loading_animations" = "Chargement des animations"; + +"loading_animations_caption" = "Choisissez un motif et rejouez-le dans la barre de menu. \"Aléatoire\" conserve le comportement existant."; + +"animation_random_default" = "Aléatoire (par défaut)"; + +"replay_selected_animation" = "Rejouer l'animation sélectionnée"; + +"blink_now" = "Cligne des yeux maintenant"; + +"section_probe_logs" = "Journaux de sonde"; + +"probe_logs_caption" = "Récupère la dernière sortie de la sonde pour le débogage ; La copie conserve le texte intégral."; + +"fetch_log" = "Récupérer le journal"; + +"copy" = "Copier"; + +"save_to_file" = "Enregistrer dans un fichier"; + +"load_parse_dump" = "Charger le vidage d'analyse"; + +"rerun_provider_autodetect" = "Réexécuter la détection automatique du fournisseur"; + +"loading" = "Chargement…"; + +"no_log_yet_fetch" = "Pas de journal pour l'instant. Récupérer pour charger."; + +"section_fetch_strategy" = "Récupérer les tentatives de stratégie"; + +"fetch_strategy_caption" = "Dernières décisions et erreurs du pipeline de récupération pour un fournisseur."; + +"section_openai_cookies" = "Cookies OpenAI"; + +"openai_cookies_caption" = "Importation de cookies + journaux de récupération WebKit de la dernière tentative de cookies OpenAI."; + +"no_log_yet" = "Pas de journal pour l'instant. Mettez à jour les cookies OpenAI dans Fournisseurs → Codex pour exécuter une importation."; + +"section_caches" = "Caches"; + +"caches_caption" = "Effacez les résultats de l’analyse des coûts mis en cache ou les caches des cookies du navigateur."; + +"clear_cookie_cache" = "Vider le cache des cookies"; + +"clear_cost_cache" = "Vider le cache des coûts"; + +"section_notifications" = "Notifications"; + +"notifications_caption" = "Déclenchez des notifications de test pour la fenêtre de session de 5 heures (épuisée/restaurée)."; + +"post_depleted" = "Post épuisé"; + +"post_restored" = "Message restauré"; + +"section_cli_sessions" = "Sessions CLI"; + +"cli_sessions_caption" = "Gardez les sessions Codex/Claude CLI actives après une sonde. La valeur par défaut se ferme une fois les données capturées."; + +"keep_cli_sessions_alive" = "Maintenir les sessions CLI en vie"; + +"keep_cli_sessions_alive_subtitle" = "Ignorer le démontage entre les sondes (débogage uniquement)."; + +"reset_cli_sessions" = "Réinitialiser les sessions CLI"; + +"section_error_simulation" = "Simulation d'erreur"; + +"error_simulation_caption" = "Injectez un faux message d'erreur dans la carte de menu pour tester la mise en page."; + +"set_menu_error" = "Erreur de menu de définition"; + +"clear_menu_error" = "Effacer l'erreur de menu"; + +"set_cost_error" = "Erreur de définition du coût"; + +"clear_cost_error" = "Effacer l'erreur de coût"; + +"section_cli_paths" = "Chemins CLI"; + +"cli_paths_caption" = "Couches binaires et PATH du Codex résolues ; Capture du chemin de connexion au démarrage (délai d'attente court)."; + +"codex_binary" = "Binaire Codex"; + +"claude_binary" = "Binaire Claude"; + +"effective_path" = "CHEMIN efficace"; + +"unavailable" = "Indisponible"; + +"login_shell_path" = "CHEMIN du shell de connexion (capture de démarrage)"; + +"cleared" = "Effacé"; + +"no_fetch_attempts" = "Aucune tentative de récupération pour l'instant."; + +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe peut bloquer les applications de la barre de menus dans Paramètres système → Barre de menus → Autoriser dans la barre de menus. QuotaKit est en cours d'exécution, mais macOS cache peut-être son icône. Ouvrez les paramètres de la barre de menu et activez QuotaKit."; + + +/* Metric preferences */ +"metric_pref_automatic" = "Automatique"; + +"metric_pref_primary" = "Principal"; + +"metric_pref_secondary" = "Secondaire"; + +"metric_pref_tertiary" = "Tertiaire"; + +"metric_pref_extra_usage" = "Utilisation supplémentaire"; + +"metric_pref_average" = "Moyenne"; + + +/* Display modes */ +"display_mode_percent" = "Pourcentage"; + +"display_mode_pace" = "Rythme"; + +"display_mode_both" = "Les deux"; + +"display_mode_percent_desc" = "Afficher le pourcentage restant/utilisé (par exemple 45 %)"; + +"display_mode_pace_desc" = "Afficher l'indicateur d'allure (par exemple +5 %)"; + +"display_mode_both_desc" = "Afficher à la fois le pourcentage et le rythme (par exemple 45 % · +5 %)"; + + +/* Provider status */ +"status_operational" = "Opérationnel"; + +"status_partial_outage" = "Dégradation partielle"; + +"status_major_outage" = "Panne majeure"; + +"status_critical_issue" = "Problème critique"; + +"status_maintenance" = "Maintenance"; + +"status_unknown" = "Statut inconnu"; + + +/* Refresh frequency */ +"refresh_manual" = "Manuel"; + +"refresh_1min" = "1 minute"; + +"refresh_2min" = "2 minutes"; + +"refresh_5min" = "5 minutes"; + +"refresh_15min" = "15 minutes"; + +"refresh_30min" = "30 minutes"; + + +/* Additional keys */ +"not_found" = "Pas trouvé"; + + +/* Cost estimation */ +"cost_header_estimated" = "Coût (estimé)"; + +"cost_estimate_hint" = "Estimé à partir des journaux locaux · peut différer de votre facture"; + +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Aucun IDE JetBrains avec AI Assistant détecté. Installez un IDE JetBrains et activez AI Assistant."; + +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Jeton API OpenRouter non configuré. Définissez la variable d'environnement OPENROUTER_API_KEY ou configurez-la dans Paramètres."; + +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Jeton API z.ai introuvable. Définissez apiKey dans ~/.quotakit/config.json ou Z_AI_API_KEY."; + +"Missing DeepSeek API key." = "Clé API DeepSeek manquante."; + +"%@ is unavailable in the current environment." = "%@ n'est pas disponible dans l'environnement actuel."; + +"All Systems Operational" = "Tous les systèmes opérationnels"; + +"Last 30 days" = "30 derniers jours"; + +"Last 30 days:" = "30 derniers jours :"; + +"This month" = "Ce mois-ci"; + +"Store multiple OpenAI API keys." = "Stockez plusieurs clés API OpenAI."; + +"Admin API key" = "Clé API d'administration"; + +"Open billing" = "Facturation ouverte"; + +"Google accounts" = "Comptes Google"; + +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Stockez plusieurs comptes Google OAuth Antigravity pour une commutation rapide."; + +"Add Google Account" = "Ajouter un compte Google"; + +"Open Token Plan" = "Plan de jetons ouverts"; + +"Text Generation" = "Génération de texte"; + +"Text to Speech" = "Synthèse vocale"; + +"Music Generation" = "Génération de musique"; + +"Image Generation" = "Génération d'images"; + +"No local data found" = "Aucune donnée locale trouvée"; + +"Credits unavailable; keep Codex running to refresh." = "Crédits indisponibles ; laissez le Codex fonctionner pour l'actualiser."; + +"No available fetch strategy for minimax." = "Aucune stratégie de récupération disponible pour minimax."; + +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Aucune session de Cursor trouvée. Veuillez vous connecter à cursor.com dans Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Si vous utilisez Safari, accordez l'accès complet au disque à QuotaKit dans Paramètres système ▸ Confidentialité et sécurité. Vous pouvez également vous connecter à Cursor à partir du menu QuotaKit (Ajouter/changer de compte)."; + +"No OpenCode session cookies found in browsers." = "Aucun cookie de session OpenCode trouvé dans les navigateurs."; + +"No available fetch strategy for %@." = "Aucune stratégie de récupération disponible pour %@."; + +"Today" = "Aujourd’hui"; + +"Today tokens" = "Jetons d'aujourd'hui"; + +"30d cost" = "coût 30 jours"; + +"30d tokens" = "jetons 30d"; + +"Latest tokens" = "Derniers jetons"; + +"Top model" = "Top modèle"; + +"Storage" = "Stockage"; + +"Add Account..." = "Ajouter un compte..."; + +"Usage Dashboard" = "Tableau de bord d'utilisation"; + +"Status Page" = "Page d'état"; + +"Settings..." = "Réglages…"; + +"About CodexBar" = "À propos de QuotaKit"; + +"Quit" = "Quitter"; + +"Last %d day" = "Dernier %d jour"; + +"Last %d days" = "%d derniers jours"; + +"%@ tokens" = "Jetons %@"; + +"Latest billing day" = "Dernier jour de facturation"; + +"Latest billing day (%@)" = "Dernier jour de facturation (%@)"; + +"%@ left" = "%@ restant"; + +"Resets %@" = "Réinitialise %@"; + +"Resets in %@" = "Réinitialisé dans %@"; + +"Resets now" = "Réinitialise maintenant"; + +"Lasts until reset" = "Dure jusqu'à la réinitialisation"; + +"Updated %@" = "%@ mis à jour"; + +"Updated %@h ago" = "Mis à jour il y a %@h"; + +"Updated %@m ago" = "Mis à jour il y a %@m"; + +"Updated just now" = "Mis à jour tout à l'heure"; + +"Projected empty in %@" = "Projeté vide dans %@"; + +"Runs out in %@" = "S'épuise dans %@"; + +"Pace: %@" = "Rythme : %@"; + +"Pace: %@ · %@" = "Rythme : %@ · %@"; + +"%@ · %@" = "%@ · %@"; + +"≈ %d%% run-out risk" = "≈ %d%% risque d'épuisement"; + +"%d%% in deficit" = "%d%% en déficit"; + +"%d%% in reserve" = "%d%% en réserve"; + +"usage_percent_suffix_left" = "restant"; + +"usage_percent_suffix_used" = "utilisé"; + +"Store multiple DeepSeek API keys." = "Stockez plusieurs clés API DeepSeek."; + +"This week" = "Cette semaine"; + +"Week" = "Semaine"; + +"Month" = "Mois"; + +"Models" = "Modèles"; + +"24h tokens" = "jetons 24h"; + +"Latest hour" = "Dernière heure"; + +"Peak hour" = "Heure de pointe"; + +"Top method" = "Méthode supérieure"; + +"30d cash" = "30 jours en espèces"; + +"30d billing history from MiniMax web session" = "Historique de facturation 30 jours à partir de la session Web MiniMax"; + +"AWS Cost Explorer billing can lag." = "La facturation d'AWS Cost Explorer peut prendre du retard."; + +"Rate limit: %d / %@" = "Limite de débit : %d / %@"; + +"Key remaining" = "Clé restante"; + +"No limit set for the API key" = "Aucune limite définie pour la clé API"; + +"API key limit unavailable right now" = "Limite de clé API indisponible pour le moment"; + +"This month: %@ tokens" = "Ce mois-ci : %@ jetons"; + +"No utilization data yet." = "Aucune donnée d'utilisation pour l'instant."; + +"No %@ utilization data yet." = "Aucune donnée d'utilisation de %@ pour l'instant."; + +"%@: %@%% used" = "%@ : %@%% utilisé"; + +"%dd" = "%dd"; + +"today" = "aujourd'hui"; + +"just now" = "tout à l' heure"; + +"On pace" = "Au rythme"; + +"Runs out now" = "S'épuise maintenant"; + +"Projected empty now" = "Projeté vide maintenant"; + +"Switch Account..." = "Changer de compte..."; + +"Update ready, restart now?" = "La mise à jour est prête, redémarrer maintenant ?"; + +"Daily" = "Quotidien"; + +"Hourly Tokens" = "Jetons horaires"; + +"No data" = "Aucune donnée"; + +"No usage breakdown data available." = "Aucune donnée de répartition d'utilisation disponible."; + + +"Today: %@ · %@ tokens" = "Aujourd'hui : %@ · %@ jetons"; + +"Today: %@" = "Aujourd'hui : %@"; + +"Today: %@ tokens" = "Aujourd'hui : %@ jetons"; + +"Last 30 days: %@ · %@ tokens" = "30 derniers jours : %@ · %@ jetons"; + +"Last 30 days: %@" = "30 derniers jours : %@"; + +"Est. total (30d): %@" = "HNE. total (30j) : %@"; + +"Est. total (%@): %@" = "HNE. total (%@) : %@"; + +"Hover a bar for details" = "Passez la souris sur une barre pour plus de détails"; + +"%@: %@ · %@ tokens" = "%@ : %@ · %@ jetons"; + +"No providers selected for Overview." = "Aucun fournisseur sélectionné pour la vue d'ensemble."; + +"No overview data available." = "Aucune donnée globale disponible."; + +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto utilise d'abord l'API IDE locale, puis Google OAuth lorsque l'EDI est fermé."; + +"Login with Google" = "Connectez-vous avec Google"; + + +/* Popup panels */ +"No usage configured." = "Aucune utilisation configurée."; + +"Quota" = "Quota"; + +"tokens" = "jetons"; + +"requests" = "requêtes"; + +"Latest" = "Dernier"; + +"Monthly" = "Mensuel"; + +"Sonnet" = "Sonnet"; + +"Overages" = "Dépassements"; + +"Activity" = "Activité"; + +"Copied" = "Copié"; + +"Copy error" = "Erreur de copie"; + +"Copy path" = "Copier le chemin"; + +"Extra usage spent" = "Utilisation supplémentaire dépensée"; + +"Credits remaining" = "Crédits restants"; + +"Using CLI fallback" = "Utilisation de la solution de secours CLI"; + +"Balance updates in near-real time (up to 5 min lag)" = "Mises à jour du solde en temps quasi réel (jusqu'à 5 minutes de décalage)"; + +"Daily billing data finalizes at 07:00 UTC" = "Les données de facturation quotidiennes se terminent à 07h00 UTC"; + +"%@ of %@ credits left" = "%@ sur %@ crédits restants"; + +"%@ of %@ bonus credits left" = "%@ de %@ crédits bonus restants"; + +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restant)"; + +"%@/%@ left" = "%@/%@ gauche"; + +"Gemini Flash" = "Flash Gémeaux"; + +"Regenerates %@" = "Régénère %@"; + +"used after next regen" = "utilisé après la prochaine régénération"; + +"after next regen" = "après la prochaine régénération"; + +"Near full" = "Presque plein"; + +"Full in ~1 regen" = "Complet en ~1 régénération"; + +"Full in ~%.0f regens" = "Plein en ~%.0f régénérations"; + +"Overage usage" = "Utilisation excédentaire"; + +"Overage cost" = "Coût excédentaire"; + +"credits" = "crédits"; + +"Zen balance" = "L'équilibre zen"; + +"API spend" = "Dépenses API"; + +"Extra usage" = "Utilisation supplémentaire"; + +"Quota usage" = "Utilisation des quotas"; + +"%.0f%% used" = "%.0f%% utilisé"; + +"Usage history (today)" = "Historique d'utilisation (aujourd'hui)"; + +"Usage history (%d days)" = "Historique d'utilisation (%d jours)"; + +"%d percent remaining" = "%d pour cent restant"; + +"Unknown" = "Inconnu"; + +"stale data" = "données obsolètes"; + +"No credits history data." = "Aucune donnée d'historique de crédits."; + +"No credits history data available." = "Aucune donnée d'historique de crédits disponible."; + +"Credits history chart" = "Graphique de l'historique des crédits"; + +"%d days of credits data" = "%d jours de données de crédits"; + +"Usage breakdown chart" = "Tableau de répartition de l'utilisation"; + +"%d days of usage data across %d services" = "%d jours de données d'utilisation sur %d services"; + +"Cost history chart" = "Graphique de l'historique des coûts"; + +"%d days of cost data" = "%d jours de données sur les coûts"; + +"Plan utilization chart" = "Tableau d'utilisation du plan"; + +"%d utilization samples" = "Exemples d'utilisation de %d"; + +"Hourly Usage" = "Utilisation horaire"; + +"Usage remaining" = "Utilisation restante"; + +"Usage used" = "Utilisation utilisée"; + +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clé API vérifiée. Ollama n'expose pas les limites de quota Cloud via l'API."; + +"Last 30 days: %@ tokens" = "30 derniers jours : jetons %@"; + +"7d spend" = "7j dépensés"; + +"30d spend" = "30 jours de dépenses"; + +"Cache read" = "Lecture du cache"; + +"Claude Admin API 30 day spend trend" = "Tendance des dépenses de l'API Claude Admin sur 30 jours"; + +"OpenRouter API key spend trend" = "Tendance des dépenses liées aux clés API OpenRouter"; + +"z.ai hourly token trend" = "tendance des jetons horaires z.ai"; + +"MiniMax 30 day token usage trend" = "Tendance d'utilisation des jetons MiniMax sur 30 jours"; + +"Today cash" = "Aujourd'hui en espèces"; + +"DeepSeek 30 day token usage trend" = "Tendance d'utilisation des jetons DeepSeek sur 30 jours"; + +"cache-hit input" = "entrée d'accès au cache"; + +"cache-miss input" = "entrée manquante dans le cache"; + +"output" = "sortie"; + +"Requests" = "Requêtes"; + +"Reported by OpenAI Admin API organization usage." = "Signalé par l’utilisation de l’organisation de l’API OpenAI Admin."; + +"Reported by Mistral billing usage." = "Rapporté par l'utilisation de la facturation Mistral."; + +"Google OAuth" = "Google OAuth"; + +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Ajoutez des comptes via GitHub OAuth Device Flow sur l'hôte sélectionné."; + +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stocke chaque compte Google connecté pour un changement rapide d'antigravité. Utilise Antigravity.app OAuth lorsqu'il est disponible, ou ANTIGRAVITY_OAUTH_CLIENT_ID et ANTIGRAVITY_OAUTH_CLIENT_SECRET comme remplacement."; + +"Manual cleanup: past sessions" = "Nettoyage manuel : sessions précédentes"; + +"Clearing removes past resume, continue, and rewind history." = "L'effacement supprime l'historique de reprise, de continuation et de rembobinage passé."; + +"Manual cleanup: file checkpoints" = "Nettoyage manuel : points de contrôle des fichiers"; + +"Clearing removes checkpoint restore data for previous edits." = "La suppression supprime les données de restauration du point de contrôle pour les modifications précédentes."; + +"Manual cleanup: saved plans" = "Nettoyage manuel : plans enregistrés"; + +"Clearing removes old plan-mode files." = "La suppression supprime les anciens fichiers en mode plan."; + +"Manual cleanup: debug logs" = "Nettoyage manuel : journaux de débogage"; + +"Clearing removes past debug logs." = "La suppression supprime les anciens journaux de débogage."; + +"Manual cleanup: attachment cache" = "Nettoyage manuel : cache des pièces jointes"; + +"Clearing removes cached large pastes or attached images." = "La suppression supprime les gros collages mis en cache ou les images jointes."; + +"Manual cleanup: session metadata" = "Nettoyage manuel : métadonnées de session"; + +"Clearing removes per-session environment metadata." = "La suppression supprime les métadonnées de l'environnement par session."; + +"Manual cleanup: shell snapshots" = "Nettoyage manuel : instantanés du shell"; + +"Clearing removes leftover runtime shell snapshot files." = "La suppression supprime les fichiers instantanés du shell d'exécution restants."; + +"Manual cleanup: legacy todos" = "Nettoyage manuel : tâches héritées"; + +"Clearing removes legacy per-session task lists." = "La suppression supprime les anciennes listes de tâches par session."; + +"Manual cleanup: sessions" = "Nettoyage manuel : sessions"; + +"Clearing removes past Codex session history." = "La suppression supprime l'historique des sessions Codex passées."; + +"Manual cleanup: archived sessions" = "Nettoyage manuel : sessions archivées"; + +"Clearing removes archived Codex session history." = "La suppression supprime l'historique des sessions Codex archivé."; + +"Manual cleanup: cache" = "Nettoyage manuel : cache"; + +"Clearing removes provider-owned cached data." = "La suppression supprime les données mises en cache appartenant au fournisseur."; + +"Manual cleanup: logs" = "Nettoyage manuel : journaux"; + +"Clearing removes local diagnostic logs." = "La suppression supprime les journaux de diagnostic locaux."; + +"Manual cleanup: file history" = "Nettoyage manuel : historique des fichiers"; + +"Clearing removes local edit checkpoint history." = "La suppression supprime l’historique des points de contrôle des modifications locales."; + +"Manual cleanup: temporary data" = "Nettoyage manuel : données temporaires"; + +"Clearing removes local temporary provider data." = "La suppression supprime les données du fournisseur temporaire local."; + +"Total: %@" = "Total : %@"; + +"%d more items" = "%d plus d'articles"; + +"Cleanup ideas" = "Idées de nettoyage"; + +"%d unreadable item(s) skipped" = "%d élément(s) illisible(s) ignoré(s)"; + + +"API key limit" = "Limite de clé API"; + +"Auth" = "Authentification"; + +"Auto" = "Auto"; + +"Disabled — no recent data" = "Désactivé – aucune donnée récente"; + +"Limits not available" = "Limites non disponibles"; + +"No usage yet" = "Pas encore d'utilisation"; + +"Not fetched yet" = "Pas encore récupéré"; + +"Refreshing" = "Actualisation"; + +"Session" = "Session"; + +"Source" = "Source"; + +"State" = "État"; + +"Unavailable" = "Indisponible"; + +"Weekly" = "Hebdomadaire"; + +"not detected" = "non détecté"; + +"Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; + +"minimax_usage_amount_format" = "Utilisation : %@ / %@"; + +"minimax_used_percent_format" = "Utilisé %@"; + +"minimax_service_text_generation" = "Génération de texte"; + +"minimax_service_text_to_speech" = "Synthèse vocale"; + +"minimax_service_music_generation" = "Génération de musique"; + +"minimax_service_image_generation" = "Génération d'images"; + +"minimax_service_lyrics_generation" = "Génération de paroles"; + +"minimax_service_coding_plan_vlm" = "Plan de codage VLM"; + +"minimax_service_coding_plan_search" = "Recherche de plan de codage"; + + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ attend l'autorisation"; + +"%@ requests" = "%@ requêtes"; + +"%@: %@ credits" = "%@ : %@ crédits"; + +"30d requests" = "demandes 30j"; + +"4 days" = "4 jours"; + +"5 days" = "5 jours"; + +"7 days" = "7 jours"; + +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clé API vérifie l'accès à Ollama Cloud ; les cookies exposent toujours des limites de quota."; + +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clé d'accès AWS. Peut également être défini avec AWS_ACCESS_KEY_ID."; + +"AWS region. Can also be set with AWS_REGION." = "Région AWS. Peut également être défini avec AWS_REGION."; + +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clé d'accès secrète AWS. Peut également être défini avec AWS_SECRET_ACCESS_KEY."; + +"Access key ID" = "ID de la clé d'accès"; + +"Add Account" = "Ajouter un compte"; + +"Adding Account…" = "Ajout d'un compte…"; + +"Antigravity login failed" = "La connexion antigravité a échoué"; + +"Antigravity login timed out" = "La connexion antigravité a expiré"; + +"Auth source" = "Source d'authentification"; + +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur Chrome de Xiaomi MiMo."; + +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importe automatiquement les données de session Windsurf à partir du navigateur Chromium localStorage."; + +"Automatic imports browser cookies from Bailian." = "Importe automatiquement les cookies du navigateur depuis Bailian."; + +"Automatically imports browser cookies." = "Importe automatiquement les cookies du navigateur."; + +"Automatically imports browser session cookies." = "Importe automatiquement les cookies de session du navigateur."; + +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nom du déploiement Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME est également pris en charge."; + +"Azure OpenAI key" = "Clé Azure OpenAI"; + +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Point de terminaison de ressource Azure OpenAI. AZURE_OPENAI_ENDPOINT est également pris en charge."; + +"Base URL" = "URL de base"; + +"Base URL for the LLM-API-Key-Proxy instance." = "URL de base pour l'instance LLM-API-Key-Proxy."; + +"Browser cookies" = "Cookies du navigateur"; + +"Cap end" = "Fin du capuchon"; + +"Cap start" = "Début du plafond"; + +"Capacity End" = "Fin de capacité"; + +"Capacity Start" = "Capacité Début"; + +"Changelog" = "Journal des modifications"; + +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Choisissez l'hôte API Moonshot/Kimi pour les comptes internationaux ou en Chine continentale."; + +"CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit ne peut pas remplacer un compte système connecté par une configuration de clé API uniquement."; + +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit n'a pas pu trouver l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; + +"CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit n'a pas pu lire le stockage du compte géré. Récupérez la boutique avant d'ajouter un autre compte."; + +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit n'a pas pu lire l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; + +"CodexBar could not read the current system account on this Mac." = "QuotaKit n'a pas pu lire le compte système actuel sur ce Mac."; + +"CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit n'a pas pu remplacer l'authentification Codex en direct sur ce Mac."; + +"CodexBar could not safely preserve the current system account before switching." = "QuotaKit n'a pas pu conserver en toute sécurité le compte système actuel avant le changement."; + +"CodexBar could not save the current system account before switching." = "QuotaKit n'a pas pu enregistrer le compte système actuel avant de changer."; + +"CodexBar could not update managed account storage." = "QuotaKit n'a pas pu mettre à jour le stockage du compte géré."; + +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit a trouvé un autre compte géré qui utilise déjà le compte système actuel. Résolvez le compte en double avant de changer."; + +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit demandera au Trousseau macOS « %@ » afin de pouvoir décrypter les cookies du navigateur et authentifier votre compte. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS le jeton Claude Code OAuth afin de pouvoir récupérer votre utilisation de Claude. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS l'en-tête de votre cookie Amp afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre en-tête de cookie Augment afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre en-tête de cookie Claude afin de pouvoir récupérer l'utilisation du Web de Claude. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS l'en-tête de votre cookie Cursor afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS l'en-tête de votre cookie d'usine afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre jeton GitHub Copilot afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre clé API Kimi K2 afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre jeton d'authentification Kimi afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre jeton API MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre en-tête de cookie MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre en-tête de cookie OpenAI afin de pouvoir récupérer les extras du tableau de bord Codex. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre en-tête de cookie OpenCode afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre clé API synthétique afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit demandera au Trousseau macOS votre jeton API z.ai afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; + +"Could not open Cursor login in your browser." = "Impossible d'ouvrir la connexion par curseur dans votre navigateur."; + +"Could not open browser for Antigravity" = "Impossible d'ouvrir le navigateur pour Antigravity"; + +"Credits used" = "Crédits utilisés"; + +"Day" = "Jour"; + +"Deployment" = "Déploiement"; + +"Drag to reorder" = "Faites glisser pour réorganiser"; + +"Endpoint" = "Point de terminaison"; + +"Enterprise host" = "Hôte d'entreprise"; + +"Extra usage balance: %@" = "Solde d'utilisation supplémentaire : %@"; + +"Keychain Access Required" = "Accès au Trousseau requis"; + +"Kiro menu bar value" = "Valeur de la barre de menu Kiro"; + +"Label" = "Libellé"; + +"No organizations loaded. Click Refresh after setting your API key." = "Aucune organisation chargée. Cliquez sur Actualiser après avoir défini votre clé API."; + +"No output captured." = "Aucune sortie capturée."; + +"No system account" = "Aucun compte système"; + +"Oasis-Token" = "Oasis-Token"; + +"Open Augment (Log Out & Back In)" = "Ouvrir l'augmentation (déconnexion et reconnexion)"; + +"Open Codebuff Dashboard" = "Ouvrir le tableau de bord Codebuff"; + +"Open Command Code Settings" = "Ouvrir les paramètres du code de commande"; + +"Open Crof dashboard" = "Ouvrir le tableau de bord Crof"; + +"Open Manus" = "Ouvrir Manus"; + +"Open MiMo Balance" = "Ouvrir la balance MiMo"; + +"Open Moonshot Console" = "Ouvrir la console Moonshot"; + +"Open Ollama API Keys" = "Ouvrir les clés API Ollama"; + +"Open StepFun Platform" = "Ouvrir la plateforme StepFun"; + +"Open T3 Chat Settings" = "Ouvrir les paramètres de discussion T3"; + +"Open Volcengine Ark Console" = "Ouvrir la console Volcengine Ark"; + +"Open legacy provider docs" = "Ouvrir les documents du fournisseur existant"; + +"Open projects" = "Projets ouverts"; + +"Open this URL manually to continue login:\n\n%@" = "Ouvrez cette URL manuellement pour continuer la connexion :\n\n%@"; + +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organisation facultatif pour les comptes liés à plusieurs organisations Anthropic."; + +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Facultatif. S'applique à la clé API Admin configurée ; Les comptes de jetons sélectionnés n'héritent pas d'OPENAI_PROJECT_ID."; + +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Facultatif. Entrez votre hôte GitHub Enterprise, par exemple octocorp.ghe.com. Laissez vide pour github.com."; + +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Facultatif. Laissez vide pour découvrir et regrouper les projets visibles par la clé API."; + +"Org ID (optional)" = "ID de l'organisation (facultatif)"; + +"Organizations" = "Organisations"; + +"Password" = "Mot de passe"; + +"%@ authentication is disabled." = "L'authentification %@ est désactivée."; + +"%@ cookies are disabled." = "Les cookies %@ sont désactivés."; + +"%@ web API access is disabled." = "L'accès à l'API Web %@ est désactivé."; + +"Disable %@ dashboard cookie usage." = "Désactivez l'utilisation des cookies du tableau de bord %@."; + +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accès au Trousseau est désactivé dans Advanced, donc l'importation des cookies du navigateur n'est pas disponible."; + +"Manually paste an %@ from a browser session." = "Collez manuellement un %@ à partir d'une session de navigateur."; + +"Paste a Cookie header captured from %@." = "Collez un en-tête de cookie capturé à partir de %@."; + +"Paste a Cookie header from %@." = "Collez un en-tête de cookie à partir de %@."; + +"Paste a Cookie header or cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL à partir de %@."; + +"Paste a Cookie header or full cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL complète à partir de %@."; + +"Paste a Cookie or Authorization header from %@." = "Collez un en-tête de cookie ou d'autorisation à partir de %@."; + +"Paste a full cookie header or the %@ value." = "Collez un en-tête de cookie complet ou la valeur %@."; + +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Collez un en-tête de cookie ou une capture cURL complète à partir des paramètres de T3 Chat."; + +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai. Doit contenir un cookie ory_session_*."; + +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Collez le jeton Oasis à partir d'une session de navigateur connectée sur platform.stepfun.com."; + +"Paste the %@ JSON bundle from %@." = "Collez le bundle JSON %@ de %@."; + +"Paste the %@ value or a full Cookie header." = "Collez la valeur %@ ou un en-tête de cookie complet."; + +"Personal account" = "Compte personnel"; + +"Project ID" = "ID du projet"; + +"Re-auth" = "Se reconnecter"; + +"Re-authenticating…" = "Réauthentification…"; + +"Refresh Session" = "Session de rafraîchissement"; + +"Refresh organizations" = "Actualiser les organisations"; + +"Region" = "Région"; + +"Reload" = "Recharger"; + +"Reorder" = "Réorganiser"; + +"Secret access key" = "Clé d'accès secrète"; + +"Series" = "Séries"; + +"Service" = "Service"; + +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Affichez ou masquez les crédits Kiro, le pourcentage ou les deux à côté de l'icône de la barre de menu."; + +"Show usage for organizations you belong to. Personal account is always shown." = "Afficher l'utilisation des organisations auxquelles vous appartenez. Le compte personnel est toujours affiché."; + +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Connectez-vous à cursor.com dans votre navigateur, puis actualisez Cursor dans QuotaKit."; + +"Simulated error text" = "Texte d'erreur simulé"; + +"StepFun platform account (phone number or email)." = "Compte de la plateforme StepFun (numéro de téléphone ou email)."; + +"Stored in ~/.codexbar/config.json." = "Stocké dans ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Stocké dans ~/.quotakit/config.json. AZURE_OPENAI_API_KEY est également pris en charge."; + +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Stocké dans ~/.quotakit/config.json. Pour l'API Kimi officielle, utilisez l'API Moonshot / Kimi."; + +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Stocké dans ~/.quotakit/config.json. Obtenez votre clé API depuis la console Volcengine Ark."; + +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Stocké dans ~/.quotakit/config.json. Obtenez votre clé dans les paramètres d'Ollama."; + +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Stocké dans ~/.quotakit/config.json. Obtenez votre clé sur console.deepgram.com."; + +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Stocké dans ~/.quotakit/config.json. Obtenez votre clé sur Elevenlabs.io/app/settings/api-keys."; + +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Stocké dans ~/.quotakit/config.json. Obtenez votre clé sur openrouter.ai/settings/keys et définissez-y une limite de dépenses pour activer le suivi des quotas de clés API."; + +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Stocké dans ~/.quotakit/config.json. Dans Warp, ouvrez Paramètres > Plateforme > Clés API, puis créez-en une."; + +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Stocké dans ~/.quotakit/config.json. Les métriques nécessitent un accès à Groq Enterprise Prometheus."; + +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Stocké dans ~/.quotakit/config.json. OPENAI_ADMIN_KEY est préféré ; OPENAI_API_KEY fonctionne toujours."; + +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Stocké dans ~/.quotakit/config.json. Nécessite une clé API Anthropic Admin."; + +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Stocké dans ~/.quotakit/config.json. Utilisé pour /v1/quota-stats."; + +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Stocké dans ~/.quotakit/config.json. Vous pouvez également fournir CODEBUFF_API_KEY ou laisser QuotaKit lire ~/.config/manicode/credentials.json (créé par `codebuff login`)."; + +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stocké dans ~/.quotakit/config.json. Vous pouvez également fournir CROF_API_KEY."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stocké dans ~/.quotakit/config.json. Vous pouvez également fournir KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; + +"T3 Chat cookie" = "Cookie de discussion T3"; + +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Ce compte n'est plus disponible dans QuotaKit. Actualisez la liste des comptes et réessayez."; + +"The browser login did not complete in time. Try Antigravity login again." = "La connexion au navigateur ne s'est pas terminée à temps. Essayez à nouveau de vous connecter à Antigravity."; + +"Timed out waiting for Cursor login. %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@"; + +"Timed out waiting for Cursor login. %@ Last error: %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@ Dernière erreur : %@"; + +"Today requests" = "Demandes d'aujourd'hui"; + +"Total (30d): %@ credits" = "Total (30j) : %@ crédits"; + +"Username" = "Nom d’utilisateur"; + +"Uses username + password to login and obtain an Oasis-Token automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un jeton Oasis."; + +"Uses username + password to login and obtain an %@ automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un %@."; + +"Utilization End" = "Fin d'utilisation"; + +"Utilization Start" = "Début de l'utilisation"; + +"Verbosity" = "Niveau de verbosité"; + +"Windsurf session JSON bundle" = "Pack JSON de session de planche à voile"; + +"Workspace ID" = "ID de l'espace de travail"; + +"Your StepFun platform password. Used to login and obtain a session token." = "Votre mot de passe de la plateforme StepFun. Utilisé pour se connecter et obtenir un jeton de session."; + +"claude /login exited with status %d." = "claude /login est sorti avec le statut %d."; + +"codex login exited with status %d." = "La connexion à Codex s'est terminée avec le statut %d."; + +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : …\n\nou collez une capture cURL à partir du tableau de bord Abacus AI"; + +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie : …\n\nou collez la valeur __Secure-next-auth.session-token"; + +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie : …\n\nou collez la valeur du jeton kimi-auth"; + +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou collez uniquement la valeur session_id"; + +"Clear" = "Effacer"; + +"No matching providers" = "Aucun fournisseur correspondant"; + +"Search providers" = "Fournisseurs de recherche"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings new file mode 100644 index 000000000..79affff89 --- /dev/null +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,2097 @@ +/* Dutch localization for CodexBar */ + +" providers" = " providers"; + +"(System)" = "(Systeem)"; + +"30d" = "30d"; + +"A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; + +"API key" = "API-sleutel"; + +"API region" = "API-regio"; + +"API token" = "API-token"; + +"API tokens" = "API-tokens"; + +"About" = "Over"; + +"Account" = "Account"; + +"Accounts" = "Accounts"; + +"Accounts subtitle" = "Accounts"; + +"Active" = "Actief"; + +"Add" = "Toevoegen"; + +"Add Workspace" = "Werkruimte toevoegen"; + +"Advanced" = "Geavanceerd"; + +"All" = "Alle"; + +"Always allow prompts" = "Sta altijd aanwijzingen toe"; + +"Animation pattern" = "Animatie patroon"; + +"Antigravity login is managed in the app" = "Antigravity-login wordt beheerd in de app"; + +"Applies only to the Security.framework OAuth keychain reader." = "Geldt alleen voor de Security.framework OAuth-sleutelhangerlezer."; + +"Auto falls back to the next source if the preferred one fails." = "Auto valt terug naar de volgende bron als de voorkeursbron uitvalt."; + +"Auto uses API first, then falls back to CLI on auth failures." = "Auto gebruikt eerst de API en valt vervolgens terug op CLI bij auth-mislukkingen."; + +"Auto-detect" = "Automatische detectie"; + +"Auto-refresh is off; use the menu's Refresh command." = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; + +"Auto-refresh: hourly · Timeout: 10m" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; + +"Automatic" = "Automatisch"; + +"Automatic imports browser cookies and WorkOS tokens." = "Importeert automatisch browsercookies en WorkOS-tokens."; + +"Automatic imports browser cookies and local storage tokens." = "Importeert automatisch browsercookies en lokale opslagtokens."; + +"Automatic imports browser cookies for dashboard extras." = "Importeert automatisch browsercookies voor dashboardextra's."; + +"Automatic imports browser cookies for the web API." = "Importeert automatisch browsercookies voor de web-API."; + +"Automatic imports browser cookies from Model Studio/Bailian." = "Importeert automatisch browsercookies van Model Studio/Bailian."; + +"Automatic imports browser cookies from admin.mistral.ai." = "Importeert automatisch browsercookies van admin.mistral.ai."; + +"Automatic imports browser cookies from opencode.ai." = "Importeert automatisch browsercookies van opencode.ai."; + +"Automatic imports browser cookies or stored sessions." = "Importeert automatisch browsercookies of opgeslagen sessies."; + +"Automatic imports browser cookies." = "Automatische import van browsercookies."; + +"Automatically imports browser session cookie." = "Importeert automatisch een browsersessiecookie."; + +"Automatically opens CodexBar when you start your Mac." = "Opent automatisch QuotaKit wanneer u uw Mac start."; + +"Automation" = "Automatisering"; + +"Average (\\(label1) + \\(label2))" = "Gemiddeld (\\(label1) + \\(label2))"; + +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Gemiddeld (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + +"Avoid Keychain prompts" = "Vermijd sleutelhangerprompts"; + +"Balance" = "Saldo"; + +"Battery Saver" = "Batterijbesparing"; + +"Bordered" = "Omzoomd"; + +"Build" = "Bouwen"; + +"Built \\(buildTimestamp)" = "Gebouwd \\(buildTimestamp)"; + +"Buy Credits..." = "Koop tegoeden..."; + +"Buy Credits…" = "Koop tegoeden…"; + +"CLI paths" = "CLI-paden"; + +"CLI sessions" = "CLI-sessies"; + +"Caches" = "Caches"; + +"Cancel" = "Annuleren"; + +"Check for Updates…" = "Controleer op updates…"; + +"Check for updates automatically" = "Automatisch controleren op updates"; + +"Check if you like your agents having some fun up there." = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; + +"Check provider status" = "Controleer de status van de provider"; + +"Choose Codex workspace" = "Kies Codex-werkruimte"; + +"Choose the MiniMax host (global .io or China mainland .com)." = "Kies de MiniMax-host (global .io of China vasteland .com)."; + +"Choose up to " = "Kies tot"; + +"Choose up to \\(Self.maxOverviewProviders) providers" = "Kies maximaal \\(Self.maxOverviewProviders) providers"; + +"Choose up to \\(count) providers" = "Kies maximaal \\(count) providers"; + +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; + +"Choose which Codex account CodexBar should follow." = "Kies welk Codex-account QuotaKit moet volgen."; + +"Choose which window drives the menu bar percent." = "Kies welk venster het menubalkpercentage aanstuurt."; + +"Chrome" = "Chrome"; + +"Claude CLI not found" = "Claude CLI niet gevonden"; + +"Claude binary" = "Claude binair"; + +"Claude cookies" = "Claude-koekjes"; + +"Claude login failed" = "Inloggen bij Claude is mislukt"; + +"Claude login timed out" = "Er is een time-out opgetreden bij het inloggen bij Claude"; + +"Close" = "Sluiten"; + +"Code review" = "Code review"; + +"Codex CLI not found" = "Codex-CLI niet gevonden"; + +"Codex account login already running" = "Inloggen op Codex-account is al actief"; + +"Codex binary" = "Codex binair"; + +"Codex login failed" = "Codex-aanmelding mislukt"; + +"Codex login timed out" = "Er is een time-out opgetreden bij het inloggen op de Codex"; + +"CodexBar Lifecycle Keepalive" = "QuotaKit-levenscyclus Keepalive"; + +"CodexBar can't show its menu bar icon" = "QuotaKit kan het menubalkpictogram niet weergeven"; + +"CodexBar could not read managed account storage. " = "QuotaKit kan de beheerde accountopslag niet lezen."; + +"Configure…" = "Configureer…"; + +"Connected" = "Aangesloten"; + +"Controls how much detail is logged." = "Bepaalt hoeveel details worden geregistreerd."; + +"Cookie header" = "Cookie-header"; + +"Cookie source" = "Cookie-bron"; + +"Cookie: ..." = "Koekje: ..."; + +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; + +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nof plak de __Secure-next-auth.session-token-waarde"; + +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nof plak de kimi-auth tokenwaarde"; + +"Cookie: …" = "Cookie: …"; + +"CopilotDeviceFlow" = "CopilotDeviceFlow"; + +"Cost" = "Kosten"; + +"Could not add Codex account" = "Kan Codex-account niet toevoegen"; + +"Could not open Terminal for Gemini" = "Kan Terminal voor Gemini niet openen"; + +"Could not start claude /login" = "Kan claude /login niet starten"; + +"Could not start codex login" = "Kan codex-aanmelding niet starten"; + +"Could not switch system account" = "Kan van systeemaccount niet wisselen"; + +"Credits" = "Kredieten"; + +"Credits history" = "Creditgeschiedenis"; + +"Cursor login failed" = "Cursoraanmelding mislukt"; + +"Custom" = "Aangepast"; + +"Custom Path" = "Aangepast pad"; + +"Daily Routines" = "Dagelijkse routines"; + +"Debug" = "Foutopsporing"; + +"Default" = "Standaard"; + +"Disable Keychain access" = "Schakel sleutelhangertoegang uit"; + +"Disabled" = "Uitgeschakeld"; + +"Dismiss" = "Afwijzen"; + +"Disconnected" = "Verbinding verbroken"; + +"Display" = "Weergave"; + +"Display mode" = "Weergavemodus"; + +"Display reset times as absolute clock values instead of countdowns." = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; + +"Done" = "Klaar"; + +"Effective PATH" = "Effectief PAD"; + +"Email" = "E-mail"; + +"Enable Merge Icons to configure Overview tab providers." = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; + +"Enable file logging" = "Bestandsregistratie inschakelen"; + +"Enabled" = "Ingeschakeld"; + +"Error" = "Fout"; + +"Error simulation" = "Foutsimulatie"; + +"Expose troubleshooting tools in the Debug tab." = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; + +"Failed" = "Mislukt"; + +"False" = "Onwaar"; + +"Fetch strategy attempts" = "Strategiepogingen ophalen"; + +"Fetching" = "Ophalen"; + +"Field" = "Veld"; + +"Field subtitle" = "Ondertitel van veld"; + +"Finish the current managed account change before switching the system account." = "Voltooi de huidige beheerde accountwijziging voordat u van systeemaccount wisselt."; + +"Force animation on next refresh" = "Animatie forceren bij volgende vernieuwing"; + +"Gateway region" = "Gateway-regio"; + +"Gemini CLI not found" = "Gemini-CLI niet gevonden"; + +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, incidenten verschijnen in het pictogram en het menu."; + +"General" = "Algemeen"; + +"GitHub" = "GitHub"; + +"GitHub Copilot Login" = "GitHub Copilot-aanmelding"; + +"GitHub Login" = "GitHub-aanmelding"; + +"Hide details" = "Details verbergen"; + +"Hide personal information" = "Verberg persoonlijke informatie"; + +"Historical tracking" = "Historische tracking"; + +"How often CodexBar polls providers in the background." = "Hoe vaak QuotaKit providers op de achtergrond ondervraagt."; + +"Inactive" = "Inactief"; + +"Install CLI" = "Installeer CLI"; + +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installeer de Claude CLI (npm i -g @anthropic-ai/claude-code) en probeer het opnieuw."; + +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installeer de Codex CLI (npm i -g @openai/codex) en probeer het opnieuw."; + +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installeer de Gemini CLI (npm i -g @google/gemini-cli) en probeer het opnieuw."; + +"JetBrains AI is ready" = "JetBrains AI is klaar"; + +"JetBrains IDE" = "JetBrains IDE"; + +"Keep CLI sessions alive" = "Houd CLI-sessies levend"; + +"Keyboard shortcut" = "Sneltoets"; + +"Keychain access" = "Toegang via sleutelhanger"; + +"Keychain prompt policy" = "Sleutelhangerpromptbeleid"; + +"Last \\(name) fetch failed:" = "Laatste \\(name) ophalen mislukt:"; + +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Laatste ophalen van \\(self.store.metadata(for: self.provider).displayName) mislukt:"; + +"Last attempt" = "Laatste poging"; + +"Link" = "Link"; + +"Loading animations" = "Animaties laden"; + +"Loading…" = "Laden…"; + +"Local" = "Lokaal"; + +"Logging" = "Loggen"; + +"Login failed" = "Inloggen mislukt"; + +"Login shell PATH (startup capture)" = "Login shell PATH (opstartopname)"; + +"Login timed out" = "Er is een time-out opgetreden voor het inloggen"; + +"MCP details" = "MCP-details"; + +"Managed Codex accounts unavailable" = "Beheerde Codex-accounts zijn niet beschikbaar"; + +"Managed account storage is unreadable. Live account access is still available, " = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar,"; + +"Manual" = "Handmatig"; + +"May your tokens never run out—keep agent limits in view." = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; + +"Menu bar" = "Menubalk"; + +"Menu bar auto-shows the provider closest to its rate limit." = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; + +"Menu bar metric" = "Menubalkstatistiek"; + +"Menu bar shows percent" = "Menubalk toont percentage"; + +"Menu content" = "Menu-inhoud"; + +"Merge Icons" = "Pictogrammen samenvoegen"; + +"Never prompt" = "Nooit vragen"; + +"No" = "Nee"; + +"No Codex accounts detected yet." = "Er zijn nog geen Codex-accounts gedetecteerd."; + +"No JetBrains IDE detected" = "Geen JetBrains IDE gedetecteerd"; + +"No cost history data." = "Geen kostengeschiedenisgegevens."; + +"No data available" = "Geen gegevens beschikbaar"; + +"No data yet" = "Nog geen gegevens"; + +"No enabled providers available for Overview." = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; + +"No providers selected" = "Geen aanbieders geselecteerd"; + +"No token accounts yet." = "Nog geen tokenaccounts."; + +"No usage breakdown data." = "Geen gebruiksgegevens."; + +"None" = "Geen"; + +"Notifications" = "Meldingen"; + +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer dit wordt bereikt"; + +"OK" = "OK"; + +"Obscure email addresses in the menu bar and menu UI." = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; + +"Off" = "Uit"; + +"Offline" = "Offline"; + +"On" = "Op"; + +"Online" = "Online"; + +"Only on user action" = "Alleen bij gebruikersactie"; + +"Open" = "Open"; + +"Open API Keys" = "Open API-sleutels"; + +"Open Amp Settings" = "Open Versterkerinstellingen"; + +"Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity om in te loggen en vernieuw vervolgens QuotaKit."; + +"Open Browser" = "Browser openen"; + +"Open Coding Plan" = "Coderingsplan openen"; + +"Open Console" = "Console openen"; + +"Open Dashboard" = "Dashboard openen"; + +"Open Mistral Admin" = "Open Mistral-beheer"; + +"Open Menu Bar Settings" = "Open Menubalkinstellingen"; + +"Open Ollama Settings" = "Open Ollama-instellingen"; + +"Open Terminal" = "Terminal openen"; + +"Open Usage Page" = "Gebruikspagina openen"; + +"Open Warp API Key Guide" = "Open Warp API-sleutelgids"; + +"Open menu" = "Menu openen"; + +"Open token file" = "Tokenbestand openen"; + +"OpenAI cookies" = "OpenAI-cookies"; + +"OpenAI web extras" = "OpenAI-webextra's"; + +"Option A" = "Optie A"; + +"Option B" = "Optie B"; + +"Optional override if workspace lookup fails." = "Optioneel overschrijven als het opzoeken van de werkruimte mislukt."; + +"Options" = "Opties"; + +"Override auto-detection with a custom IDE base path" = "Overschrijf automatische detectie met een aangepast IDE-basispad"; + +"Overview" = "Overzicht"; + +"Overview rows always follow provider order." = "Overzichtsrijen volgen altijd de volgorde van de provider."; + +"Overview tab providers" = "Overzicht tabblad aanbieders"; + +"Paste API key…" = "API-sleutel plakken…"; + +"Paste API token…" = "API-token plakken…"; + +"Paste key…" = "Sleutel plakken…"; + +"Paste sessionKey or OAuth token…" = "SessionKey of OAuth-token plakken..."; + +"Paste the Cookie header from a request to admin.mistral.ai. " = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai."; + +"Paste token…" = "Token plakken..."; + +"Personal" = "Persoonlijk"; + +"Picker" = "Kikker"; + +"Picker subtitle" = "Ondertitel kiezen"; + +"Placeholder" = "Tijdelijke aanduiding"; + +"Plan" = "Plan"; + +"Play full-screen confetti when weekly usage resets." = "Speel confetti op volledig scherm af wanneer het wekelijkse gebruik wordt gereset."; + +"Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude-statuspagina's en Google Workspace voor"; + +"Prevents any Keychain access while enabled." = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; + +"Primary (API key limit)" = "Primair (API-sleutellimiet)"; + +"Primary (\\(label))" = "Primair (\\(label))"; + +"Primary (\\(metadata.sessionLabel))" = "Primair (\\(metadata.sessionLabel))"; + +"Probe logs" = "Sondelogboeken"; + +"Progress bars fill as you consume quota (instead of showing remaining)." = "Voortgangsbalken worden gevuld naarmate u uw quotum verbruikt (in plaats van de resterende hoeveelheid weer te geven)."; + +"Provider" = "Aanbieder"; + +"Providers" = "Providers"; + +"Quit CodexBar" = "Sluit QuotaKit af"; + +"Random (default)" = "Willekeurig (standaard)"; + +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; + +"Refresh" = "Vernieuwen"; + +"Refresh cadence" = "Cadans vernieuwen"; + +"Remote" = "Op afstand"; + +"Remove" = "Verwijderen"; + +"Remove Codex account?" = "Codex-account verwijderen?"; + +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) verwijderen uit QuotaKit? Het beheerde Codex-huis wordt verwijderd."; + +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) verwijderen uit QuotaKit? Het beheerde Codex-huis wordt verwijderd."; + +"Remove selected account" = "Geselecteerd account verwijderen"; + +"Replace critter bars with provider branding icons and a percentage." = "Vervang critterbalken door brandingpictogrammen van de provider en een percentage."; + +"Replay selected animation" = "Speel de geselecteerde animatie opnieuw af"; + +"Requires authentication via GitHub Device Flow." = "Vereist authenticatie via GitHub Device Flow."; + +"Resets: \\(reset)" = "Resetten: \\(reset)"; + +"Rolling five-hour limit" = "Doorlopende limiet van vijf uur"; + +"Search hourly" = "Zoek per uur"; + +"Secondary (\\(label))" = "Secundair (\\(label))"; + +"Secondary (\\(metadata.weeklyLabel))" = "Secundair (\\(metadata.weeklyLabel))"; + +"Select a provider" = "Selecteer een aanbieder"; + +"Select the IDE to monitor" = "Selecteer de IDE die u wilt monitoren"; + +"Session quota notifications" = "Meldingen over sessiequota"; + +"Session tokens" = "Sessietokens"; + +"Settings" = "Instellingen"; + +"Show Codex Credits and Claude Extra usage sections in the menu." = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; + +"Show Debug Settings" = "Toon foutopsporingsinstellingen"; + +"Show all token accounts" = "Toon alle tokenaccounts"; + +"Show cost summary" = "Kostenoverzicht weergeven"; + +"Show credits + extra usage" = "Toon credits + extra gebruik"; + +"Show details" = "Details weergeven"; + +"Show most-used provider" = "Toon meest gebruikte provider"; + +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Toon providerpictogrammen in de switcher (toon anders een wekelijkse voortgangslijn)."; + +"Show reset time as clock" = "Toon resettijd als klok"; + +"Show usage as used" = "Toon gebruik zoals gebruikt"; + +"Sign in via button below" = "Meld u aan via onderstaande knop"; + +"Skip teardown between probes (debug-only)." = "Sla demontage tussen tests over (alleen voor foutopsporing)."; + +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapel token-accounts in het menu (laat anders een accountwisselbalk zien)."; + +"Start at Login" = "Begin bij Inloggen"; + +"Status" = "Status"; + +"Store Claude sessionKey cookies or OAuth access tokens." = "Bewaar Claude sessionKey-cookies of OAuth-toegangstokens."; + +"Store multiple Abacus AI Cookie headers." = "Bewaar meerdere Abacus AI Cookie-headers."; + +"Store multiple Augment Cookie headers." = "Bewaar meerdere Augment Cookie-headers."; + +"Store multiple Cursor Cookie headers." = "Bewaar meerdere Cursor Cookie-headers."; + +"Store multiple Factory Cookie headers." = "Bewaar meerdere Factory Cookie-headers."; + +"Store multiple MiniMax Cookie headers." = "Bewaar meerdere MiniMax Cookie-headers."; + +"Store multiple Mistral Cookie headers." = "Bewaar meerdere Mistral Cookie-headers."; + +"Store multiple Ollama Cookie headers." = "Bewaar meerdere Ollama Cookie-headers."; + +"Store multiple OpenCode Cookie headers." = "Bewaar meerdere OpenCode Cookie-headers."; + +"Store multiple OpenCode Go Cookie headers." = "Bewaar meerdere OpenCode Go Cookie-headers."; + +"Stored in the CodexBar config file." = "Opgeslagen in het QuotaKit-configuratiebestand."; + +"Stored in ~/.codexbar/config.json. " = "Opgeslagen in ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Opgeslagen in ~/.quotakit/config.json. Genereer er een op kimi-k2.ai."; + +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Opgeslagen in ~/.quotakit/config.json. Plak de sleutel uit het synthetische dashboard."; + +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Opgeslagen in ~/.quotakit/config.json. Plak de API-sleutel van uw codeerplan uit Model Studio."; + +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Opgeslagen in ~/.quotakit/config.json. Plak uw MiniMax API-sleutel."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Opgeslagen in ~/.quotakit/config.json. U kunt ook KILO_API_KEY of"; + +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Slaat de lokale Codex-gebruiksgeschiedenis op (8 weken) om tempo-voorspellingen te personaliseren."; + +"Subscription Utilization" = "Abonnementsgebruik"; + +"Surprise me" = "Verras mij"; + +"Switcher shows icons" = "Switcher toont pictogrammen"; + +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink QuotaKitCLI naar /usr/local/bin en /opt/homebrew/bin als quotakit."; + +"System" = "Systeem"; + +"Temporarily shows the loading animation after the next refresh." = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; + +"Tertiary (\\(label))" = "Tertiair (\\(label))"; + +"Tertiary (\\(tertiaryTitle))" = "Tertiair (\\(tertiaryTitle))"; + +"The default Codex account on this Mac." = "Het standaard Codex-account op deze Mac."; + +"Toggle" = "Schakelaar"; + +"Toggle subtitle" = "Schakel ondertiteling in"; + +"Token" = "Token"; + +"Trigger the menu bar menu from anywhere." = "Activeer het menubalkmenu vanaf elke locatie."; + +"True" = "WAAR"; + +"Twitter" = "Twitteren"; + +"Unsupported" = "Niet ondersteund"; + +"Update Channel" = "Kanaal bijwerken"; + +"Updated" = "Bijgewerkt"; + +"Updates unavailable in this build." = "Updates zijn niet beschikbaar in deze build."; + +"Usage" = "Gebruik"; + +"Usage breakdown" = "Uitsplitsing van gebruik"; + +"Usage history (30 days)" = "Gebruiksgeschiedenis"; + +"Usage source" = "Gebruiksbron"; + +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Gebruik BigModel voor de eindpunten op het vasteland van China (open.bigmodel.cn)."; + +"Use a single menu bar icon with a provider switcher." = "Gebruik één menubalkpictogram met een providerwisselaar."; + +"Use international or China mainland console gateways for quota fetches." = "Gebruik internationale of Chinese consolegateways voor het ophalen van quota."; + +"Version" = "Versie"; + +"Version \\(self.versionString)" = "Versie \\(self.versionString)"; + +"Version \\(version)" = "Versie \\(version)"; + +"Version \\(versionString)" = "Versie \\(versionString)"; + +"Vertex AI Login" = "Vertex AI-login"; + +"Wait for the current managed Codex login to finish before adding another account." = "Wacht tot de huidige beheerde Codex-aanmelding is voltooid voordat u een ander account toevoegt."; + +"Waiting for Authentication..." = "Wachten op authenticatie..."; + +"Website" = "Website"; + +"Weekly limit confetti" = "Wekelijkse limiet confetti"; + +"Weekly token limit" = "Wekelijkse tokenlimiet"; + +"Weekly usage" = "Wekelijks gebruik"; + +"Weekly usage unavailable for this account." = "Wekelijks gebruik is niet beschikbaar voor dit account."; + +"Window: \\(window)" = "Venster: \\(window)"; + +"Write logs to \\(self.fileLogPath) for debugging." = "Schrijf logboeken naar \\(self.fileLogPath) voor foutopsporing."; + +"Yes" = "Ja"; + +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; + +"\\(name): fetching…\\(elapsed)" = "\\(name): ophalen…\\(elapsed)"; + +"\\(name): last attempt \\(when)" = "\\(name): laatste poging \\(when)"; + +"\\(name): no data yet" = "\\(name): nog geen gegevens"; + +"\\(name): unsupported" = "\\(name): niet ondersteund"; + +"all browsers" = "alle browsers"; + +"available again." = "weer beschikbaar."; + +"built_format" = "Gebouwd %@"; + +"copilot_complete_in_browser" = "Voltooi het inloggen in uw browser."; + +"copilot_device_code" = "Apparaatcode gekopieerd naar klembord: %1$@\n\nVerifiëren op: %2$@"; + +"copilot_device_code_copied" = "Apparaatcode gekopieerd."; + +"copilot_verify_at" = "Verifiëren op %@"; + +"copilot_waiting_text" = "Voltooi het inloggen in uw browser.\nDit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; + +"copilot_window_closes_auto" = "Dit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; + +"cost_status_error" = "%1$@: %2$@"; + +"cost_status_fetching" = "%1$@: ophalen… %2$@"; + +"cost_status_last_attempt" = "%1$@: laatste poging %2$@"; + +"cost_status_no_data" = "%@: nog geen gegevens"; + +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + +"cost_status_unsupported" = "%@: niet ondersteund"; + +"credits_remaining" = "Tegoeden: %@"; + +"cursor_on_demand" = "Op aanvraag: %@"; + +"cursor_on_demand_with_limit" = "Op aanvraag: %1$@ / %2$@"; + +"extra_usage_format" = "Extra verbruik: %1$@ / %2$@"; + +"jetbrains_detected_generate" = "Gedetecteerd: %@. Gebruik de AI-assistent één keer om quotagegevens te genereren en vernieuw vervolgens QuotaKit."; + +"jetbrains_detected_select" = "Gedetecteerd: %@. Selecteer uw favoriete IDE in Instellingen en vernieuw vervolgens QuotaKit."; + +"last_fetch_failed_with_provider" = "Laatste %@ ophaalactie mislukt:"; + +"last_spend" = "Laatste uitgave: %@"; + +"mcp_model_usage" = "%1$@: %2$@"; + +"mcp_resets" = "Resetten: %@"; + +"mcp_window" = "Venster: %@"; + +"metric_average" = "Gemiddeld (%1$@ + %2$@)"; + +"metric_primary" = "Primair (%@)"; + +"metric_secondary" = "Secundair (%@)"; + +"metric_tertiary" = "Tertiair (%@)"; + +"multiple_workspaces_found" = "QuotaKit heeft meerdere werkruimten gevonden voor %@. Kies de werkruimte die u wilt toevoegen."; + +"ory_session_…=…; csrftoken=…" = "ory_sessie_…=…; csrftoken=…"; + +"overview_choose_providers" = "Kies maximaal %@ providers"; + +"remove_account_message" = "%@ verwijderen uit QuotaKit? Het beheerde Codex-huis wordt verwijderd."; + +"version_format" = "Versie %@"; + +"vertex_ai_login_instructions" = "Om het gebruik van Vertex AI bij te houden, authenticeert u zich met Google Cloud.\n\n1. Open Terminal\n2. Uitvoeren: gcloud auth applicatie-standaard login\n3. Volg de browserprompts om in te loggen\n4. Stel uw project in: gcloud config set project PROJECT_ID\n\nTerminal nu openen?"; + +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID is ingesteld, maar alleen opencode, opencodego en deepgram ondersteunen workspaceID."; + +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-licentie."; + + +/* General Pane */ +"section_system" = "Systeem"; + +"section_usage" = "Gebruik"; + +"section_automation" = "Automatisering"; + +"language_title" = "Taal"; + +"language_subtitle" = "Wijzig de weergavetaal. Vereist een herstart van de app om volledig effect te krijgen."; + +"language_system" = "Systeem"; + +"language_english" = "Engels"; + +"language_spanish" = "Spaans"; + +"language_catalan" = "Català"; + +"language_chinese_simplified" = "简体中文"; + +"language_chinese_traditional" = "繁體中文"; + +"language_portuguese_brazilian" = "Portugees (Brazilië)"; + +"language_dutch" = "Nederlands"; + +"language_french" = "Frans"; + +"language_ukrainian" = "Oekraïens"; + +"language_swedish" = "Zweeds"; + +"language_vietnamese" = "Vietnamees"; + +"start_at_login_title" = "Begin bij Inloggen"; + +"start_at_login_subtitle" = "Opent automatisch QuotaKit wanneer u uw Mac start."; + +"show_cost_summary" = "Kostenoverzicht weergeven"; + +"show_cost_summary_subtitle" = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; + +"cost_history_days_title" = "Geschiedenisvenster: %d dagen"; + +"cost_auto_refresh_info" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; + +"refresh_cadence_title" = "Cadans vernieuwen"; + +"refresh_cadence_subtitle" = "Hoe vaak QuotaKit providers op de achtergrond ondervraagt."; + +"manual_refresh_hint" = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; + +"check_provider_status_title" = "Controleer de status van de provider"; + +"check_provider_status_subtitle" = "Polls van OpenAI/Claude-statuspagina's en Google Workspace voor Gemini/Antigravity, waarbij incidenten in het pictogram en het menu worden weergegeven."; + +"session_quota_notifications_title" = "Meldingen over sessiequota"; + +"session_quota_notifications_subtitle" = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer het weer beschikbaar komt."; + +"quota_warning_notifications_title" = "Quotumwaarschuwingsmeldingen"; + +"quota_warning_notifications_subtitle" = "Waarschuwt wanneer het resterende sessie- of wekelijkse quotum de geconfigureerde drempels overschrijdt."; + +"quota_warnings_title" = "Quotumwaarschuwingen"; + +"quota_warning_session" = "sessie"; + +"quota_warning_session_capitalized" = "Sessie"; + +"quota_warning_weekly" = "wekelijks"; + +"quota_warning_weekly_capitalized" = "Wekelijks"; + +"quota_warning_notification_title" = "%1$@ %2$@ quotum laag"; + +"quota_warning_notification_body" = "%1$@ over. Je waarschuwingsdrempel van %2$d%% %3$@ is bereikt."; + +"quota_warning_notification_body_with_account" = "Rekening %1$@. %2$@ over. Je waarschuwingsdrempel van %3$d%% %4$@ is bereikt."; + +"session_depleted_notification_title" = "%@ sessie uitgeput"; + +"session_depleted_notification_body" = "0% over. Zal op de hoogte stellen wanneer het weer beschikbaar is."; + +"session_restored_notification_title" = "%@ sessie hersteld"; + +"session_restored_notification_body" = "Sessiequota zijn weer beschikbaar."; + +"quota_warning_warn_at" = "Waarschuw bij"; + +"quota_warning_global_threshold_subtitle" = "Resterende percentages voor sessie- en wekelijkse vensters, tenzij een provider deze overschrijft."; + +"quota_warning_sound" = "Meldingsgeluid afspelen"; + +"quota_warning_provider_inherits" = "Gebruikt de algemene instellingen voor quotawaarschuwingen, tenzij hier een venster wordt aangepast."; + +"quota_warning_customize_thresholds" = "Pas %@ drempels aan"; + +"quota_warning_enable_warnings" = "Schakel %@ waarschuwingen in"; + +"quota_warning_window_warn_at" = "%@ waarschuwen om"; + +"quota_warning_off" = "Uit"; + +"quota_warning_inherited" = "Geërfd: %@"; + +"quota_warning_depleted_only" = "alleen maar uitgeput"; + +"quota_warning_upper" = "Bovenste"; + +"quota_warning_lower" = "Lager"; + +"apply" = "Toepassen"; + +"quit_app" = "Sluit QuotaKit af"; + + +/* Tab titles */ +"tab_general" = "Algemeen"; + +"tab_providers" = "Aanbieders"; + +"tab_display" = "Weergave"; + +"tab_advanced" = "Geavanceerd"; + +"tab_about" = "Over"; + +"tab_debug" = "Foutopsporing"; + + +/* Providers Pane */ +"select_a_provider" = "Selecteer een aanbieder"; + +"cancel" = "Annuleren"; + +"last_fetch_failed" = "laatste ophaalactie mislukt"; + +"usage_not_fetched_yet" = "gebruik nog niet opgehaald"; + +"managed_account_storage_unreadable" = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar, maar beheerde acties voor toevoegen, opnieuw verifiëren en verwijderen zijn uitgeschakeld totdat de winkel kan worden hersteld."; + +"remove_codex_account_title" = "Codex-account verwijderen?"; + +"remove" = "Verwijderen"; + +"managed_login_already_running" = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat u een ander account toevoegt of opnieuw verifieert."; + +"managed_login_failed" = "Beheerde Codex-aanmelding is niet voltooid. Controleer of `codex --version` werkt in Terminal. Als macOS `codex` naar de prullenbak heeft geblokkeerd of verplaatst, verwijdert u verouderde dubbele installaties, voert u `npm install -g --include=optioneel @openai/codex@latest` uit en probeert u het vervolgens opnieuw."; + +"codex_login_output" = "codex login-uitvoer:"; + +"managed_login_missing_email" = "Codex-aanmelding voltooid, maar er was geen account-e-mailadres beschikbaar. Probeer het opnieuw nadat u heeft bevestigd dat het account volledig is aangemeld."; + +"login_success_notification_title" = "%@ inloggen succesvol"; + +"login_success_notification_body" = "U kunt terugkeren naar de app; authenticatie voltooid."; + +"workspace_selection_cancelled" = "QuotaKit heeft meerdere werkruimten gevonden, maar er is geen werkruimte geselecteerd."; + +"unsafe_managed_home" = "QuotaKit weigerde een onverwacht beheerd thuispad te wijzigen: %@"; + +"menu_bar_metric_title" = "Menubalkstatistiek"; + +"menu_bar_metric_subtitle" = "Kies welk venster het menubalkpercentage aanstuurt."; + +"menu_bar_metric_subtitle_deepseek" = "Toont het DeepSeek-saldo in de menubalk."; + +"menu_bar_metric_subtitle_moonshot" = "Toont het Moonshot / Kimi API-saldo in de menubalk."; + +"menu_bar_metric_subtitle_mistral" = "Toont de Mistral API-uitgaven van de huidige maand in de menubalk."; + +"menu_bar_metric_subtitle_kimik2" = "Toont Kimi K2 API-sleutelcredits in de menubalk."; + +"automatic" = "Automatisch"; + +"primary_api_key_limit" = "Primair (API-sleutellimiet)"; + + +/* Display Pane */ +"section_menu_bar" = "Menubalk"; + +"merge_icons_title" = "Pictogrammen samenvoegen"; + +"merge_icons_subtitle" = "Gebruik één menubalkpictogram met een providerwisselaar."; + +"switcher_shows_icons_title" = "Switcher toont pictogrammen"; + +"switcher_shows_icons_subtitle" = "Toon providerpictogrammen in de switcher (toon anders een wekelijkse voortgangslijn)."; + +"show_most_used_provider_title" = "Toon meest gebruikte provider"; + +"show_most_used_provider_subtitle" = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; + +"menu_bar_shows_percent_title" = "Menubalk toont percentage"; + +"menu_bar_shows_percent_subtitle" = "Vervang critterbalken door brandingpictogrammen van de provider en een percentage."; + +"display_mode_title" = "Weergavemodus"; + +"display_mode_subtitle" = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; + +"section_menu_content" = "Menu-inhoud"; + +"show_usage_as_used_title" = "Toon gebruik zoals gebruikt"; + +"show_usage_as_used_subtitle" = "Voortgangsbalken worden gevuld naarmate u uw quotum verbruikt (in plaats van de resterende hoeveelheid weer te geven)."; + +"show_quota_warning_markers_title" = "Toon waarschuwingsmarkeringen voor quota"; + +"show_quota_warning_markers_subtitle" = "Teken drempelmarkeringen op gebruiksbalken wanneer quotawaarschuwingen zijn geconfigureerd."; + +"weekly_progress_work_days_title" = "Wekelijkse voortgang werkdagen"; + +"weekly_progress_work_days_subtitle" = "Teken daggrensmarkeringen op de wekelijkse gebruiksbalken."; + +"show_reset_time_as_clock_title" = "Toon resettijd als klok"; + +"show_reset_time_as_clock_subtitle" = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; + +"show_provider_changelog_links_title" = "Toon provider changelog-links"; + +"show_provider_changelog_links_subtitle" = "Voegt koppelingen naar release-opmerkingen voor ondersteunde CLI-ondersteunde providers toe aan het menu."; + +"show_credits_extra_usage_title" = "Toon credits + extra gebruik"; + +"show_credits_extra_usage_subtitle" = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; + +"show_all_token_accounts_title" = "Toon alle tokenaccounts"; + +"show_all_token_accounts_subtitle" = "Stapel token-accounts in het menu (laat anders een accountwisselbalk zien)."; + +"multi_account_layout_title" = "Indeling voor meerdere accounts"; + +"multi_account_layout_subtitle" = "Kies voor gesegmenteerd wisselen tussen accounts of gestapelde accountkaarten."; + +"multi_account_layout_segmented" = "Gesegmenteerd"; + +"multi_account_layout_stacked" = "Gestapeld"; + +"overview_tab_providers_title" = "Overzicht tabblad aanbieders"; + +"configure" = "Configureer…"; + +"overview_enable_merge_icons_hint" = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; + +"overview_no_providers_hint" = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; + +"overview_rows_follow_order" = "Overzichtsrijen volgen altijd de volgorde van de provider."; + +"overview_no_providers_selected" = "Geen aanbieders geselecteerd"; + + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Sneltoets"; + +"open_menu_shortcut_title" = "Menu openen"; + +"open_menu_shortcut_subtitle" = "Activeer het menubalkmenu vanaf elke locatie."; + +"install_cli" = "Installeer CLI"; + +"install_cli_subtitle" = "Symlink QuotaKitCLI naar /usr/local/bin en /opt/homebrew/bin als quotakit."; + +"cli_not_found" = "QuotaKitCLI niet gevonden in appbundel."; + +"no_writable_bin_dirs" = "Geen beschrijfbare mapmap gevonden."; + +"show_debug_settings_title" = "Toon foutopsporingsinstellingen"; + +"show_debug_settings_subtitle" = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; + +"surprise_me_title" = "Verras mij"; + +"surprise_me_subtitle" = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; + +"weekly_limit_confetti_title" = "Wekelijkse limiet confetti"; + +"weekly_limit_confetti_subtitle" = "Speel confetti op volledig scherm af wanneer het wekelijkse gebruik wordt gereset."; + +"hide_personal_info_title" = "Verberg persoonlijke informatie"; + +"hide_personal_info_subtitle" = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; + +"show_provider_storage_usage_title" = "Toon het opslaggebruik van de provider"; + +"show_provider_storage_usage_subtitle" = "Toon lokaal schijfgebruik in menu's. Scant bekende paden van de provider op de achtergrond."; + +"section_keychain_access" = "Toegang via sleutelhanger"; + +"keychain_access_caption" = "Schakel alle lees- en schrijfbewerkingen van de sleutelhanger uit. Gebruik dit als macOS blijft vragen om 'Chrome/Brave/Edge Safe Storage', zelfs nadat u op Altijd toestaan ​​hebt geklikt. Browsercookie-import is niet beschikbaar als deze is ingeschakeld; plak Cookie-headers handmatig in Providers. Claude/Codex OAuth via de CLI werkt nog steeds."; + +"disable_keychain_access_title" = "Schakel sleutelhangertoegang uit"; + +"disable_keychain_access_subtitle" = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; + + +/* About Pane */ +"about_tagline" = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; + +"link_github" = "GitHub"; + +"link_website" = "Website"; + +"link_twitter" = "Twitteren"; + +"link_email" = "E-mail"; + +"check_updates_auto" = "Automatisch controleren op updates"; + +"update_channel" = "Kanaal bijwerken"; + +"check_for_updates" = "Controleer op updates…"; + +"updates_unavailable" = "Updates zijn niet beschikbaar in deze build."; + +"copyright" = "© 2026 Peter Steinberger. MIT-licentie."; + + +/* Debug Pane */ +"section_logging" = "Loggen"; + +"enable_file_logging" = "Bestandsregistratie inschakelen"; + +"enable_file_logging_subtitle" = "Schrijf logboeken naar %@ voor foutopsporing."; + +"verbosity_title" = "Breedsprakigheid"; + +"verbosity_subtitle" = "Bepaalt hoeveel details worden geregistreerd."; + +"open_log_file" = "Logbestand openen"; + +"force_animation_next_refresh" = "Animatie forceren bij volgende vernieuwing"; + +"force_animation_next_refresh_subtitle" = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; + +"section_loading_animations" = "Animaties laden"; + +"loading_animations_caption" = "Kies een patroon en speel het opnieuw af in de menubalk. \"Random\" behoudt het bestaande gedrag."; + +"animation_random_default" = "Willekeurig (standaard)"; + +"replay_selected_animation" = "Speel de geselecteerde animatie opnieuw af"; + +"blink_now" = "Knipper nu"; + +"section_probe_logs" = "Sondelogboeken"; + +"probe_logs_caption" = "Haal de nieuwste testuitvoer op voor foutopsporing; Bij kopiëren blijft de volledige tekst behouden."; + +"fetch_log" = "Logboek ophalen"; + +"copy" = "Kopiëren"; + +"save_to_file" = "Opslaan in bestand"; + +"load_parse_dump" = "Parseerdump laden"; + +"rerun_provider_autodetect" = "Voer de automatische detectie van de provider opnieuw uit"; + +"loading" = "Laden..."; + +"no_log_yet_fetch" = "Nog geen logboek. Ophalen om te laden."; + +"section_fetch_strategy" = "Strategiepogingen ophalen"; + +"fetch_strategy_caption" = "Laatste ophaalpijplijnbeslissingen en fouten voor een provider."; + +"section_openai_cookies" = "OpenAI-cookies"; + +"openai_cookies_caption" = "Cookie-import + WebKit-scraping-logboeken van de laatste OpenAI-cookiepoging."; + +"no_log_yet" = "Nog geen logboek. Update OpenAI-cookies in Providers → Codex om een ​​import uit te voeren."; + +"section_caches" = "Caches"; + +"caches_caption" = "Wis in het cachegeheugen opgeslagen kostenscanresultaten of caches van browsercookies."; + +"clear_cookie_cache" = "Cookie-cache wissen"; + +"clear_cost_cache" = "Wis de kostencache"; + +"section_notifications" = "Meldingen"; + +"notifications_caption" = "Activeer testmeldingen voor het sessievenster van 5 uur (opgebruikt/hersteld)."; + +"post_depleted" = "Post uitgeput"; + +"post_restored" = "Bericht hersteld"; + +"section_cli_sessions" = "CLI-sessies"; + +"cli_sessions_caption" = "Houd Codex/Claude CLI-sessies levend na een onderzoek. Standaard wordt afgesloten zodra gegevens zijn vastgelegd."; + +"keep_cli_sessions_alive" = "Houd CLI-sessies levend"; + +"keep_cli_sessions_alive_subtitle" = "Sla demontage tussen tests over (alleen voor foutopsporing)."; + +"reset_cli_sessions" = "CLI-sessies opnieuw instellen"; + +"section_error_simulation" = "Foutsimulatie"; + +"error_simulation_caption" = "Injecteer een valse foutmelding in de menukaart voor het testen van de lay-out."; + +"set_menu_error" = "Menufout instellen"; + +"clear_menu_error" = "Menufout wissen"; + +"set_cost_error" = "Fout bij instellen van kosten"; + +"clear_cost_error" = "Duidelijke kostenfout"; + +"section_cli_paths" = "CLI-paden"; + +"cli_paths_caption" = "Opgelost Codex binaire en PATH-lagen; opstarten login PATH vastleggen (korte time-out)."; + +"codex_binary" = "Codex binair"; + +"claude_binary" = "Claude binair"; + +"effective_path" = "Effectief PAD"; + +"unavailable" = "Niet beschikbaar"; + +"login_shell_path" = "Login shell PATH (opstartopname)"; + +"cleared" = "Gewist."; + +"no_fetch_attempts" = "Nog geen ophaalpogingen."; + +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe kan menubalk-apps blokkeren in Systeeminstellingen → Menubalk → Toestaan ​​in de menubalk. QuotaKit is actief, maar macOS verbergt mogelijk het pictogram ervan. Open de Menubalkinstellingen en schakel QuotaKit in."; + + +/* Metric preferences */ +"metric_pref_automatic" = "Automatisch"; + +"metric_pref_primary" = "Primair"; + +"metric_pref_secondary" = "Secundair"; + +"metric_pref_tertiary" = "Tertiair"; + +"metric_pref_extra_usage" = "Extra gebruik"; + +"metric_pref_average" = "Gemiddeld"; + + +/* Display modes */ +"display_mode_percent" = "Procent"; + +"display_mode_pace" = "Tempo"; + +"display_mode_both" = "Beide"; + +"display_mode_percent_desc" = "Toon resterend/gebruikt percentage (bijvoorbeeld 45%)"; + +"display_mode_pace_desc" = "Toon tempo-indicator (bijv. +5%)"; + +"display_mode_both_desc" = "Toon zowel percentage als tempo (bijvoorbeeld 45% · +5%)"; + + +/* Provider status */ +"status_operational" = "Operationeel"; + +"status_partial_outage" = "Gedeeltelijke uitval"; + +"status_major_outage" = "Grote storing"; + +"status_critical_issue" = "Kritieke kwestie"; + +"status_maintenance" = "Onderhoud"; + +"status_unknown" = "Status onbekend"; + + +/* Refresh frequency */ +"refresh_manual" = "Handmatig"; + +"refresh_1min" = "1 min"; + +"refresh_2min" = "2 minuten"; + +"refresh_5min" = "5 min"; + +"refresh_15min" = "15 minuten"; + +"refresh_30min" = "30 min"; + + +/* Additional keys */ +"not_found" = "Niet gevonden"; + + +/* Cost estimation */ +"cost_header_estimated" = "Kosten (geschat)"; + +"cost_estimate_hint" = "Geschat op basis van lokale logboeken · kan afwijken van uw factuur"; + +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Geen JetBrains IDE met AI Assistant gedetecteerd. Installeer een JetBrains IDE en schakel AI Assistant in."; + +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API-token niet geconfigureerd. Stel de omgevingsvariabele OPENROUTER_API_KEY in of configureer deze in Instellingen."; + +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API-token niet gevonden. Stel apiKey in ~/.quotakit/config.json of Z_AI_API_KEY."; + +"Missing DeepSeek API key." = "Ontbrekende DeepSeek API-sleutel."; + +"%@ is unavailable in the current environment." = "%@ is niet beschikbaar in de huidige omgeving."; + +"All Systems Operational" = "Alle systemen operationeel"; + +"Last 30 days" = "Laatste 30 dagen"; + +"Last 30 days:" = "Afgelopen 30 dagen:"; + +"This month" = "Deze maand"; + +"Store multiple OpenAI API keys." = "Bewaar meerdere OpenAI API-sleutels."; + +"Admin API key" = "Beheerder API-sleutel"; + +"Open billing" = "Facturering openen"; + +"Google accounts" = "Google-accounts"; + +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Bewaar meerdere Antigravity Google OAuth-accounts voor snel schakelen."; + +"Add Google Account" = "Google-account toevoegen"; + +"Open Token Plan" = "Tokenplan openen"; + +"Text Generation" = "Tekst genereren"; + +"Text to Speech" = "Tekst naar spraak"; + +"Music Generation" = "Muziek generatie"; + +"Image Generation" = "Beeldgeneratie"; + +"No local data found" = "Geen lokale gegevens gevonden"; + +"Credits unavailable; keep Codex running to refresh." = "Tegoeden niet beschikbaar; laat Codex draaien om te vernieuwen."; + +"No available fetch strategy for minimax." = "Geen beschikbare ophaalstrategie voor minimax."; + +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Geen Cursorsessie gevonden. Meld u aan bij cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX of Edge Canary. Als u Safari gebruikt, verleen QuotaKit volledige schijftoegang in Systeeminstellingen ▸ Privacy en beveiliging. U kunt zich ook aanmelden bij Cursor via het QuotaKit-menu (Account toevoegen/wisselen)."; + +"No OpenCode session cookies found in browsers." = "Er zijn geen OpenCode-sessiecookies gevonden in browsers."; + +"No available fetch strategy for %@." = "Geen beschikbare ophaalstrategie voor %@."; + +"Today" = "Vandaag"; + +"Today tokens" = "Vandaag tokens"; + +"30d cost" = "30d kosten"; + +"30d tokens" = "30d-tokens"; + +"Latest tokens" = "Nieuwste tokens"; + +"Top model" = "Topmodel"; + +"Storage" = "Opslag"; + +"Add Account..." = "Account toevoegen..."; + +"Usage Dashboard" = "Gebruiksdashboard"; + +"Status Page" = "Statuspagina"; + +"Settings..." = "Instellingen..."; + +"About CodexBar" = "Over QuotaKit"; + +"Quit" = "Stoppen"; + +"Last %d day" = "Afgelopen %d dag"; + +"Last %d days" = "Afgelopen %d dagen"; + +"%@ tokens" = "%@ tokens"; + +"Latest billing day" = "Laatste factuurdag"; + +"Latest billing day (%@)" = "Laatste factuurdag (%@)"; + +"%@ left" = "%@ over"; + +"Resets %@" = "Reset %@"; + +"Resets in %@" = "Resetten over %@"; + +"Resets now" = "Wordt nu gereset"; + +"Lasts until reset" = "Gaat mee tot reset"; + +"Updated %@" = "Bijgewerkt %@"; + +"Updated %@h ago" = "%@u geleden bijgewerkt"; + +"Updated %@m ago" = "%@m geleden bijgewerkt"; + +"Updated just now" = "Zojuist bijgewerkt"; + +"Projected empty in %@" = "Geprojecteerd leeg in %@"; + +"Runs out in %@" = "Loopt af over %@"; + +"Pace: %@" = "Tempo: %@"; + +"Pace: %@ · %@" = "Tempo: %@ · %@"; + +"%@ · %@" = "%@ · %@"; + +"≈ %d%% run-out risk" = "≈ %d%% uitlooprisico"; + +"%d%% in deficit" = "%d%% tekort"; + +"%d%% in reserve" = "%d%% in reserve"; + +"usage_percent_suffix_left" = "over"; + +"usage_percent_suffix_used" = "gebruikt"; + +"Store multiple DeepSeek API keys." = "Bewaar meerdere DeepSeek API-sleutels."; + +"This week" = "Deze week"; + +"Week" = "Week"; + +"Month" = "Maand"; + +"Models" = "Modellen"; + +"24h tokens" = "24-uurs tokens"; + +"Latest hour" = "Laatste uur"; + +"Peak hour" = "Piekuur"; + +"Top method" = "Topmethode"; + +"30d cash" = "30d contant"; + +"30d billing history from MiniMax web session" = "30d factuurgeschiedenis van MiniMax-websessie"; + +"AWS Cost Explorer billing can lag." = "De facturering van AWS Cost Explorer kan vertraging oplopen."; + +"Rate limit: %d / %@" = "Tarieflimiet: %d / %@"; + +"Key remaining" = "Sleutel resterend"; + +"No limit set for the API key" = "Er is geen limiet ingesteld voor de API-sleutel"; + +"API key limit unavailable right now" = "API-sleutellimiet momenteel niet beschikbaar"; + +"This month: %@ tokens" = "Deze maand: %@ tokens"; + +"No utilization data yet." = "Nog geen gebruiksgegevens."; + +"No %@ utilization data yet." = "Nog geen %@ gebruiksgegevens."; + +"%@: %@%% used" = "%@: %@%% gebruikt"; + +"%dd" = "%dd"; + +"today" = "Vandaag"; + +"just now" = "zojuist"; + +"On pace" = "Op tempo"; + +"Runs out now" = "Is nu op"; + +"Projected empty now" = "Nu leeg geprojecteerd"; + +"Switch Account..." = "Account wisselen..."; + +"Update ready, restart now?" = "Update klaar, nu opnieuw opstarten?"; + +"Daily" = "Dagelijks"; + +"Hourly Tokens" = "Tokens per uur"; + +"No data" = "Geen gegevens"; + +"No usage breakdown data available." = "Er zijn geen gebruiksgegevens beschikbaar."; + + +"Today: %@ · %@ tokens" = "Vandaag: %@ · %@ tokens"; + +"Today: %@" = "Vandaag: %@"; + +"Today: %@ tokens" = "Vandaag: %@ tokens"; + +"Last 30 days: %@ · %@ tokens" = "Afgelopen 30 dagen: %@ · %@ tokens"; + +"Last 30 days: %@" = "Afgelopen 30 dagen: %@"; + +"Est. total (30d): %@" = "Geschat. totaal (30d): %@"; + +"Est. total (%@): %@" = "Geschat. totaal (%@): %@"; + +"Hover a bar for details" = "Beweeg een balk voor details"; + +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; + +"No providers selected for Overview." = "Geen aanbieders geselecteerd voor Overzicht."; + +"No overview data available." = "Geen overzichtsgegevens beschikbaar."; + +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto gebruikt eerst de lokale IDE API en vervolgens Google OAuth wanneer de IDE wordt gesloten."; + +"Login with Google" = "Inloggen met Google"; + + +/* Popup panels */ +"No usage configured." = "Geen gebruik geconfigureerd."; + +"Quota" = "Quotum"; + +"tokens" = "tokens"; + +"requests" = "verzoeken"; + +"Latest" = "Nieuwste"; + +"Monthly" = "Maandelijks"; + +"Sonnet" = "Sonnet"; + +"Overages" = "Overschotten"; + +"Activity" = "Activiteit"; + +"Copied" = "Gekopieerd"; + +"Copy error" = "Kopieerfout"; + +"Copy path" = "Kopieer pad"; + +"Extra usage spent" = "Extra gebruik besteed"; + +"Credits remaining" = "Resterende tegoeden"; + +"Using CLI fallback" = "CLI-fallback gebruiken"; + +"Balance updates in near-real time (up to 5 min lag)" = "Saldo-updates in bijna realtime (tot 5 minuten vertraging)"; + +"Daily billing data finalizes at 07:00 UTC" = "De dagelijkse factureringsgegevens worden afgerond om 07:00 UTC"; + +"%@ of %@ credits left" = "%@ van %@ credits over"; + +"%@ of %@ bonus credits left" = "Er zijn nog %@ van %@ bonuscredits over"; + +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ resterend)"; + +"%@/%@ left" = "%@/%@ over"; + +"Gemini Flash" = "Tweeling flits"; + +"Regenerates %@" = "Regenereert %@"; + +"used after next regen" = "gebruikt na de volgende regen"; + +"after next regen" = "na de volgende regen"; + +"Near full" = "Bijna vol"; + +"Full in ~1 regen" = "Volledig in ~1 regeneratie"; + +"Full in ~%.0f regens" = "Volledig in ~%.0f regens"; + +"Overage usage" = "Overmatig gebruik"; + +"Overage cost" = "Overschrijdingskosten"; + +"credits" = "tegoeden"; + +"Zen balance" = "Zen-balans"; + +"API spend" = "API-uitgaven"; + +"Extra usage" = "Extra gebruik"; + +"Quota usage" = "Quotumgebruik"; + +"%.0f%% used" = "%.0f%% gebruikt"; + +"Usage history (today)" = "Gebruiksgeschiedenis (vandaag)"; + +"Usage history (%d days)" = "Gebruiksgeschiedenis (%d dagen)"; + +"%d percent remaining" = "%d procent resterend"; + +"Unknown" = "Onbekend"; + +"stale data" = "verouderde gegevens"; + +"No credits history data." = "Geen kredietgeschiedenisgegevens."; + +"No credits history data available." = "Er zijn geen kredietgeschiedenisgegevens beschikbaar."; + +"Credits history chart" = "Creditgeschiedenisgrafiek"; + +"%d days of credits data" = "%d dagen aan kredietgegevens"; + +"Usage breakdown chart" = "Uitsplitsingsschema voor gebruik"; + +"%d days of usage data across %d services" = "%d dagen aan gebruiksgegevens voor %d services"; + +"Cost history chart" = "Kostengeschiedenisgrafiek"; + +"%d days of cost data" = "%d dagen aan kostengegevens"; + +"Plan utilization chart" = "Plan gebruiksgrafiek"; + +"%d utilization samples" = "%d gebruiksvoorbeelden"; + +"Hourly Usage" = "Uurgebruik"; + +"Usage remaining" = "Resterend gebruik"; + +"Usage used" = "Gebruik gebruikt"; + +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API-sleutel geverifieerd. Ollama stelt geen Cloud-quotumlimieten bloot via de API."; + +"Last 30 days: %@ tokens" = "Afgelopen 30 dagen: %@ tokens"; + +"7d spend" = "7d uitgaven"; + +"30d spend" = "30d uitgaven"; + +"Cache read" = "Cache lezen"; + +"Claude Admin API 30 day spend trend" = "Claude Admin API bestedingstrend van 30 dagen"; + +"OpenRouter API key spend trend" = "Trend van uitgaven voor OpenRouter API-sleutels"; + +"z.ai hourly token trend" = "z.ai tokentrend per uur"; + +"MiniMax 30 day token usage trend" = "MiniMax 30 dagen tokengebruikstrend"; + +"Today cash" = "Vandaag contant"; + +"DeepSeek 30 day token usage trend" = "DeepSeek 30 dagen tokengebruikstrend"; + +"cache-hit input" = "cache-hit-invoer"; + +"cache-miss input" = "cache-miss invoer"; + +"output" = "uitgang"; + +"Requests" = "Verzoeken"; + +"Reported by OpenAI Admin API organization usage." = "Gerapporteerd door het gebruik van de OpenAI Admin API-organisatie."; + +"Reported by Mistral billing usage." = "Gerapporteerd door Mistral-factureringsgebruik."; + +"Google OAuth" = "Google OAuth"; + +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Voeg accounts toe via GitHub OAuth Device Flow op de geselecteerde host."; + +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Slaat elk ingelogd Google-account op voor snel schakelen tussen anti-zwaartekracht. Gebruikt Antigravity.app OAuth indien beschikbaar, of ANTIGRAVITY_OAUTH_CLIENT_ID en ANTIGRAVITY_OAUTH_CLIENT_SECRET als overschrijving."; + +"Manual cleanup: past sessions" = "Handmatig opschonen: afgelopen sessies"; + +"Clearing removes past resume, continue, and rewind history." = "Door te wissen wordt de geschiedenis van het hervatten, doorgaan en terugspoelen uit het verleden verwijderd."; + +"Manual cleanup: file checkpoints" = "Handmatig opschonen: bestandscontrolepunten"; + +"Clearing removes checkpoint restore data for previous edits." = "Door het wissen worden de controlepuntherstelgegevens van eerdere bewerkingen verwijderd."; + +"Manual cleanup: saved plans" = "Handmatig opschonen: opgeslagen plannen"; + +"Clearing removes old plan-mode files." = "Door te wissen worden oude bestanden in de planmodus verwijderd."; + +"Manual cleanup: debug logs" = "Handmatig opschonen: foutopsporingslogboeken"; + +"Clearing removes past debug logs." = "Door te wissen worden eerdere foutopsporingslogboeken verwijderd."; + +"Manual cleanup: attachment cache" = "Handmatig opschonen: bijlagecache"; + +"Clearing removes cached large pastes or attached images." = "Door te wissen worden in de cache opgeslagen grote pasta's of bijgevoegde afbeeldingen verwijderd."; + +"Manual cleanup: session metadata" = "Handmatig opschonen: sessiemetagegevens"; + +"Clearing removes per-session environment metadata." = "Door te wissen worden de metagegevens van de omgeving per sessie verwijderd."; + +"Manual cleanup: shell snapshots" = "Handmatig opschonen: shell-snapshots"; + +"Clearing removes leftover runtime shell snapshot files." = "Door het wissen worden de overgebleven runtime shell-snapshotbestanden verwijderd."; + +"Manual cleanup: legacy todos" = "Handmatig opschonen: oude taken"; + +"Clearing removes legacy per-session task lists." = "Door het wissen worden verouderde takenlijsten per sessie verwijderd."; + +"Manual cleanup: sessions" = "Handmatig opschonen: sessies"; + +"Clearing removes past Codex session history." = "Door te wissen wordt de geschiedenis van de Codex-sessie verwijderd."; + +"Manual cleanup: archived sessions" = "Handmatig opschonen: gearchiveerde sessies"; + +"Clearing removes archived Codex session history." = "Door te wissen wordt de gearchiveerde Codex-sessiegeschiedenis verwijderd."; + +"Manual cleanup: cache" = "Handmatig opschonen: cache"; + +"Clearing removes provider-owned cached data." = "Door te wissen worden gegevens in de cache van de provider verwijderd."; + +"Manual cleanup: logs" = "Handmatig opschonen: logboeken"; + +"Clearing removes local diagnostic logs." = "Door te wissen worden lokale diagnostische logboeken verwijderd."; + +"Manual cleanup: file history" = "Handmatig opschonen: bestandsgeschiedenis"; + +"Clearing removes local edit checkpoint history." = "Door te wissen wordt de geschiedenis van de lokale bewerkingscontrolepunten verwijderd."; + +"Manual cleanup: temporary data" = "Handmatig opschonen: tijdelijke gegevens"; + +"Clearing removes local temporary provider data." = "Door te wissen worden lokale tijdelijke providergegevens verwijderd."; + +"Total: %@" = "Totaal: %@"; + +"%d more items" = "%d meer artikelen"; + +"Cleanup ideas" = "Opruimideeën"; + +"%d unreadable item(s) skipped" = "%d onleesbare item(s) overgeslagen"; + + +"API key limit" = "API-sleutellimiet"; + +"Auth" = "Aut"; + +"Auto" = "Auto"; + +"Disabled — no recent data" = "Uitgeschakeld — geen recente gegevens"; + +"Limits not available" = "Limieten niet beschikbaar"; + +"No usage yet" = "Nog geen gebruik"; + +"Not fetched yet" = "Nog niet opgehaald"; + +"Refreshing" = "Verfrissend"; + +"Session" = "Sessie"; + +"Source" = "Bron"; + +"State" = "Staat"; + +"Unavailable" = "Niet beschikbaar"; + +"Weekly" = "Wekelijks"; + +"not detected" = "niet gedetecteerd"; + +"Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; + +"minimax_usage_amount_format" = "Gebruik: %@ / %@"; + +"minimax_used_percent_format" = "Gebruikt %@"; + +"minimax_service_text_generation" = "Tekst genereren"; + +"minimax_service_text_to_speech" = "Tekst naar spraak"; + +"minimax_service_music_generation" = "Muziek generatie"; + +"minimax_service_image_generation" = "Beeldgeneratie"; + +"minimax_service_lyrics_generation" = "Songtekst generatie"; + +"minimax_service_coding_plan_vlm" = "Codeerplan VLM"; + +"minimax_service_coding_plan_search" = "Coderingsplan zoeken"; + + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ wacht op toestemming"; + +"%@ requests" = "%@ verzoeken"; + +"%@: %@ credits" = "%@: %@ tegoeden"; + +"30d requests" = "30d verzoeken"; + +"4 days" = "4 dagen"; + +"5 days" = "5 dagen"; + +"7 days" = "7 dagen"; + +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-sleutel verifieert Ollama Cloud-toegang; cookies stellen nog steeds quotumlimieten bloot."; + +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-toegangssleutel-ID. Kan ook worden ingesteld met AWS_ACCESS_KEY_ID."; + +"AWS region. Can also be set with AWS_REGION." = "AWS-regio. Kan ook worden ingesteld met AWS_REGION."; + +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Geheime toegangssleutel van AWS. Kan ook worden ingesteld met AWS_SECRET_ACCESS_KEY."; + +"Access key ID" = "Toegangssleutel-ID"; + +"Add Account" = "Account toevoegen"; + +"Adding Account…" = "Account toevoegen…"; + +"Antigravity login failed" = "Antigravity-aanmelding mislukt"; + +"Antigravity login timed out" = "Er is een time-out opgetreden bij het inloggen op anti-zwaartekracht"; + +"Auth source" = "Authenticatiebron"; + +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importeert automatisch Chrome-browsercookies van Xiaomi MiMo."; + +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatische import van windsurfsessiegegevens uit de Chromium-browser localStorage."; + +"Automatic imports browser cookies from Bailian." = "Importeert automatisch browsercookies van Bailian."; + +"Automatically imports browser cookies." = "Importeert automatisch browsercookies."; + +"Automatically imports browser session cookies." = "Importeert automatisch browsersessiecookies."; + +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-implementatienaam. AZURE_OPENAI_DEPLOYMENT_NAME wordt ook ondersteund."; + +"Azure OpenAI key" = "Azure OpenAI-sleutel"; + +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-resource-eindpunt. AZURE_OPENAI_ENDPOINT wordt ook ondersteund."; + +"Base URL" = "Basis-URL"; + +"Base URL for the LLM-API-Key-Proxy instance." = "Basis-URL voor de LLM-API-Key-Proxy-instantie."; + +"Browser cookies" = "Browser-cookies"; + +"Cap end" = "Dop uiteinde"; + +"Cap start" = "Kap begin"; + +"Capacity End" = "Einde capaciteit"; + +"Capacity Start" = "Capaciteit begin"; + +"Changelog" = "Wijzigingslog"; + +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Kies de Moonshot/Kimi API-host voor internationale accounts of accounts op het vasteland van China."; + +"CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit kan een systeemaccount dat is aangemeld met alleen een API-sleutelconfiguratie niet vervangen."; + +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit kon de opgeslagen verificatie voor dat account niet vinden. Authenticeer het opnieuw en probeer het opnieuw."; + +"CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit kan de beheerde accountopslag niet lezen. Herstel de winkel voordat u een ander account toevoegt."; + +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit kan de opgeslagen verificatie voor dat account niet lezen. Authenticeer het opnieuw en probeer het opnieuw."; + +"CodexBar could not read the current system account on this Mac." = "QuotaKit kon het huidige systeemaccount op deze Mac niet lezen."; + +"CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit kon de live Codex-authenticatie op deze Mac niet vervangen."; + +"CodexBar could not safely preserve the current system account before switching." = "QuotaKit kon het huidige systeemaccount niet veilig behouden voordat hij overschakelde."; + +"CodexBar could not save the current system account before switching." = "QuotaKit kon het huidige systeemaccount niet opslaan voordat er werd overgeschakeld."; + +"CodexBar could not update managed account storage." = "QuotaKit kan de beheerde accountopslag niet updaten."; + +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit heeft een ander beheerd account gevonden dat al gebruikmaakt van het huidige systeemaccount. Los het dubbele account op voordat u overstapt."; + +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit vraagt ​​macOS-sleutelhanger om “%@”, zodat browsercookies kunnen worden gedecodeerd en uw account kan worden geverifieerd. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit zal macOS Keychain om het Claude Code OAuth-token vragen, zodat het uw Claude-gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om je Amp-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw Augment-cookie-header vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw Claude-cookieheader vragen, zodat deze het webgebruik van Claude kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw Cursor-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw Factory-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw GitHub Copilot-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit vraagt ​​macOS Keychain om je Kimi K2 API-sleutel, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit vraagt ​​macOS Keychain om je Kimi-authenticatietoken, zodat het gebruik kan worden opgehaald. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw MiniMax API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw MiniMax-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit zal macOS Keychain om uw OpenAI-cookieheader vragen, zodat deze extra's op het Codex-dashboard kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw OpenCode-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit vraagt ​​macOS-sleutelhanger om uw synthetische API-sleutel, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit zal macOS Keychain om uw z.ai API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; + +"Could not open Cursor login in your browser." = "Kan Cursor-login niet openen in uw browser."; + +"Could not open browser for Antigravity" = "Kan browser voor Antigravity niet openen"; + +"Credits used" = "Gebruikte tegoeden"; + +"Day" = "Dag"; + +"Deployment" = "Inzet"; + +"Drag to reorder" = "Sleep om de volgorde te wijzigen"; + +"Endpoint" = "Eindpunt"; + +"Enterprise host" = "Enterprise-host"; + +"Extra usage balance: %@" = "Extra gebruikssaldo: %@"; + +"Keychain Access Required" = "Toegang tot sleutelhanger vereist"; + +"Kiro menu bar value" = "Waarde van de Kiro-menubalk"; + +"Label" = "Label"; + +"No organizations loaded. Click Refresh after setting your API key." = "Er zijn geen organisaties geladen. Klik op Vernieuwen nadat u uw API-sleutel hebt ingesteld."; + +"No output captured." = "Geen uitvoer vastgelegd."; + +"No system account" = "Geen systeemaccount"; + +"Oasis-Token" = "Oasis-token"; + +"Open Augment (Log Out & Back In)" = "Augment openen (uitloggen en weer inloggen)"; + +"Open Codebuff Dashboard" = "Open het Codebuff-dashboard"; + +"Open Command Code Settings" = "Open de opdrachtcode-instellingen"; + +"Open Crof dashboard" = "Open het Crof-dashboard"; + +"Open Manus" = "Manus openen"; + +"Open MiMo Balance" = "Open MiMo-saldo"; + +"Open Moonshot Console" = "Open de Moonshot-console"; + +"Open Ollama API Keys" = "Open Ollama API-sleutels"; + +"Open StepFun Platform" = "Open het StepFun-platform"; + +"Open T3 Chat Settings" = "Open T3 Chat-instellingen"; + +"Open Volcengine Ark Console" = "Open de Volcengine Ark-console"; + +"Open legacy provider docs" = "Open oude providerdocumenten"; + +"Open projects" = "Openstaande projecten"; + +"Open this URL manually to continue login:\n\n%@" = "Open deze URL handmatig om door te gaan met inloggen:\n\n%@"; + +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optionele organisatie-ID voor accounts die zijn gekoppeld aan meerdere Anthropic-organisaties."; + +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optioneel. Is van toepassing op de geconfigureerde Admin API-sleutel; geselecteerde tokenaccounts nemen OPENAI_PROJECT_ID niet over."; + +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optioneel. Voer uw GitHub Enterprise-host in, bijvoorbeeld octocorp.ghe.com. Laat leeg voor github.com."; + +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optioneel. Laat dit veld leeg om projecten te ontdekken en samen te voegen die zichtbaar zijn voor de API-sleutel."; + +"Org ID (optional)" = "Organisatie-ID (optioneel)"; + +"Organizations" = "Organisaties"; + +"Password" = "Wachtwoord"; + +"%@ authentication is disabled." = "%@-authenticatie is uitgeschakeld."; + +"%@ cookies are disabled." = "%@ cookies zijn uitgeschakeld."; + +"%@ web API access is disabled." = "%@ web-API-toegang is uitgeschakeld."; + +"Disable %@ dashboard cookie usage." = "Schakel het gebruik van %@ dashboardcookies uit."; + +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "De sleutelhangertoegang is uitgeschakeld in Geavanceerd, dus het importeren van browsercookies is niet beschikbaar."; + +"Manually paste an %@ from a browser session." = "Plak handmatig een %@ uit een browsersessie."; + +"Paste a Cookie header captured from %@." = "Plak een Cookie-header vastgelegd van %@."; + +"Paste a Cookie header from %@." = "Plak een Cookie-header van %@."; + +"Paste a Cookie header or cURL capture from %@." = "Plak een cookie-header of cURL-opname uit %@."; + +"Paste a Cookie header or full cURL capture from %@." = "Plak een cookiekoptekst of volledige krulopname uit %@."; + +"Paste a Cookie or Authorization header from %@." = "Plak een cookie- of autorisatiekop van %@."; + +"Paste a full cookie header or the %@ value." = "Plak een volledige cookiekop of de waarde %@."; + +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Plak een Cookie-header of volledige cURL-opname uit de T3 Chat-instellingen."; + +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai. Moet een ory_session_* cookie bevatten."; + +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Plak de Oasis-Token uit een ingelogde browsersessie op platform.stepfun.com."; + +"Paste the %@ JSON bundle from %@." = "Plak de %@ JSON-bundel uit %@."; + +"Paste the %@ value or a full Cookie header." = "Plak de waarde %@ of een volledige Cookie-header."; + +"Personal account" = "Persoonlijk account"; + +"Project ID" = "Project-ID"; + +"Re-auth" = "Opnieuw verifiëren"; + +"Re-authenticating…" = "Opnieuw authenticeren…"; + +"Refresh Session" = "Sessie vernieuwen"; + +"Refresh organizations" = "Vernieuw organisaties"; + +"Region" = "Regio"; + +"Reload" = "Herladen"; + +"Reorder" = "Opnieuw ordenen"; + +"Secret access key" = "Geheime toegangssleutel"; + +"Series" = "Serie"; + +"Service" = "Dienst"; + +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Toon of verberg Kiro-credits, percentages of beide naast het menubalkpictogram."; + +"Show usage for organizations you belong to. Personal account is always shown." = "Toon gebruik voor organisaties waartoe u behoort. Persoonlijk account wordt altijd getoond."; + +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Meld u aan bij cursor.com in uw browser en vernieuw vervolgens Cursor in QuotaKit."; + +"Simulated error text" = "Gesimuleerde fouttekst"; + +"StepFun platform account (phone number or email)." = "StepFun-platformaccount (telefoonnummer of e-mailadres)."; + +"Stored in ~/.codexbar/config.json." = "Opgeslagen in ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Opgeslagen in ~/.quotakit/config.json. AZURE_OPENAI_API_KEY wordt ook ondersteund."; + +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Opgeslagen in ~/.quotakit/config.json. Gebruik Moonshot / Kimi API voor de officiële Kimi API."; + +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Opgeslagen in ~/.quotakit/config.json. Haal uw API-sleutel op via de Volcengine Ark-console."; + +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Opgeslagen in ~/.quotakit/config.json. Haal uw sleutel op via Ollama-instellingen."; + +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Opgeslagen in ~/.quotakit/config.json. Haal uw sleutel op via console.deepgram.com."; + +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Opgeslagen in ~/.quotakit/config.json. Haal uw sleutel op via elevenlabs.io/app/settings/api-keys."; + +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Opgeslagen in ~/.quotakit/config.json. Haal uw sleutel op via openrouter.ai/settings/keys en stel daar een sleutelbestedingslimiet in om het bijhouden van API-sleutelquota in te schakelen."; + +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Opgeslagen in ~/.quotakit/config.json. Open in Warp Instellingen > Platform > API-sleutels en maak er vervolgens een."; + +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Opgeslagen in ~/.quotakit/config.json. Voor statistieken is toegang tot Groq Enterprise Prometheus vereist."; + +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Opgeslagen in ~/.quotakit/config.json. OPENAI_ADMIN_KEY heeft de voorkeur; OPENAI_API_KEY werkt nog steeds."; + +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Opgeslagen in ~/.quotakit/config.json. Vereist een Anthropic Admin API-sleutel."; + +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Opgeslagen in ~/.quotakit/config.json. Gebruikt voor /v1/quota-stats."; + +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Opgeslagen in ~/.quotakit/config.json. Je kunt ook CODEBUFF_API_KEY opgeven of QuotaKit ~/.config/manicode/credentials.json laten lezen (gemaakt door `codebuff login`)."; + +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Opgeslagen in ~/.quotakit/config.json. U kunt ook CROF_API_KEY opgeven."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Opgeslagen in ~/.quotakit/config.json. U kunt ook KILO_API_KEY of ~/.local/share/kilo/auth.json (kilo.access) opgeven."; + +"T3 Chat cookie" = "T3 Chat-cookie"; + +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Dat account is niet langer beschikbaar in QuotaKit. Vernieuw de accountlijst en probeer het opnieuw."; + +"The browser login did not complete in time. Try Antigravity login again." = "De browseraanmelding is niet op tijd voltooid. Probeer Antigravity-login opnieuw."; + +"Timed out waiting for Cursor login. %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@"; + +"Timed out waiting for Cursor login. %@ Last error: %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@ Laatste fout: %@"; + +"Today requests" = "Vandaag verzoeken"; + +"Total (30d): %@ credits" = "Totaal (30d): %@ credits"; + +"Username" = "Gebruikersnaam"; + +"Uses username + password to login and obtain an Oasis-Token automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een Oasis-Token te verkrijgen."; + +"Uses username + password to login and obtain an %@ automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een %@ te verkrijgen."; + +"Utilization End" = "Gebruik einde"; + +"Utilization Start" = "Gebruik starten"; + +"Verbosity" = "Breedsprakigheid"; + +"Windsurf session JSON bundle" = "Windsurfsessie JSON-bundel"; + +"Workspace ID" = "Werkruimte-ID"; + +"Your StepFun platform password. Used to login and obtain a session token." = "Uw StepFun-platformwachtwoord. Wordt gebruikt om in te loggen en een sessietoken te verkrijgen."; + +"claude /login exited with status %d." = "claude /login afgesloten met status %d."; + +"codex login exited with status %d." = "codex login afgesloten met status %d."; + +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; + +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nof plak de __Secure-next-auth.session-token-waarde"; + +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nof plak de kimi-auth-tokenwaarde"; + +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nof plak alleen de session_id-waarde"; + +"Clear" = "Duidelijk"; + +"No matching providers" = "Geen overeenkomende aanbieders"; + +"Search providers" = "Zoekaanbieders"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index dd43bfe79..fa2a8b974 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1,1061 +1,2098 @@ /* Brazilian Portuguese localization for CodexBar */ " providers" = " provedores"; + "(System)" = "(Sistema)"; + "30d" = "30d"; + "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; + "API key" = "Chave de API"; + "API region" = "Região da API"; + "API token" = "Token da API"; + "API tokens" = "Tokens de API"; + "About" = "Sobre"; + "Account" = "Conta"; + "Accounts" = "Contas"; + "Accounts subtitle" = "Subtítulo de contas"; + "Active" = "Ativo"; + "Add" = "Adicionar"; + "Add Workspace" = "Adicionar workspace"; + "Advanced" = "Avançado"; + "All" = "Todos"; + "Always allow prompts" = "Sempre permitir prompts"; + "Animation pattern" = "Padrão de animação"; + "Antigravity login is managed in the app" = "O login do Antigravity é gerenciado no app"; + "Applies only to the Security.framework OAuth keychain reader." = "Aplica-se apenas ao leitor de chaves OAuth Security.framework."; + "Auto falls back to the next source if the preferred one fails." = "Automático usa a próxima fonte se a preferida falhar."; + "Auto uses API first, then falls back to CLI on auth failures." = "Automático usa a API primeiro e recorre à CLI em falhas de autenticação."; + "Auto-detect" = "Detectar automaticamente"; + "Auto-refresh is off; use the menu's Refresh command." = "A atualização automática está desativada; use Atualizar no menu."; + "Auto-refresh: hourly · Timeout: 10m" = "Atualização automática: a cada hora · Timeout: 10 min"; + "Automatic" = "Automático"; + "Automatic imports browser cookies and WorkOS tokens." = "Importa automaticamente cookies do navegador e tokens WorkOS."; + "Automatic imports browser cookies and local storage tokens." = "Importa automaticamente cookies do navegador e tokens do armazenamento local."; + "Automatic imports browser cookies for dashboard extras." = "Importa automaticamente cookies do navegador para extras do dashboard."; + "Automatic imports browser cookies for the web API." = "Importa automaticamente cookies do navegador para a API web."; + "Automatic imports browser cookies from Model Studio/Bailian." = "Importa automaticamente cookies do navegador do Model Studio/Bailian."; + "Automatic imports browser cookies from admin.mistral.ai." = "Importa automaticamente cookies do navegador de admin.mistral.ai."; + "Automatic imports browser cookies from opencode.ai." = "Importa automaticamente cookies do navegador de opencode.ai."; + "Automatic imports browser cookies or stored sessions." = "Importa automaticamente cookies do navegador ou sessões salvas."; + "Automatic imports browser cookies." = "Importa cookies do navegador automaticamente."; + "Automatically imports browser session cookie." = "Importa automaticamente o cookie de sessão do navegador."; + "Automatically opens CodexBar when you start your Mac." = "Abre o QuotaKit automaticamente ao iniciar o Mac."; + "Automation" = "Automação"; + "Average (\\(label1) + \\(label2))" = "Média (\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Média (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "Evitar prompts do Keychain"; + "Balance" = "Saldo"; + "Battery Saver" = "Economia de bateria"; + "Bordered" = "Com borda"; + "Build" = "Build"; + "Built \\(buildTimestamp)" = "Build \\(buildTimestamp)"; + "Buy Credits..." = "Comprar créditos..."; + "Buy Credits…" = "Comprar créditos…"; + "CLI paths" = "Caminhos da CLI"; + "CLI sessions" = "Sessões da CLI"; + "Caches" = "Caches"; + "Cancel" = "Cancelar"; + "Check for Updates…" = "Buscar atualizações…"; + "Check for updates automatically" = "Buscar atualizações automaticamente"; + "Check if you like your agents having some fun up there." = "Veja se você gosta dos seus agentes se divertindo ali em cima."; + "Check provider status" = "Verificar status dos provedores"; + "Choose Codex workspace" = "Escolher workspace do Codex"; + "Choose the MiniMax host (global .io or China mainland .com)." = "Escolha o host MiniMax (global .io ou China continental .com)."; + "Choose up to " = "Escolha até "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "Escolha até \\(Self.maxOverviewProviders) provedores"; + "Choose up to \\(count) providers" = "Escolha até \\(count) provedores"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Escolha o que mostrar na barra de menus (Ritmo mostra uso vs. esperado)."; + "Choose which Codex account CodexBar should follow." = "Escolha qual conta Codex o QuotaKit deve acompanhar."; + "Choose which window drives the menu bar percent." = "Escolha qual janela define a porcentagem da barra de menus."; + "Chrome" = "Chrome"; + "Claude CLI not found" = "CLI do Claude não encontrada"; + "Claude binary" = "Binário do Claude"; + "Claude cookies" = "Cookies do Claude"; + "Claude login failed" = "Falha no login do Claude"; + "Claude login timed out" = "Tempo esgotado no login do Claude"; + "Close" = "Fechar"; + "Code review" = "Revisão de código"; + "Codex CLI not found" = "CLI do Codex não encontrada"; + "Codex account login already running" = "Login de conta Codex já em andamento"; + "Codex binary" = "Binário do Codex"; + "Codex login failed" = "Falha no login do Codex"; + "Codex login timed out" = "Tempo esgotado no login do Codex"; + "CodexBar Lifecycle Keepalive" = "Keepalive do ciclo de vida do QuotaKit"; + "QuotaKit can't show its menu bar icon" = "O QuotaKit não consegue mostrar o ícone na barra de menus"; + "CodexBar could not read managed account storage. " = "O QuotaKit não conseguiu ler o armazenamento de contas gerenciadas. "; + "Configure…" = "Configurar…"; + "Connected" = "Conectado"; + "Controls how much detail is logged." = "Controla o nível de detalhe dos logs."; + "Cookie header" = "Cabeçalho Cookie"; + "Cookie source" = "Fonte do cookie"; + "Cookie: ..." = "Cookie: ..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nou cole uma captura cURL do dashboard do Abacus AI"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nou cole o valor de __Secure-next-auth.session-token"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nou cole o valor do token kimi-auth"; + "Cookie: …" = "Cookie: …"; + "CopilotDeviceFlow" = "CopilotDeviceFlow"; + "Cost" = "Custo"; + "Could not add Codex account" = "Não foi possível adicionar a conta Codex"; + "Could not open Terminal for Gemini" = "Não foi possível abrir o Terminal para Gemini"; + "Could not start claude /login" = "Não foi possível iniciar claude /login"; + "Could not start codex login" = "Não foi possível iniciar o login do Codex"; + "Could not switch system account" = "Não foi possível trocar a conta do sistema"; + "Credits" = "Créditos"; + "Credits history" = "Histórico de créditos"; + "Cursor login failed" = "Falha no login do Cursor"; + "Custom" = "Personalizado"; + "Custom Path" = "Caminho personalizado"; + "Daily Routines" = "Rotinas diárias"; + "Debug" = "Depuração"; + "Default" = "Padrão"; + "Disable Keychain access" = "Desativar acesso ao Keychain"; + "Disabled" = "Desativado"; + "Dismiss" = "Dispensar"; + "Disconnected" = "Desconectado"; + "Display" = "Exibição"; + "Display mode" = "Modo de exibição"; + "Display reset times as absolute clock values instead of countdowns." = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; + "Done" = "Concluído"; + "Effective PATH" = "PATH efetivo"; + "Email" = "E-mail"; + "Enable Merge Icons to configure Overview tab providers." = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; + "Enable file logging" = "Ativar logs em arquivo"; + "Enabled" = "Ativado"; + "Error" = "Erro"; + "Error simulation" = "Simulação de erro"; + "Expose troubleshooting tools in the Debug tab." = "Exibe ferramentas de diagnóstico na aba Depuração."; + "Failed" = "Falhou"; + "False" = "Falso"; + "Fetch strategy attempts" = "Tentativas da estratégia de busca"; + "Fetching" = "Buscando"; + "Field" = "Campo"; + "Field subtitle" = "Subtítulo do campo"; + "Finish the current managed account change before switching the system account." = "Conclua a alteração de conta gerenciada atual antes de trocar a conta do sistema."; + "Force animation on next refresh" = "Forçar animação na próxima atualização"; + "Gateway region" = "Região do gateway"; + "Gemini CLI not found" = "CLI do Gemini não encontrada"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, exibindo incidentes no ícone e no menu."; + "General" = "Geral"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "Login do GitHub Copilot"; + "GitHub Login" = "Login do GitHub"; + "Hide details" = "Ocultar detalhes"; + "Hide personal information" = "Ocultar informações pessoais"; + "Historical tracking" = "Acompanhamento histórico"; + "How often CodexBar polls providers in the background." = "Frequência com que o QuotaKit consulta provedores em segundo plano."; + "Inactive" = "Inativo"; + "Install CLI" = "Instalar CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instale a CLI do Claude (npm i -g @anthropic-ai/claude-code) e tente novamente."; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instale a CLI do Codex (npm i -g @openai/codex) e tente novamente."; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instale a CLI do Gemini (npm i -g @google/gemini-cli) e tente novamente."; + "JetBrains AI is ready" = "JetBrains AI está pronto"; + "JetBrains IDE" = "IDE JetBrains"; + "Keep CLI sessions alive" = "Manter sessões da CLI ativas"; + "Keyboard shortcut" = "Atalho de teclado"; + "Keychain access" = "Acesso ao Keychain"; + "Keychain prompt policy" = "Política de prompts do Keychain"; + "Last \\(name) fetch failed:" = "Última busca de \\(name) falhou:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Última busca de \\(self.store.metadata(for: self.provider).displayName) falhou:"; + "Last attempt" = "Última tentativa"; + "Link" = "Link"; + "Loading animations" = "Animações de carregamento"; + "Loading…" = "Carregando…"; + "Local" = "Local"; + "Logging" = "Logs"; + "Login failed" = "Falha no login"; + "Login shell PATH (startup capture)" = "PATH do shell de login (captura na inicialização)"; + "Login timed out" = "Tempo esgotado no login"; + "MCP details" = "Detalhes MCP"; + "Managed Codex accounts unavailable" = "Contas Codex gerenciadas indisponíveis"; + "Managed account storage is unreadable. Live account access is still available, " = "O armazenamento de contas gerenciadas está ilegível. O acesso à conta ativa ainda está disponível, "; + "Manual" = "Manual"; + "May your tokens never run out—keep agent limits in view." = "Que seus tokens nunca acabem — mantenha os limites dos agentes à vista."; + "Menu bar" = "Barra de menus"; + "Menu bar auto-shows the provider closest to its rate limit." = "A barra de menus mostra automaticamente o provedor mais próximo do limite de taxa."; + "Menu bar metric" = "Métrica da barra de menus"; + "Menu bar shows percent" = "Barra de menus mostra porcentagem"; + "Menu content" = "Conteúdo do menu"; + "Merge Icons" = "Mesclar Ícones"; + "Never prompt" = "Nunca perguntar"; + "No" = "Não"; + "No Codex accounts detected yet." = "Nenhuma conta Codex detectada ainda."; + "No JetBrains IDE detected" = "Nenhuma IDE JetBrains detectada"; + "No cost history data." = "Sem dados de histórico de custos."; + "No data available" = "Nenhum dado disponível"; + "No data yet" = "Ainda sem dados"; + "No enabled providers available for Overview." = "Nenhum provedor ativado disponível para Visão geral."; + "No providers selected" = "Nenhum provedor selecionado"; + "No token accounts yet." = "Ainda sem contas de token."; + "No usage breakdown data." = "Sem dados de detalhamento de uso."; + "None" = "Nenhum"; + "Notifications" = "Notificações"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "Notifica quando a cota de sessão de 5 horas chega a 0% e quando fica "; + "OK" = "OK"; + "Obscure email addresses in the menu bar and menu UI." = "Oculta endereços de e-mail na barra de menus e na UI do menu."; + "Off" = "Desligado"; + "Offline" = "Offline"; + "On" = "Ligado"; + "Online" = "Online"; + "Only on user action" = "Somente por ação do usuário"; + "Open" = "Abrir"; + "Open API Keys" = "Abrir chaves de API"; + "Open Amp Settings" = "Abrir ajustes do Amp"; + "Open Antigravity to sign in, then refresh CodexBar." = "Abra o Antigravity para entrar e depois atualize o QuotaKit."; + "Open Browser" = "Abrir navegador"; + "Open Coding Plan" = "Abrir Coding Plan"; + "Open Console" = "Abrir console"; + "Open Dashboard" = "Abrir dashboard"; + "Open Mistral Admin" = "Abrir Mistral Admin"; + "Open Menu Bar Settings" = "Abrir ajustes da barra de menus"; + "Open Ollama Settings" = "Abrir ajustes do Ollama"; + "Open Terminal" = "Abrir Terminal"; + "Open Usage Page" = "Abrir página de uso"; + "Open Warp API Key Guide" = "Abrir guia de chave de API do Warp"; + "Open menu" = "Abrir menu"; + "Open token file" = "Abrir arquivo de token"; + "OpenAI cookies" = "Cookies da OpenAI"; + "OpenAI web extras" = "Extras web da OpenAI"; + "Option A" = "Opção A"; + "Option B" = "Opção B"; + "Optional override if workspace lookup fails." = "Substituição opcional se a busca do workspace falhar."; + "Options" = "Opções"; + "Override auto-detection with a custom IDE base path" = "Substituir detecção automática por um caminho base personalizado da IDE"; + "Overview" = "Visão geral"; + "Overview rows always follow provider order." = "As linhas da Visão geral sempre seguem a ordem dos provedores."; + "Overview tab providers" = "Provedores da aba Visão geral"; + "Paste API key…" = "Cole a chave de API…"; + "Paste API token…" = "Cole o token da API…"; + "Paste key…" = "Cole a chave…"; + "Paste sessionKey or OAuth token…" = "Cole sessionKey ou token OAuth…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "Cole o cabeçalho Cookie de uma requisição para admin.mistral.ai. "; + "Paste token…" = "Cole o token…"; + "Personal" = "Pessoal"; + "Picker" = "Seletor"; + "Picker subtitle" = "Subtítulo do seletor"; + "Placeholder" = "Texto de exemplo"; + "Plan" = "Plano"; + "Play full-screen confetti when weekly usage resets." = "Mostra confete em tela cheia quando o uso semanal for renovado."; + "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta as páginas de status da OpenAI/Claude e o Google Workspace para "; + "Prevents any Keychain access while enabled." = "Impede qualquer acesso ao Keychain quando ativado."; + "Primary (API key limit)" = "Primário (limite da chave de API)"; + "Primary (\\(label))" = "Primário (\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "Primário (\\(metadata.sessionLabel))"; + "Probe logs" = "Logs de verificação"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "As barras de progresso preenchem conforme você consome a cota (em vez de mostrar o restante)."; + "Provider" = "Provedor"; + "Providers" = "Provedores"; + "Quit CodexBar" = "Encerrar QuotaKit"; + "Random (default)" = "Aleatório (padrão)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lê logs de uso locais. Mostra o custo de hoje + a janela de histórico selecionada no menu."; + "Refresh" = "Atualizar"; + "Refresh cadence" = "Cadência de atualização"; + "Remote" = "Remoto"; + "Remove" = "Remover"; + "Remove Codex account?" = "Remover conta Codex?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Remover \\(account.email) do QuotaKit? O diretório Codex gerenciado será apagado."; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Remover \\(email) do QuotaKit? O diretório Codex gerenciado será apagado."; + "Remove selected account" = "Remover conta selecionada"; + "Replace critter bars with provider branding icons and a percentage." = "Substitui barras de bichinhos por ícones da marca do provedor e uma porcentagem."; + "Replay selected animation" = "Reproduzir animação selecionada"; + "Requires authentication via GitHub Device Flow." = "Requer autenticação via GitHub Device Flow."; + "Resets: \\(reset)" = "Renova em: \\(reset)"; + "Rolling five-hour limit" = "Limite móvel de cinco horas"; + "Search hourly" = "Buscar a cada hora"; + "Secondary (\\(label))" = "Secundário (\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "Secundário (\\(metadata.weeklyLabel))"; + "Select a provider" = "Selecione um provedor"; + "Select the IDE to monitor" = "Selecione a IDE para monitorar"; + "Session quota notifications" = "Notificações de cota de sessão"; + "Session tokens" = "Tokens de sessão"; + "Settings" = "Ajustes"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; + "Show Debug Settings" = "Mostrar ajustes de depuração"; + "Show all token accounts" = "Mostrar todas as contas de token"; + "Show cost summary" = "Mostrar resumo de custos"; + "Show credits + extra usage" = "Mostrar créditos + uso extra"; + "Show details" = "Mostrar detalhes"; + "Show most-used provider" = "Mostrar provedor mais usado"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostra ícones dos provedores no alternador (caso contrário, mostra uma linha de progresso semanal)."; + "Show reset time as clock" = "Mostrar renovação como horário"; + "Show usage as used" = "Mostrar uso como consumido"; + "Sign in via button below" = "Entre pelo botão abaixo"; + "Skip teardown between probes (debug-only)." = "Não encerra entre verificações (somente depuração)."; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Empilha contas de token no menu (caso contrário, mostra uma barra de alternância de contas)."; + "Start at Login" = "Iniciar ao fazer login"; + "Status" = "Status"; + "Store Claude sessionKey cookies or OAuth access tokens." = "Armazena cookies sessionKey ou tokens de acesso OAuth do Claude."; + "Store multiple Abacus AI Cookie headers." = "Armazena vários cabeçalhos Cookie do Abacus AI."; + "Store multiple Augment Cookie headers." = "Armazena vários cabeçalhos Cookie do Augment."; + "Store multiple Cursor Cookie headers." = "Armazena vários cabeçalhos Cookie do Cursor."; + "Store multiple Factory Cookie headers." = "Armazena vários cabeçalhos Cookie do Factory."; + "Store multiple MiniMax Cookie headers." = "Armazena vários cabeçalhos Cookie do MiniMax."; + "Store multiple Mistral Cookie headers." = "Armazena vários cabeçalhos Cookie do Mistral."; + "Store multiple Ollama Cookie headers." = "Armazena vários cabeçalhos Cookie do Ollama."; + "Store multiple OpenCode Cookie headers." = "Armazena vários cabeçalhos Cookie do OpenCode."; + "Store multiple OpenCode Go Cookie headers." = "Armazena vários cabeçalhos Cookie do OpenCode Go."; + "Stored in the CodexBar config file." = "Armazenado no arquivo de configuração do QuotaKit."; + "Stored in ~/.codexbar/config.json. " = "Armazenado em ~/.quotakit/config.json. "; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Armazenado em ~/.quotakit/config.json. Gere um em kimi-k2.ai."; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Armazenado em ~/.quotakit/config.json. Cole a chave do dashboard Synthetic."; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Armazenado em ~/.quotakit/config.json. Cole sua chave de API do Coding Plan do Model Studio."; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Armazenado em ~/.quotakit/config.json. Cole sua chave de API do MiniMax."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Armazenado em ~/.quotakit/config.json. Você também pode informar KILO_API_KEY ou "; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Armazena histórico local de uso do Codex (8 semanas) para personalizar previsões de Ritmo."; + "Subscription Utilization" = "Uso da assinatura"; + "Surprise me" = "Surpreenda-me"; + "Switcher shows icons" = "Alternador mostra ícones"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "Sistema"; + "Temporarily shows the loading animation after the next refresh." = "Mostra temporariamente a animação de carregamento após a próxima atualização."; + "Tertiary (\\(label))" = "Terciário (\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "Terciário (\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "A conta Codex padrão deste Mac."; + "Toggle" = "Alternar"; + "Toggle subtitle" = "Subtítulo do alternador"; + "Token" = "Token"; + "Trigger the menu bar menu from anywhere." = "Aciona o menu da barra de menus de qualquer lugar."; + "True" = "Verdadeiro"; + "Twitter" = "Twitter"; + "Unsupported" = "Não suportado"; + "Update Channel" = "Canal de atualização"; + "Updated" = "Atualizado"; + "Updates unavailable in this build." = "Atualizações indisponíveis nesta build."; + "Usage" = "Uso"; + "Usage breakdown" = "Detalhamento de uso"; + "Usage history (30 days)" = "Histórico de uso"; + "Usage source" = "Fonte de uso"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usa BigModel para endpoints da China continental (open.bigmodel.cn)."; + "Use a single menu bar icon with a provider switcher." = "Usa um único ícone na barra de menus com alternador de provedores."; + "Use international or China mainland console gateways for quota fetches." = "Usa gateways de console internacionais ou da China continental para buscar cotas."; + "Version" = "Versão"; + "Version \\(self.versionString)" = "Versão \\(self.versionString)"; + "Version \\(version)" = "Versão \\(version)"; + "Version \\(versionString)" = "Versão \\(versionString)"; + "Vertex AI Login" = "Login do Vertex AI"; + "Wait for the current managed Codex login to finish before adding another account." = "Aguarde o login gerenciado atual do Codex terminar antes de adicionar outra conta."; + "Waiting for Authentication..." = "Aguardando autenticação..."; + "Website" = "Site"; + "Weekly limit confetti" = "Confete do limite semanal"; + "Weekly token limit" = "Limite semanal de tokens"; + "Weekly usage" = "Uso semanal"; + "Weekly usage unavailable for this account." = "Uso semanal indisponível para esta conta."; + "Window: \\(window)" = "Janela: \\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "Grava logs em \\(self.fileLogPath) para depuração."; + "Yes" = "Sim"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + "\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name): buscando…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name): última tentativa \\(when)"; + "\\(name): no data yet" = "\\(name): ainda sem dados"; + "\\(name): unsupported" = "\\(name): não suportado"; + "all browsers" = "todos os navegadores"; + "available again." = "disponível novamente."; + "built_format" = "Build %@"; + "copilot_complete_in_browser" = "Conclua o login no navegador."; + "copilot_device_code" = "Código do dispositivo copiado para a área de transferência: %1$@\n\nVerifique em: %2$@"; + "copilot_device_code_copied" = "Código do dispositivo copiado."; + "copilot_verify_at" = "Verifique em %@"; + "copilot_waiting_text" = "Conclua o login no navegador.\nEsta janela fecha automaticamente quando o login for concluído."; + "copilot_window_closes_auto" = "Esta janela fecha automaticamente quando o login for concluído."; + "cost_status_error" = "%1$@: %2$@"; + "cost_status_fetching" = "%1$@: buscando… %2$@"; + "cost_status_last_attempt" = "%1$@: última tentativa %2$@"; + "cost_status_no_data" = "%@: ainda sem dados"; + "cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@: não suportado"; + "credits_remaining" = "Créditos: %@"; + "cursor_on_demand" = "Sob demanda: %@"; + "cursor_on_demand_with_limit" = "Sob demanda: %1$@ / %2$@"; + "extra_usage_format" = "Uso extra: %1$@ / %2$@"; + "jetbrains_detected_generate" = "Detectado: %@. Use o assistente de IA uma vez para gerar dados de cota e atualize o QuotaKit."; + "jetbrains_detected_select" = "Detectado: %@. Selecione sua IDE preferida em Ajustes e atualize o QuotaKit."; + "last_fetch_failed_with_provider" = "Última busca de %@ falhou:"; + "last_spend" = "Último gasto: %@"; + "mcp_model_usage" = "%1$@: %2$@"; + "mcp_resets" = "Renova em: %@"; + "mcp_window" = "Janela: %@"; + "metric_average" = "Média (%1$@ + %2$@)"; + "metric_primary" = "Primário (%@)"; + "metric_secondary" = "Secundário (%@)"; + "metric_tertiary" = "Terciário (%@)"; + "multiple_workspaces_found" = "O QuotaKit encontrou vários workspaces para %@. Escolha o workspace para adicionar."; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "Escolha até %@ provedores"; + "remove_account_message" = "Remover %@ do QuotaKit? O diretório Codex gerenciado será apagado."; + "version_format" = "Versão %@"; + "vertex_ai_login_instructions" = "Para acompanhar o uso do Vertex AI, autentique-se no Google Cloud.\n\n1. Abra o Terminal\n2. Execute: gcloud auth application-default login\n3. Siga os prompts no navegador para entrar\n4. Defina seu projeto: gcloud config set project PROJECT_ID\n\nAbrir o Terminal agora?"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID está definido, mas somente opencode, opencodego e deepgram oferecem suporte a workspaceID."; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licença MIT."; + /* General Pane */ "section_system" = "Sistema"; + "section_usage" = "Uso"; + "section_automation" = "Automação"; + "language_title" = "Idioma"; + "language_subtitle" = "Altera o idioma de exibição. Requer reiniciar o app para ter efeito completo."; + "language_system" = "Sistema"; + "language_english" = "Inglês"; + "language_spanish" = "Espanhol"; + "language_catalan" = "Catalão"; + "language_chinese_simplified" = "Chinês simplificado"; + "language_chinese_traditional" = "Chinês tradicional"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "Sueco"; + +"language_dutch" = "Nederlands"; + +"language_french" = "Francês"; + +"language_ukrainian" = "Ucraniano"; + "start_at_login_title" = "Iniciar ao fazer login"; + "start_at_login_subtitle" = "Abre o QuotaKit automaticamente ao iniciar o Mac."; + "show_cost_summary" = "Mostrar resumo de custos"; + "show_cost_summary_subtitle" = "Lê logs de uso locais. Mostra o custo de hoje + janela selecionada no menu."; + "cost_history_days_title" = "Janela do histórico: %d dias"; + "cost_auto_refresh_info" = "Atualização automática: a cada hora · Timeout: 10 min"; + "refresh_cadence_title" = "Cadência de atualização"; + "refresh_cadence_subtitle" = "Frequência com que o QuotaKit consulta provedores em segundo plano."; + "manual_refresh_hint" = "A atualização automática está desativada; use Atualizar no menu."; + "check_provider_status_title" = "Verificar status dos provedores"; + "check_provider_status_subtitle" = "Consulta páginas de status da OpenAI/Claude e o Google Workspace para Gemini/Antigravity, exibindo incidentes no ícone e no menu."; + "session_quota_notifications_title" = "Notificações de cota de sessão"; + "session_quota_notifications_subtitle" = "Notifica quando a cota de sessão de 5 horas chega a 0% e quando fica disponível novamente."; + "quota_warning_notifications_title" = "Notificações de alerta de cota"; + "quota_warning_notifications_subtitle" = "Avisa quando a cota restante da sessão ou da semana fica abaixo dos limites configurados."; + "quota_warnings_title" = "Alertas de cota"; + "quota_warning_session" = "sessão"; + "quota_warning_session_capitalized" = "Sessão"; + "quota_warning_weekly" = "semanal"; + "quota_warning_weekly_capitalized" = "Semanal"; + "quota_warning_notification_title" = "%1$@: cota baixa (%2$@)"; + "quota_warning_notification_body" = "%1$@ restante. Você atingiu o limite de alerta de %2$d%% (%3$@)."; + "quota_warning_notification_body_with_account" = "Conta %1$@. %2$@ restante. Você atingiu o limite de alerta de %3$d%% (%4$@)."; + "session_depleted_notification_title" = "Sessão do %@ esgotada"; + "session_depleted_notification_body" = "0% restante. Avisaremos quando estiver disponível novamente."; + "session_restored_notification_title" = "Sessão do %@ restaurada"; + "session_restored_notification_body" = "A cota de sessão está disponível novamente."; + "quota_warning_warn_at" = "Alertar em"; + "quota_warning_global_threshold_subtitle" = "Percentuais restantes para as janelas de sessão e semanal, a menos que um provedor defina valores próprios."; + "quota_warning_sound" = "Reproduzir som de notificação"; + "quota_warning_provider_inherits" = "Usa as configurações globais de alerta de cota, a menos que uma janela seja personalizada aqui."; + "quota_warning_customize_thresholds" = "Personalizar limites de %@"; + "quota_warning_enable_warnings" = "Ativar alertas de %@"; + "quota_warning_window_warn_at" = "%@: alertar em"; + "quota_warning_off" = "Desativado"; + "quota_warning_inherited" = "Usando global: %@"; + "quota_warning_depleted_only" = "somente ao esgotar"; + "quota_warning_upper" = "Limite superior"; + "quota_warning_lower" = "Limite inferior"; + "apply" = "Aplicar"; + "quit_app" = "Encerrar QuotaKit"; + /* Tab titles */ "tab_general" = "Geral"; + "tab_providers" = "Provedores"; + "tab_display" = "Exibição"; + "tab_advanced" = "Avançado"; + "tab_about" = "Sobre"; + "tab_debug" = "Depuração"; + /* Providers Pane */ "select_a_provider" = "Selecione um provedor"; + "cancel" = "Cancelar"; + "last_fetch_failed" = "última busca falhou"; + "usage_not_fetched_yet" = "uso ainda não buscado"; + "managed_account_storage_unreadable" = "O armazenamento de contas gerenciadas está ilegível. O acesso à conta ativa ainda está disponível, mas adicionar, reautenticar e remover contas gerenciadas ficam desativados até o armazenamento ser recuperável."; + "remove_codex_account_title" = "Remover conta Codex?"; + "remove" = "Remover"; + "managed_login_already_running" = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar ou reautenticar outra conta."; + "managed_login_failed" = "O login gerenciado do Codex não foi concluído. Verifique se `codex --version` funciona no Terminal. Se o macOS bloqueou ou moveu `codex` para o Lixo, remova instalações duplicadas antigas, execute `npm install -g --include=optional @openai/codex@latest` e tente novamente."; + "codex_login_output" = "Saída do codex login:"; + "managed_login_missing_email" = "O login do Codex foi concluído, mas nenhum e-mail da conta estava disponível. Tente novamente após confirmar que a conta está totalmente conectada."; + "login_success_notification_title" = "Login do %@ bem-sucedido"; + "login_success_notification_body" = "Você pode voltar ao app; a autenticação foi concluída."; + "workspace_selection_cancelled" = "O QuotaKit encontrou vários workspaces, mas nenhum foi selecionado."; + "unsafe_managed_home" = "O QuotaKit se recusou a modificar um caminho de diretório gerenciado inesperado: %@"; + "menu_bar_metric_title" = "Métrica da barra de menus"; + "menu_bar_metric_subtitle" = "Escolha qual janela define a porcentagem da barra de menus."; + "menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; + "menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; + "menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; + "menu_bar_metric_subtitle_kimik2" = "Mostra os créditos da chave de API do Kimi K2 na barra de menus."; + "automatic" = "Automático"; + "primary_api_key_limit" = "Primário (limite da chave de API)"; + /* Display Pane */ "section_menu_bar" = "Barra de menus"; + "merge_icons_title" = "Mesclar Ícones"; + "merge_icons_subtitle" = "Usa um único ícone na barra de menus com alternador de provedores."; + "switcher_shows_icons_title" = "Alternador mostra ícones"; + "switcher_shows_icons_subtitle" = "Mostra ícones dos provedores no alternador (caso contrário, mostra uma linha de progresso semanal)."; + "show_most_used_provider_title" = "Mostrar provedor mais usado"; + "show_most_used_provider_subtitle" = "A barra de menus mostra automaticamente o provedor mais próximo do limite de taxa."; + "menu_bar_shows_percent_title" = "Barra de menus mostra porcentagem"; + "menu_bar_shows_percent_subtitle" = "Substitui barras de bichinhos por ícones da marca do provedor e uma porcentagem."; + "display_mode_title" = "Modo de exibição"; + "display_mode_subtitle" = "Escolha o que mostrar na barra de menus (Ritmo mostra uso vs. esperado)."; + "section_menu_content" = "Conteúdo do menu"; + "show_usage_as_used_title" = "Mostrar uso como consumido"; + "show_usage_as_used_subtitle" = "As barras de progresso preenchem conforme você consome a cota (em vez de mostrar o restante)."; + "show_quota_warning_markers_title" = "Mostrar marcadores de alerta de cota"; + "show_quota_warning_markers_subtitle" = "Desenha marcas de limite nas barras de uso quando os alertas de cota estão configurados."; + "weekly_progress_work_days_title" = "Dias úteis no progresso semanal"; + "weekly_progress_work_days_subtitle" = "Desenha marcas de limite de dia nas barras de uso semanal."; + "show_reset_time_as_clock_title" = "Mostrar renovação como horário"; + "show_reset_time_as_clock_subtitle" = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; + "show_provider_changelog_links_title" = "Mostrar links de changelog dos provedores"; + "show_provider_changelog_links_subtitle" = "Adiciona links de notas de versão para provedores baseados em CLI compatíveis no menu."; + "show_credits_extra_usage_title" = "Mostrar créditos + uso extra"; + "show_credits_extra_usage_subtitle" = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; + "show_all_token_accounts_title" = "Mostrar todas as contas de token"; + "show_all_token_accounts_subtitle" = "Empilha contas de token no menu (caso contrário, mostra uma barra de alternância de contas)."; + "multi_account_layout_title" = "Layout de múltiplas contas"; + "multi_account_layout_subtitle" = "Escolha alternância segmentada de contas ou cartões de contas empilhados."; + "multi_account_layout_segmented" = "Segmentado"; + "multi_account_layout_stacked" = "Empilhado"; + "overview_tab_providers_title" = "Provedores da aba Visão geral"; + "configure" = "Configurar…"; + "overview_enable_merge_icons_hint" = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; + "overview_no_providers_hint" = "Nenhum provedor ativado disponível para Visão geral."; + "overview_rows_follow_order" = "As linhas da Visão geral sempre seguem a ordem dos provedores."; + "overview_no_providers_selected" = "Nenhum provedor selecionado"; + /* Advanced Pane */ "section_keyboard_shortcut" = "Atalho de teclado"; + "open_menu_shortcut_title" = "Abrir menu"; + "open_menu_shortcut_subtitle" = "Aciona o menu da barra de menus de qualquer lugar."; + "install_cli" = "Instalar CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "Nenhum diretório bin gravável encontrado."; + "show_debug_settings_title" = "Mostrar ajustes de depuração"; + "show_debug_settings_subtitle" = "Exibe ferramentas de diagnóstico na aba Depuração."; + "surprise_me_title" = "Surpreenda-me"; + "surprise_me_subtitle" = "Veja se você gosta dos seus agentes se divertindo ali em cima."; + "weekly_limit_confetti_title" = "Confete do limite semanal"; + "weekly_limit_confetti_subtitle" = "Mostra confete em tela cheia quando o uso semanal for renovado."; + "hide_personal_info_title" = "Ocultar informações pessoais"; + "hide_personal_info_subtitle" = "Oculta endereços de e-mail na barra de menus e na UI do menu."; + "show_provider_storage_usage_title" = "Mostrar uso de armazenamento dos provedores"; + "show_provider_storage_usage_subtitle" = "Mostra o uso de disco local nos menus. Verifica em segundo plano caminhos conhecidos pertencentes aos provedores."; + "section_keychain_access" = "Acesso ao Keychain"; + "keychain_access_caption" = "Desativa todas as leituras e gravações do Keychain. A importação de cookies do navegador fica indisponível; cole cabeçalhos Cookie manualmente em Provedores."; + "disable_keychain_access_title" = "Desativar acesso ao Keychain"; + "disable_keychain_access_subtitle" = "Impede qualquer acesso ao Keychain quando ativado."; + /* About Pane */ "about_tagline" = "Que seus tokens nunca acabem — mantenha os limites dos agentes à vista."; + "link_github" = "GitHub"; + "link_website" = "Site"; + "link_twitter" = "Twitter"; + "link_email" = "E-mail"; + "check_updates_auto" = "Buscar atualizações automaticamente"; + "update_channel" = "Canal de atualização"; + "check_for_updates" = "Buscar atualizações…"; + "updates_unavailable" = "Atualizações indisponíveis nesta build."; + "copyright" = "© 2026 Peter Steinberger. Licença MIT."; + /* Debug Pane */ "section_logging" = "Logs"; + "enable_file_logging" = "Ativar logs em arquivo"; + "enable_file_logging_subtitle" = "Grava logs em %@ para depuração."; + "verbosity_title" = "Verbosidade"; + "verbosity_subtitle" = "Controla o nível de detalhe dos logs."; + "open_log_file" = "Abrir arquivo de log"; + "force_animation_next_refresh" = "Forçar animação na próxima atualização"; + "force_animation_next_refresh_subtitle" = "Mostra temporariamente a animação de carregamento após a próxima atualização."; + "section_loading_animations" = "Animações de carregamento"; + "loading_animations_caption" = "Escolha um padrão e reproduza na barra de menus. \"Aleatório\" mantém o comportamento atual."; + "animation_random_default" = "Aleatório (padrão)"; + "replay_selected_animation" = "Reproduzir animação selecionada"; + "blink_now" = "Piscar agora"; + "section_probe_logs" = "Logs de verificação"; + "probe_logs_caption" = "Busca a saída mais recente da verificação para depuração; Copiar mantém o texto completo."; + "fetch_log" = "Buscar log"; + "copy" = "Copiar"; + "save_to_file" = "Salvar em arquivo"; + "load_parse_dump" = "Carregar dump de análise"; + "rerun_provider_autodetect" = "Executar novamente a detecção automática de provedores"; + "loading" = "Carregando…"; + "no_log_yet_fetch" = "Ainda sem log. Busque para carregar."; + "section_fetch_strategy" = "Tentativas da estratégia de busca"; + "fetch_strategy_caption" = "Últimas decisões e erros do pipeline de busca de um provedor."; + "section_openai_cookies" = "Cookies da OpenAI"; + "openai_cookies_caption" = "Logs de importação de cookies + scraping WebKit da última tentativa de cookies da OpenAI."; + "no_log_yet" = "Ainda sem log. Atualize os cookies da OpenAI em Provedores → Codex para executar uma importação."; + "section_caches" = "Caches"; + "caches_caption" = "Limpa resultados de varredura de custo em cache ou caches de cookies do navegador."; + "clear_cookie_cache" = "Limpar cache de cookies"; + "clear_cost_cache" = "Limpar cache de custos"; + "section_notifications" = "Notificações"; + "notifications_caption" = "Aciona notificações de teste para a janela de sessão de 5 horas (esgotada/restaurada)."; + "post_depleted" = "Notificar esgotamento"; + "post_restored" = "Notificar restauração"; + "section_cli_sessions" = "Sessões da CLI"; + "cli_sessions_caption" = "Mantém sessões da CLI Codex/Claude ativas após uma verificação. Por padrão, sai assim que os dados são capturados."; + "keep_cli_sessions_alive" = "Manter sessões da CLI ativas"; + "keep_cli_sessions_alive_subtitle" = "Não encerra entre verificações (somente depuração)."; + "reset_cli_sessions" = "Reiniciar sessões da CLI"; + "section_error_simulation" = "Simulação de erro"; + "error_simulation_caption" = "Insere uma mensagem de erro falsa no card do menu para testar o layout."; + "set_menu_error" = "Definir erro do menu"; + "clear_menu_error" = "Limpar erro do menu"; + "set_cost_error" = "Definir erro de custo"; + "clear_cost_error" = "Limpar erro de custo"; + "section_cli_paths" = "Caminhos da CLI"; + "cli_paths_caption" = "Binário do Codex e camadas de PATH resolvidos; captura do PATH de login na inicialização (timeout curto)."; + "codex_binary" = "Binário do Codex"; + "claude_binary" = "Binário do Claude"; + "effective_path" = "PATH efetivo"; + "unavailable" = "Indisponível"; + "login_shell_path" = "PATH do shell de login (captura na inicialização)"; + "cleared" = "Limpo."; + "no_fetch_attempts" = "Ainda sem tentativas de busca."; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "O macOS Tahoe pode bloquear apps da barra de menus em Ajustes do Sistema → Barra de Menus → Permitir na Barra de Menus. O QuotaKit está em execução, mas o macOS pode estar ocultando seu ícone. Abra os ajustes da Barra de Menus e ative o QuotaKit."; + /* Metric preferences */ "metric_pref_automatic" = "Automático"; + "metric_pref_primary" = "Primário"; + "metric_pref_secondary" = "Secundário"; + "metric_pref_tertiary" = "Terciário"; + "metric_pref_extra_usage" = "Uso extra"; + "metric_pref_average" = "Média"; + /* Display modes */ "display_mode_percent" = "Porcentagem"; + "display_mode_pace" = "Ritmo"; + "display_mode_both" = "Ambos"; + "display_mode_percent_desc" = "Mostra a porcentagem restante/usada (ex.: 45%)"; + "display_mode_pace_desc" = "Mostra o indicador de ritmo (ex.: +5%)"; + "display_mode_both_desc" = "Mostra porcentagem e ritmo (ex.: 45% · +5%)"; + /* Provider status */ "status_operational" = "Operacional"; + "status_partial_outage" = "Falha parcial"; + "status_major_outage" = "Falha geral"; + "status_critical_issue" = "Problema crítico"; + "status_maintenance" = "Manutenção"; + "status_unknown" = "Status desconhecido"; + /* Refresh frequency */ "refresh_manual" = "Manual"; + "refresh_1min" = "1 min"; + "refresh_2min" = "2 min"; + "refresh_5min" = "5 min"; + "refresh_15min" = "15 min"; + "refresh_30min" = "30 min"; + /* Additional keys */ "not_found" = "Não encontrado"; + /* Cost estimation */ "cost_header_estimated" = "Custo (estimado)"; + "cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; + "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Nenhuma IDE JetBrains com AI Assistant detectada. Instale uma IDE JetBrains e ative o AI Assistant."; + "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token de API do OpenRouter não configurado. Defina a variável de ambiente OPENROUTER_API_KEY ou configure em Ajustes."; + "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Token de API do z.ai não encontrado. Defina apiKey em ~/.quotakit/config.json ou Z_AI_API_KEY."; + "Missing DeepSeek API key." = "Chave de API do DeepSeek ausente."; + "%@ is unavailable in the current environment." = "%@ está indisponível no ambiente atual."; + "All Systems Operational" = "Todos os sistemas operacionais"; + "Last 30 days" = "Últimos 30 dias"; + "Last 30 days:" = "Últimos 30 dias:"; + "This month" = "Este mês"; + "Store multiple OpenAI API keys." = "Armazena várias chaves de API da OpenAI."; + "Admin API key" = "Chave de API admin"; + "Open billing" = "Abrir faturamento"; + "Google accounts" = "Contas Google"; + "Store multiple Antigravity Google OAuth accounts for quick switching." = "Armazena várias contas Google OAuth do Antigravity para troca rápida."; + "Add Google Account" = "Adicionar conta Google"; + "Open Token Plan" = "Abrir Token Plan"; + "Text Generation" = "Geração de texto"; + "Text to Speech" = "Texto para fala"; + "Music Generation" = "Geração de música"; + "Image Generation" = "Geração de imagem"; + "No local data found" = "Nenhum dado local encontrado"; + "Credits unavailable; keep Codex running to refresh." = "Créditos indisponíveis; mantenha o Codex em execução para atualizar."; + "No available fetch strategy for minimax." = "Nenhuma estratégia de busca disponível para o minimax."; + "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Nenhuma sessão do Cursor encontrada. Faça login em cursor.com no Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Se você usa o Safari, conceda Acesso Total ao Disco ao QuotaKit em Ajustes do Sistema ▸ Privacidade e Segurança. Você também pode entrar no Cursor pelo menu do QuotaKit (Adicionar / trocar conta)."; + "No OpenCode session cookies found in browsers." = "Nenhum cookie de sessão do OpenCode encontrado nos navegadores."; + "No available fetch strategy for %@." = "Nenhuma estratégia de busca disponível para %@."; + "Today" = "Hoje"; + "Today tokens" = "Tokens de hoje"; + "30d cost" = "Custo 30 d"; + "30d tokens" = "Tokens 30 d"; + "Latest tokens" = "Tokens recentes"; + "Top model" = "Modelo principal"; + "Storage" = "Armazenamento"; + "Add Account..." = "Adicionar conta..."; + "Usage Dashboard" = "Dashboard de uso"; + "Status Page" = "Página de status"; + "Settings..." = "Ajustes..."; + "About CodexBar" = "Sobre o QuotaKit"; + "Quit" = "Encerrar"; + "Last %d day" = "Último %d dia"; + "Last %d days" = "Últimos %d dias"; + "%@ tokens" = "%@ tokens"; + "Latest billing day" = "Último dia de cobrança"; + "Latest billing day (%@)" = "Último dia de cobrança (%@)"; + "%@ left" = "%@ restante"; + "Resets %@" = "Renova %@"; + "Resets in %@" = "Renova em %@"; + "Resets now" = "Renova agora"; + "Lasts until reset" = "Dura até a renovação"; + "Updated %@" = "Atualizado %@"; + "Updated %@h ago" = "Atualizado há %@h"; + "Updated %@m ago" = "Atualizado há %@m"; + "Updated just now" = "Atualizado agora mesmo"; + "Projected empty in %@" = "Esgotamento previsto em %@"; + "Runs out in %@" = "Esgota em %@"; + "Pace: %@" = "Ritmo: %@"; + "Pace: %@ · %@" = "Ritmo: %@ · %@"; + "%@ · %@" = "%@ · %@"; + "≈ %d%% run-out risk" = "≈ %d%% de risco de esgotar"; + "%d%% in deficit" = "%d%% em déficit"; + "%d%% in reserve" = "%d%% em reserva"; + "usage_percent_suffix_left" = "restante"; + "usage_percent_suffix_used" = "usado"; + "Store multiple DeepSeek API keys." = "Armazena várias chaves de API do DeepSeek."; + "This week" = "Esta semana"; + "Week" = "Semana"; + "Month" = "Mês"; + "Models" = "Modelos"; + "24h tokens" = "Tokens 24 h"; + "Latest hour" = "Última hora"; + "Peak hour" = "Hora de pico"; + "Top method" = "Método principal"; + "30d cash" = "Dinheiro 30 d"; + "30d billing history from MiniMax web session" = "Histórico de cobrança de 30 dias da sessão web da MiniMax"; + "AWS Cost Explorer billing can lag." = "A cobrança do AWS Cost Explorer pode atrasar."; + "Rate limit: %d / %@" = "Limite de taxa: %d / %@"; + "Key remaining" = "Restante da chave"; + "No limit set for the API key" = "Nenhum limite configurado para a chave API"; + "API key limit unavailable right now" = "O limite da chave API está indisponível no momento"; + "This month: %@ tokens" = "Este mês: %@ tokens"; + "No utilization data yet." = "Ainda sem dados de uso."; + "No %@ utilization data yet." = "Ainda sem dados de uso de %@."; + "%@: %@%% used" = "%@: %@%% usado"; + "%dd" = "%dd"; + "today" = "hoje"; + "just now" = "agora mesmo"; + "On pace" = "No ritmo"; + "Runs out now" = "Esgota agora"; + "Projected empty now" = "Esgotamento previsto agora"; + "Switch Account..." = "Trocar conta..."; + "Update ready, restart now?" = "Atualização pronta, reiniciar agora?"; + "Daily" = "Diário"; + "Hourly Tokens" = "Tokens por hora"; + "No data" = "Sem dados"; + "No usage breakdown data available." = "Nenhum dado de detalhamento de uso disponível."; + "Today: %@ · %@ tokens" = "Hoje: %@ · %@ tokens"; + "Today: %@" = "Hoje: %@"; + "Today: %@ tokens" = "Hoje: %@ tokens"; + "Last 30 days: %@ · %@ tokens" = "Últimos 30 dias: %@ · %@ tokens"; + "Last 30 days: %@" = "Últimos 30 dias: %@"; + "Est. total (30d): %@" = "Total est. (30d): %@"; + "Est. total (%@): %@" = "Total est. (%@): %@"; + "Hover a bar for details" = "Passe o mouse sobre uma barra para ver detalhes"; + "%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; + "No providers selected for Overview." = "Nenhum provedor selecionado para a Visão geral."; + "No overview data available." = "Nenhum dado de visão geral disponível."; + "Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Automático usa primeiro a API local da IDE e depois o Google OAuth quando a IDE está fechada."; + "Login with Google" = "Entrar com o Google"; + /* Popup panels */ "No usage configured." = "Nenhum uso configurado."; + "Quota" = "Cota"; + "tokens" = "tokens"; + "requests" = "requisições"; + "Latest" = "Mais recente"; + "Monthly" = "Mensal"; + "Sonnet" = "Sonnet"; + "Overages" = "Excedentes"; + "Activity" = "Atividade"; + "Copied" = "Copiado"; + "Copy error" = "Erro ao copiar"; + "Copy path" = "Copiar caminho"; + "Extra usage spent" = "Gasto de uso extra"; + "Credits remaining" = "Créditos restantes"; + "Using CLI fallback" = "Usando fallback da CLI"; + "Balance updates in near-real time (up to 5 min lag)" = "O saldo atualiza quase em tempo real (até 5 min de atraso)"; + "Daily billing data finalizes at 07:00 UTC" = "Os dados diários de cobrança fecham às 07:00 UTC"; + "%@ of %@ credits left" = "Restam %@ de %@ créditos"; + "%@ of %@ bonus credits left" = "Restam %@ de %@ créditos bônus"; + "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; + "%@/%@ left" = "%@/%@ restante"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "Regenera %@"; + "used after next regen" = "usado após a próxima regeneração"; + "after next regen" = "após a próxima regeneração"; + "Near full" = "Quase cheio"; + "Full in ~1 regen" = "Cheio em ~1 regeneração"; + "Full in ~%.0f regens" = "Cheio em ~%.0f regenerações"; + "Overage usage" = "Uso excedente"; + "Overage cost" = "Custo excedente"; + "credits" = "créditos"; + "Zen balance" = "Saldo Zen"; + "API spend" = "Gasto de API"; + "Extra usage" = "Uso extra"; + "Quota usage" = "Uso da cota"; + "%.0f%% used" = "%.0f%% usado"; + "Usage history (today)" = "Histórico de uso (hoje)"; + "Usage history (%d days)" = "Histórico de uso (%d dias)"; + "%d percent remaining" = "%d%% restante"; + "Unknown" = "Desconhecido"; + "stale data" = "dados desatualizados"; + "No credits history data." = "Sem dados de histórico de créditos."; + "No credits history data available." = "Nenhum dado de histórico de créditos disponível."; + "Credits history chart" = "Gráfico de histórico de créditos"; + "%d days of credits data" = "%d dias de dados de créditos"; + "Usage breakdown chart" = "Gráfico de detalhamento de uso"; + "%d days of usage data across %d services" = "%d dias de dados de uso em %d serviços"; + "Cost history chart" = "Gráfico de histórico de custos"; + "%d days of cost data" = "%d dias de dados de custos"; + "Plan utilization chart" = "Gráfico de utilização do plano"; + "%d utilization samples" = "%d amostras de utilização"; + "Hourly Usage" = "Uso por hora"; + "Usage remaining" = "Uso restante"; + "Usage used" = "Uso usado"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "Chave de API verificada. O Ollama não expõe limites de cota do Cloud pela API."; + "Last 30 days: %@ tokens" = "Últimos 30 dias: %@ tokens"; + "7d spend" = "Gasto 7 d"; + "30d spend" = "Gasto 30 d"; + "Cache read" = "Leitura de cache"; + "Claude Admin API 30 day spend trend" = "Tendência de gasto de 30 dias da Claude Admin API"; + "OpenRouter API key spend trend" = "Tendência de gasto da chave API do OpenRouter"; + "z.ai hourly token trend" = "Tendência horária de tokens da z.ai"; + "MiniMax 30 day token usage trend" = "Tendência de uso de tokens de 30 dias da MiniMax"; + "Today cash" = "Dinheiro de hoje"; + "DeepSeek 30 day token usage trend" = "Tendência de uso de tokens de 30 dias da DeepSeek"; + "cache-hit input" = "entrada com acerto de cache"; + "cache-miss input" = "entrada sem acerto de cache"; + "output" = "saída"; + "Requests" = "Requisições"; + "Reported by OpenAI Admin API organization usage." = "Reportado pelo uso da organização na OpenAI Admin API."; + "Reported by Mistral billing usage." = "Reportado pelo uso de cobrança da Mistral."; + "Google OAuth" = "Google OAuth"; + "Add accounts via GitHub OAuth Device Flow on the selected host." = "Adiciona contas via GitHub OAuth Device Flow no host selecionado."; + "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Armazena cada conta Google conectada para troca rápida no Antigravity. Usa o OAuth do Antigravity.app quando disponível, ou ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET como substituição."; + "Manual cleanup: past sessions" = "Limpeza manual: sessões anteriores"; + "Clearing removes past resume, continue, and rewind history." = "A limpeza remove o histórico de retomar, continuar e voltar."; + "Manual cleanup: file checkpoints" = "Limpeza manual: checkpoints de arquivo"; + "Clearing removes checkpoint restore data for previous edits." = "A limpeza remove os dados de restauração de checkpoint de edições anteriores."; + "Manual cleanup: saved plans" = "Limpeza manual: planos salvos"; + "Clearing removes old plan-mode files." = "A limpeza remove arquivos antigos do modo de planejamento."; + "Manual cleanup: debug logs" = "Limpeza manual: logs de depuração"; + "Clearing removes past debug logs." = "A limpeza remove logs de depuração anteriores."; + "Manual cleanup: attachment cache" = "Limpeza manual: cache de anexos"; + "Clearing removes cached large pastes or attached images." = "A limpeza remove colagens grandes ou imagens anexadas em cache."; + "Manual cleanup: session metadata" = "Limpeza manual: metadados de sessão"; + "Clearing removes per-session environment metadata." = "A limpeza remove os metadados de ambiente por sessão."; + "Manual cleanup: shell snapshots" = "Limpeza manual: snapshots de shell"; + "Clearing removes leftover runtime shell snapshot files." = "A limpeza remove arquivos de snapshot de shell de runtime remanescentes."; + "Manual cleanup: legacy todos" = "Limpeza manual: tarefas legadas"; + "Clearing removes legacy per-session task lists." = "A limpeza remove listas de tarefas legadas por sessão."; + "Manual cleanup: sessions" = "Limpeza manual: sessões"; + "Clearing removes past Codex session history." = "A limpeza remove o histórico de sessões anteriores do Codex."; + "Manual cleanup: archived sessions" = "Limpeza manual: sessões arquivadas"; + "Clearing removes archived Codex session history." = "A limpeza remove o histórico de sessões arquivadas do Codex."; + "Manual cleanup: cache" = "Limpeza manual: cache"; + "Clearing removes provider-owned cached data." = "A limpeza remove dados em cache pertencentes ao provedor."; + "Manual cleanup: logs" = "Limpeza manual: logs"; + "Clearing removes local diagnostic logs." = "A limpeza remove logs de diagnóstico locais."; + "Manual cleanup: file history" = "Limpeza manual: histórico de arquivos"; + "Clearing removes local edit checkpoint history." = "A limpeza remove o histórico local de checkpoints de edição."; + "Manual cleanup: temporary data" = "Limpeza manual: dados temporários"; + "Clearing removes local temporary provider data." = "A limpeza remove dados temporários locais do provedor."; + "Total: %@" = "Total: %@"; + "%d more items" = "Mais %d itens"; + "Cleanup ideas" = "Ideias de limpeza"; + "%d unreadable item(s) skipped" = "%d item(ns) ilegível(is) ignorado(s)"; + "API key limit" = "Limite da chave API"; + "Auth" = "Autenticação"; + "Auto" = "Automático"; + "Disabled — no recent data" = "Desativado — sem dados recentes"; + "Limits not available" = "Limites indisponíveis"; + "No usage yet" = "Ainda sem uso"; + "Not fetched yet" = "Ainda não buscado"; + "Refreshing" = "Atualizando"; + "Session" = "Sessão"; + "Source" = "Fonte"; + "State" = "Estado"; + "Unavailable" = "Indisponível"; + "Weekly" = "Semanal"; + "not detected" = "não detectado"; + "Estimated from local Codex logs for the selected account." = "Estimado a partir de logs locais do Codex para a conta selecionada."; + "minimax_usage_amount_format" = "Uso: %@ / %@"; + "minimax_used_percent_format" = "Usado %@"; + "minimax_service_text_generation" = "Geração de texto"; + "minimax_service_text_to_speech" = "Texto para fala"; + "minimax_service_music_generation" = "Geração de música"; + "minimax_service_image_generation" = "Geração de imagem"; + "minimax_service_lyrics_generation" = "Geração de letras"; + "minimax_service_coding_plan_vlm" = "VLM do Coding Plan"; + "minimax_service_coding_plan_search" = "Busca do Coding Plan"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ está aguardando permissão"; + "%@ requests" = "%@ solicitações"; + "%@: %@ credits" = "%@: %@ créditos"; + "30d requests" = "Solicitações de 30 dias"; + "4 days" = "4 dias"; + "5 days" = "5 dias"; + "7 days" = "7 dias"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "A chave de API verifica o acesso ao Ollama Cloud; os cookies ainda expõem limites de cota."; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID da chave de acesso da AWS. Também pode ser definido com AWS_ACCESS_KEY_ID."; + "AWS region. Can also be set with AWS_REGION." = "Região da AWS. Também pode ser definida com AWS_REGION."; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Chave secreta de acesso da AWS. Também pode ser definida com AWS_SECRET_ACCESS_KEY."; + "Access key ID" = "ID da chave de acesso"; + "Add Account" = "Adicionar conta"; + "Adding Account…" = "Adicionando conta…"; + "Antigravity login failed" = "Falha no login do Antigravity"; + "Antigravity login timed out" = "Tempo esgotado no login do Antigravity"; + "Auth source" = "Fonte de autenticação"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do Chrome do Xiaomi MiMo."; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente dados de sessão do Windsurf do localStorage do Chromium."; + "Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador do Bailian."; + "Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; + "Automatically imports browser session cookies." = "Importa automaticamente cookies de sessão do navegador."; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nome do deployment do Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME também é aceito."; + "Azure OpenAI key" = "Chave do Azure OpenAI"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint do recurso Azure OpenAI. AZURE_OPENAI_ENDPOINT também é aceito."; + "Base URL" = "URL base"; + "Base URL for the LLM-API-Key-Proxy instance." = "URL base da instância LLM-API-Key-Proxy."; + "Browser cookies" = "Cookies do navegador"; + "Cap end" = "Fim do limite"; + "Cap start" = "Início do limite"; + "Capacity End" = "Fim da capacidade"; + "Capacity Start" = "Início da capacidade"; + "Changelog" = "Registro de alterações"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Escolha o host da API Moonshot/Kimi para contas internacionais ou da China continental."; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "O QuotaKit não pode substituir uma conta do sistema conectada apenas com chave de API."; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "O QuotaKit não encontrou autenticação salva para essa conta. Reautentique e tente novamente."; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "O QuotaKit não conseguiu ler o armazenamento de contas gerenciadas. Recupere o armazenamento antes de adicionar outra conta."; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "O QuotaKit não conseguiu ler a autenticação salva dessa conta. Reautentique e tente novamente."; + "CodexBar could not read the current system account on this Mac." = "O QuotaKit não conseguiu ler a conta do sistema atual neste Mac."; + "CodexBar could not replace the live Codex auth on this Mac." = "O QuotaKit não conseguiu substituir a autenticação ativa do Codex neste Mac."; + "CodexBar could not safely preserve the current system account before switching." = "O QuotaKit não conseguiu preservar com segurança a conta do sistema atual antes da troca."; + "CodexBar could not save the current system account before switching." = "O QuotaKit não conseguiu salvar a conta do sistema atual antes da troca."; + "CodexBar could not update managed account storage." = "O QuotaKit não conseguiu atualizar o armazenamento de contas gerenciadas."; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "O QuotaKit encontrou outra conta gerenciada que já usa a conta do sistema atual. Resolva a conta duplicada antes de trocar."; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS “%@” para descriptografar cookies do navegador e autenticar sua conta. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o token OAuth do Claude Code para buscar seu uso do Claude. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do Amp para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do Augment para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do Claude para buscar uso web do Claude. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do Cursor para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do Factory para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o token do GitHub Copilot para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS a chave API do Kimi K2 para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o token de autenticação do Kimi para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o token API do MiniMax para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do MiniMax para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie da OpenAI para buscar extras do painel Codex. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o cabeçalho Cookie do OpenCode para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS a chave API do Synthetic para buscar uso. Clique em OK para continuar."; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "O QuotaKit pedirá ao Chaves do macOS o token API do z.ai para buscar uso. Clique em OK para continuar."; + "Could not open Cursor login in your browser." = "Não foi possível abrir o login do Cursor no navegador."; + "Could not open browser for Antigravity" = "Não foi possível abrir o navegador para Antigravity"; + "Credits used" = "Créditos usados"; + "Day" = "Dia"; + "Deployment" = "Deployment"; + "Drag to reorder" = "Arraste para reordenar"; + "Endpoint" = "Endpoint"; + "Enterprise host" = "Host Enterprise"; + "Extra usage balance: %@" = "Saldo de uso extra: %@"; + "Keychain Access Required" = "Acesso ao Chaves necessário"; + "Kiro menu bar value" = "Valor do Kiro na barra de menu"; + "Label" = "Rótulo"; + "No organizations loaded. Click Refresh after setting your API key." = "Nenhuma organização carregada. Clique em Atualizar depois de definir sua chave API."; + "No output captured." = "Nenhuma saída capturada."; + "No system account" = "Sem conta do sistema"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "Abrir Augment (sair e entrar novamente)"; + "Open Codebuff Dashboard" = "Abrir painel do Codebuff"; + "Open Command Code Settings" = "Abrir configurações do Command Code"; + "Open Crof dashboard" = "Abrir painel do Crof"; + "Open Manus" = "Abrir Manus"; + "Open MiMo Balance" = "Abrir saldo do MiMo"; + "Open Moonshot Console" = "Abrir console do Moonshot"; + "Open Ollama API Keys" = "Abrir chaves API do Ollama"; + "Open StepFun Platform" = "Abrir plataforma StepFun"; + "Open T3 Chat Settings" = "Abrir configurações do T3 Chat"; + "Open Volcengine Ark Console" = "Abrir console Volcengine Ark"; + "Open legacy provider docs" = "Abrir docs do provedor legado"; + "Open projects" = "Abrir projetos"; + "Open this URL manually to continue login:\n\n%@" = "Abra esta URL manualmente para continuar o login:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organização opcional para contas vinculadas a várias organizações Anthropic."; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Aplica-se à chave Admin API configurada; contas de token selecionadas não herdam OPENAI_PROJECT_ID."; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Informe seu host GitHub Enterprise, por exemplo octocorp.ghe.com. Deixe em branco para github.com."; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixe em branco para descobrir e agregar projetos visíveis à chave API."; + "Org ID (optional)" = "ID da org. (opcional)"; + "Organizations" = "Organizações"; + "Password" = "Senha"; + "%@ authentication is disabled." = "A autenticação de %@ está desativada."; + "%@ cookies are disabled." = "Os cookies de %@ estão desativados."; + "%@ web API access is disabled." = "O acesso à API web de %@ está desativado."; + "Disable %@ dashboard cookie usage." = "Desativar o uso de cookies do painel de %@."; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "O acesso ao Chaves está desativado em Avançado, então a importação de cookies do navegador está indisponível."; + "Manually paste an %@ from a browser session." = "Cole manualmente um %@ de uma sessão do navegador."; + "Paste a Cookie header captured from %@." = "Cole um cabeçalho Cookie capturado de %@."; + "Paste a Cookie header from %@." = "Cole um cabeçalho Cookie de %@."; + "Paste a Cookie header or cURL capture from %@." = "Cole um cabeçalho Cookie ou captura cURL de %@."; + "Paste a Cookie header or full cURL capture from %@." = "Cole um cabeçalho Cookie ou captura cURL completa de %@."; + "Paste a Cookie or Authorization header from %@." = "Cole um cabeçalho Cookie ou Authorization de %@."; + "Paste a full cookie header or the %@ value." = "Cole um cabeçalho de cookies completo ou o valor %@."; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Cole um cabeçalho Cookie ou captura cURL completa das configurações do T3 Chat."; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Cole o cabeçalho Cookie de uma solicitação a admin.mistral.ai. Deve conter um cookie ory_session_*."; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Cole o Oasis-Token de uma sessão conectada em platform.stepfun.com."; + "Paste the %@ JSON bundle from %@." = "Cole o pacote JSON %@ de %@."; + "Paste the %@ value or a full Cookie header." = "Cole o valor %@ ou um cabeçalho Cookie completo."; + "Personal account" = "Conta pessoal"; + "Project ID" = "ID do projeto"; + "Re-auth" = "Reautenticar"; + "Re-authenticating…" = "Reautenticando…"; + "Refresh Session" = "Atualizar sessão"; + "Refresh organizations" = "Atualizar organizações"; + "Region" = "Região"; + "Reload" = "Recarregar"; + "Reorder" = "Reordenar"; + "Secret access key" = "Chave secreta de acesso"; + "Series" = "Série"; + "Service" = "Serviço"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra ou oculta créditos Kiro, porcentagem ou ambos ao lado do ícone da barra de menu."; + "Show usage for organizations you belong to. Personal account is always shown." = "Mostra o uso das organizações às quais você pertence. A conta pessoal sempre é exibida."; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Entre em cursor.com no navegador e atualize Cursor no QuotaKit."; + "Simulated error text" = "Texto de erro simulado"; + "StepFun platform account (phone number or email)." = "Conta da plataforma StepFun (telefone ou email)."; + "Stored in ~/.codexbar/config.json." = "Armazenado em ~/.quotakit/config.json."; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Armazenado em ~/.quotakit/config.json. AZURE_OPENAI_API_KEY também é aceito."; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Armazenado em ~/.quotakit/config.json. Para a API oficial do Kimi, use Moonshot / Kimi API."; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Armazenado em ~/.quotakit/config.json. Obtenha sua chave API no console Volcengine Ark."; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Armazenado em ~/.quotakit/config.json. Obtenha sua chave nas configurações do Ollama."; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Armazenado em ~/.quotakit/config.json. Obtenha sua chave em console.deepgram.com."; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Armazenado em ~/.quotakit/config.json. Obtenha sua chave em elevenlabs.io/app/settings/api-keys."; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Armazenado em ~/.quotakit/config.json. Obtenha sua chave em openrouter.ai/settings/keys e defina um limite de gasto para ativar o rastreamento de cota."; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Armazenado em ~/.quotakit/config.json. No Warp, abra Settings > Platform > API Keys e crie uma."; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Armazenado em ~/.quotakit/config.json. As métricas exigem acesso ao Groq Enterprise Prometheus."; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Armazenado em ~/.quotakit/config.json. OPENAI_ADMIN_KEY é preferida; OPENAI_API_KEY ainda funciona."; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Armazenado em ~/.quotakit/config.json. Requer uma chave Anthropic Admin API."; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Armazenado em ~/.quotakit/config.json. Usado para /v1/quota-stats."; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Armazenado em ~/.quotakit/config.json. Você também pode fornecer CODEBUFF_API_KEY ou permitir que o QuotaKit leia ~/.config/manicode/credentials.json (criado por `codebuff login`)."; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Armazenado em ~/.quotakit/config.json. Você também pode fornecer CROF_API_KEY."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Armazenado em ~/.quotakit/config.json. Você também pode fornecer KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; + "T3 Chat cookie" = "Cookie do T3 Chat"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "Essa conta não está mais disponível no QuotaKit. Atualize a lista de contas e tente novamente."; + "The browser login did not complete in time. Try Antigravity login again." = "O login no navegador não foi concluído a tempo. Tente o login do Antigravity novamente."; + "Timed out waiting for Cursor login. %@" = "Tempo esgotado aguardando o login do Cursor. %@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "Tempo esgotado aguardando o login do Cursor. %@ Último erro: %@"; + "Today requests" = "Solicitações de hoje"; + "Total (30d): %@ credits" = "Total (30 dias): %@ créditos"; + "Username" = "Nome de usuário"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nome de usuário e senha para entrar e obter um Oasis-Token automaticamente."; + "Uses username + password to login and obtain an %@ automatically." = "Usa nome de usuário e senha para entrar e obter um %@ automaticamente."; + "Utilization End" = "Fim da utilização"; + "Utilization Start" = "Início da utilização"; + "Verbosity" = "Detalhamento"; + "Windsurf session JSON bundle" = "Pacote JSON de sessão do Windsurf"; + "Workspace ID" = "ID do workspace"; + "Your StepFun platform password. Used to login and obtain a session token." = "Sua senha da plataforma StepFun. Usada para entrar e obter um token de sessão."; + "claude /login exited with status %d." = "claude /login saiu com status %d."; + "codex login exited with status %d." = "codex login saiu com status %d."; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nou cole uma captura cURL do painel Abacus AI"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nou cole o valor de __Secure-next-auth.session-token"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nou cole o valor do token kimi-auth"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou cole apenas o valor de session_id"; + "Clear" = "Limpar"; + "No matching providers" = "Nenhum provedor correspondente"; + "Search providers" = "Buscar provedores"; + + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 15722161f..ae6e96ed1 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1,1060 +1,2096 @@ /* Swedish localization for CodexBar */ " providers" = " leverantörer"; + "(System)" = "(System)"; + "30d" = "30 d"; + "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; + "API key" = "API-nyckel"; + "API region" = "API-region"; + "API token" = "API-token"; + "API tokens" = "API-token"; + "About" = "Om"; + "Account" = "Konto"; + "Accounts" = "Konton"; + "Accounts subtitle" = "Kontounderrubrik"; + "Active" = "Aktiv"; + "Add" = "Lägg till"; + "Add Workspace" = "Lägg till arbetsyta"; + "Advanced" = "Avancerat"; + "All" = "Alla"; + "Always allow prompts" = "Tillåt alltid uppmaningar"; + "Animation pattern" = "Animationsmönster"; + "Antigravity login is managed in the app" = "Antigravity-inloggning hanteras i appen"; + "Applies only to the Security.framework OAuth keychain reader." = "Gäller bara OAuth-läsaren för Nyckelring via Security.framework."; + "Auto falls back to the next source if the preferred one fails." = "Auto går vidare till nästa källa om den föredragna misslyckas."; + "Auto uses API first, then falls back to CLI on auth failures." = "Auto använder API först och går sedan över till CLI vid autentiseringsfel."; + "Auto-detect" = "Identifiera automatiskt"; + "Auto-refresh is off; use the menu's Refresh command." = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; + "Auto-refresh: hourly · Timeout: 10m" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; + "Automatic" = "Automatiskt"; + "Automatic imports browser cookies and WorkOS tokens." = "Importerar webbläsarcookies och WorkOS-token automatiskt."; + "Automatic imports browser cookies and local storage tokens." = "Importerar webbläsarcookies och token från lokal lagring automatiskt."; + "Automatic imports browser cookies for dashboard extras." = "Importerar webbläsarcookies automatiskt för extra instrumentpanelsdata."; + "Automatic imports browser cookies for the web API." = "Importerar webbläsarcookies automatiskt för webb-API:t."; + "Automatic imports browser cookies from Model Studio/Bailian." = "Importerar webbläsarcookies automatiskt från Model Studio/Bailian."; + "Automatic imports browser cookies from admin.mistral.ai." = "Importerar webbläsarcookies automatiskt från admin.mistral.ai."; + "Automatic imports browser cookies from opencode.ai." = "Importerar webbläsarcookies automatiskt från opencode.ai."; + "Automatic imports browser cookies or stored sessions." = "Importerar webbläsarcookies eller sparade sessioner automatiskt."; + "Automatic imports browser cookies." = "Importerar webbläsarcookies automatiskt."; + "Automatically imports browser session cookie." = "Importerar webbläsarens sessionscookie automatiskt."; + "Automatically opens CodexBar when you start your Mac." = "Öppnar QuotaKit automatiskt när du startar din Mac."; + "Automation" = "Automatisering"; + "Average (\\(label1) + \\(label2))" = "Genomsnitt (\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Genomsnitt (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "Undvik frågor från Nyckelring"; + "Balance" = "Saldo"; + "Battery Saver" = "Batterisparläge"; + "Bordered" = "Med ram"; + "Build" = "Bygge"; + "Built \\(buildTimestamp)" = "Byggd \\(buildTimestamp)"; + "Buy Credits..." = "Köp krediter..."; + "Buy Credits…" = "Köp krediter…"; + "CLI paths" = "CLI-sökvägar"; + "CLI sessions" = "CLI-sessioner"; + "Caches" = "Cachar"; + "Cancel" = "Avbryt"; + "Check for Updates…" = "Sök efter uppdateringar…"; + "Check for updates automatically" = "Sök efter uppdateringar automatiskt"; + "Check if you like your agents having some fun up there." = "Kontrollera om du vill att agenterna ska få leka lite där uppe."; + "Check provider status" = "Kontrollera leverantörsstatus"; + "Choose Codex workspace" = "Välj Codex-arbetsyta"; + "Choose the MiniMax host (global .io or China mainland .com)." = "Välj MiniMax-värd (global .io eller Fastlandskina .com)."; + "Choose up to " = "Välj upp till "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "Välj upp till \\(Self.maxOverviewProviders) leverantörer"; + "Choose up to \\(count) providers" = "Välj upp till \\(count) leverantörer"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Välj vad som ska visas i menyraden (takt visar användning mot förväntat)."; + "Choose which Codex account CodexBar should follow." = "Välj vilket Codex-konto QuotaKit ska följa."; + "Choose which window drives the menu bar percent." = "Välj vilket fönster som styr procenttalet i menyraden."; + "Chrome" = "Chrome"; + "Claude CLI not found" = "Claude CLI hittades inte"; + "Claude binary" = "Claude-binär"; + "Claude cookies" = "Claude-cookies"; + "Claude login failed" = "Claude-inloggning misslyckades"; + "Claude login timed out" = "Claude-inloggning tog för lång tid"; + "Close" = "Stäng"; + "Code review" = "Kodgranskning"; + "Codex CLI not found" = "Codex CLI hittades inte"; + "Codex account login already running" = "Codex-kontoinloggning körs redan"; + "Codex binary" = "Codex-binär"; + "Codex login failed" = "Codex-inloggning misslyckades"; + "Codex login timed out" = "Codex-inloggning tog för lång tid"; + "CodexBar Lifecycle Keepalive" = "QuotaKit livscykel-keepalive"; + "QuotaKit can't show its menu bar icon" = "QuotaKit kan inte visa sin menyradsikon"; + "CodexBar could not read managed account storage. " = "QuotaKit kunde inte läsa hanterad kontolagring. "; + "Configure…" = "Konfigurera…"; + "Connected" = "Ansluten"; + "Controls how much detail is logged." = "Styr hur detaljerad loggningen är."; + "Cookie header" = "Cookie-header"; + "Cookie source" = "Cookie-källa"; + "Cookie: ..." = "Cookie: ..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\neller klistra in en cURL-fångst från Abacus AI-instrumentpanelen"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\neller klistra in värdet för __Secure-next-auth.session-token"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\neller klistra in värdet för kimi-auth-token"; + "Cookie: …" = "Cookie: …"; + "CopilotDeviceFlow" = "CopilotDeviceFlow"; + "Cost" = "Kostnad"; + "Could not add Codex account" = "Kunde inte lägga till Codex-konto"; + "Could not open Terminal for Gemini" = "Kunde inte öppna Terminal för Gemini"; + "Could not start claude /login" = "Kunde inte starta claude /login"; + "Could not start codex login" = "Kunde inte starta codex login"; + "Could not switch system account" = "Kunde inte byta systemkonto"; + "Credits" = "Krediter"; + "Credits history" = "Kredithistorik"; + "Cursor login failed" = "Cursor-inloggning misslyckades"; + "Custom" = "Anpassat"; + "Custom Path" = "Anpassad sökväg"; + "Daily Routines" = "Dagliga rutiner"; + "Debug" = "Felsök"; + "Default" = "Standard"; + "Disable Keychain access" = "Inaktivera åtkomst till Nyckelring"; + "Disabled" = "Inaktiverad"; + "Dismiss" = "Stäng"; + "Disconnected" = "Frånkopplad"; + "Display" = "Visning"; + "Display mode" = "Visningsläge"; + "Display reset times as absolute clock values instead of countdowns." = "Visa återställningstider som klockslag i stället för nedräkningar."; + "Done" = "Klar"; + "Effective PATH" = "Effektiv PATH"; + "Email" = "E-post"; + "Enable Merge Icons to configure Overview tab providers." = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; + "Enable file logging" = "Aktivera filloggning"; + "Enabled" = "Aktiverad"; + "Error" = "Fel"; + "Error simulation" = "Felsimulering"; + "Expose troubleshooting tools in the Debug tab." = "Visa felsökningsverktyg på fliken Felsök."; + "Failed" = "Misslyckades"; + "False" = "Falskt"; + "Fetch strategy attempts" = "Försök med hämtningsstrategi"; + "Fetching" = "Hämtar"; + "Field" = "Fält"; + "Field subtitle" = "Fältunderrubrik"; + "Finish the current managed account change before switching the system account." = "Slutför den pågående hanterade kontoändringen innan du byter systemkonto."; + "Force animation on next refresh" = "Tvinga animation vid nästa uppdatering"; + "Gateway region" = "Gateway-region"; + "Gemini CLI not found" = "Gemini CLI hittades inte"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity och visar incidenter i ikonen och menyn."; + "General" = "Allmänt"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "GitHub Copilot-inloggning"; + "GitHub Login" = "GitHub-inloggning"; + "Hide details" = "Dölj detaljer"; + "Hide personal information" = "Dölj personuppgifter"; + "Historical tracking" = "Historisk spårning"; + "How often CodexBar polls providers in the background." = "Hur ofta QuotaKit kontrollerar leverantörer i bakgrunden."; + "Inactive" = "Inaktiv"; + "Install CLI" = "Installera CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installera Claude CLI (npm i -g @anthropic-ai/claude-code) och försök igen."; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installera Codex CLI (npm i -g @openai/codex) och försök igen."; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installera Gemini CLI (npm i -g @google/gemini-cli) och försök igen."; + "JetBrains AI is ready" = "JetBrains AI är redo"; + "JetBrains IDE" = "JetBrains IDE"; + "Keep CLI sessions alive" = "Håll CLI-sessioner vid liv"; + "Keyboard shortcut" = "Kortkommando"; + "Keychain access" = "Åtkomst till Nyckelring"; + "Keychain prompt policy" = "Policy för frågor från Nyckelring"; + "Last \\(name) fetch failed:" = "Senaste hämtningen för \\(name) misslyckades:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Senaste hämtningen för \\(self.store.metadata(for: self.provider).displayName) misslyckades:"; + "Last attempt" = "Senaste försök"; + "Link" = "Länk"; + "Loading animations" = "Laddningsanimationer"; + "Loading…" = "Läser in…"; + "Local" = "Lokal"; + "Logging" = "Loggning"; + "Login failed" = "Inloggningen misslyckades"; + "Login shell PATH (startup capture)" = "Inloggningsskalets PATH (fångad vid start)"; + "Login timed out" = "Inloggningen tog för lång tid"; + "MCP details" = "MCP-detaljer"; + "Managed Codex accounts unavailable" = "Hanterade Codex-konton är inte tillgängliga"; + "Managed account storage is unreadable. Live account access is still available, " = "Hanterad kontolagring går inte att läsa. Direkt kontoåtkomst är fortfarande tillgänglig, "; + "Manual" = "Manuellt"; + "May your tokens never run out—keep agent limits in view." = "Må dina token aldrig ta slut – håll agentgränserna synliga."; + "Menu bar" = "Menyrad"; + "Menu bar auto-shows the provider closest to its rate limit." = "Menyraden visar automatiskt leverantören som ligger närmast sin gräns."; + "Menu bar metric" = "Menyradsmått"; + "Menu bar shows percent" = "Menyraden visar procent"; + "Menu content" = "Menyinnehåll"; + "Merge Icons" = "Slå ihop ikoner"; + "Never prompt" = "Fråga aldrig"; + "No" = "Nej"; + "No Codex accounts detected yet." = "Inga Codex-konton har hittats än."; + "No JetBrains IDE detected" = "Ingen JetBrains IDE hittades"; + "No cost history data." = "Ingen kostnadshistorik."; + "No credits history data." = "Ingen kredithistorik."; + "No data available" = "Inga data tillgängliga"; + "No data yet" = "Inga data än"; + "No enabled providers available for Overview." = "Inga aktiverade leverantörer är tillgängliga för översikten."; + "No providers selected" = "Inga leverantörer valda"; + "No token accounts yet." = "Inga tokenkonton än."; + "No usage breakdown data." = "Ingen användningsuppdelning."; + "None" = "Ingen"; + "Notifications" = "Aviseringar"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "Aviserar när femtimmarssessionens kvot når 0 % och när den blir "; + "OK" = "OK"; + "Obscure email addresses in the menu bar and menu UI." = "Maskera e-postadresser i menyraden och menygränssnittet."; + "Off" = "Av"; + "Offline" = "Offline"; + "On" = "På"; + "Online" = "Online"; + "Only on user action" = "Bara vid användaråtgärd"; + "Open" = "Öppna"; + "Open API Keys" = "Öppna API-nycklar"; + "Open Amp Settings" = "Öppna Amp-inställningar"; + "Open Antigravity to sign in, then refresh CodexBar." = "Öppna Antigravity för att logga in och uppdatera sedan QuotaKit."; + "Open Browser" = "Öppna webbläsare"; + "Open Coding Plan" = "Öppna Coding Plan"; + "Open Console" = "Öppna konsol"; + "Open Dashboard" = "Öppna instrumentpanel"; + "Open Mistral Admin" = "Öppna Mistral Admin"; + "Open Menu Bar Settings" = "Öppna inställningar för menyraden"; + "Open Ollama Settings" = "Öppna Ollama-inställningar"; + "Open Terminal" = "Öppna Terminal"; + "Open Usage Page" = "Öppna användningssida"; + "Open Warp API Key Guide" = "Öppna guide för Warp API-nyckel"; + "Open menu" = "Öppna meny"; + "Open token file" = "Öppna tokenfil"; + "OpenAI cookies" = "OpenAI-cookies"; + "OpenAI web extras" = "OpenAI-webbtillägg"; + "Option A" = "Alternativ A"; + "Option B" = "Alternativ B"; + "Optional override if workspace lookup fails." = "Valfri ersättning om sökning efter arbetsyta misslyckas."; + "Options" = "Alternativ"; + "Override auto-detection with a custom IDE base path" = "Ersätt automatisk identifiering med en anpassad bas-sökväg till IDE:n"; + "Overview" = "Översikt"; + "Overview rows always follow provider order." = "Översiktsrader följer alltid leverantörsordningen."; + "Overview tab providers" = "Leverantörer på översiktsfliken"; + "Paste API key…" = "Klistra in API-nyckel…"; + "Paste API token…" = "Klistra in API-token…"; + "Paste key…" = "Klistra in nyckel…"; + "Paste sessionKey or OAuth token…" = "Klistra in sessionKey eller OAuth-token…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "Klistra in Cookie-headern från en förfrågan till admin.mistral.ai. "; + "Paste token…" = "Klistra in token…"; + "Personal" = "Personligt"; + "Picker" = "Väljare"; + "Picker subtitle" = "Väljarunderrubrik"; + "Placeholder" = "Platshållare"; + "Plan" = "Plan"; + "Play full-screen confetti when weekly usage resets." = "Spela konfetti i helskärm när veckoförbrukningen återställs."; + "Polls OpenAI/Claude status pages and Google Workspace for " = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för "; + "Prevents any Keychain access while enabled." = "Förhindrar all åtkomst till Nyckelring när det är aktiverat."; + "Primary (API key limit)" = "Primär (API-nyckelgräns)"; + "Primary (\\(label))" = "Primär (\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "Primär (\\(metadata.sessionLabel))"; + "Probe logs" = "Probloggar"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "Förloppsstaplar fylls när du förbrukar kvot i stället för att visa återstående."; + "Provider" = "Leverantör"; + "Providers" = "Leverantörer"; + "Quit CodexBar" = "Avsluta QuotaKit"; + "Random (default)" = "Slumpmässig (standard)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Läser lokala användningsloggar. Visar idag och valt historikfönster i menyn."; + "Refresh" = "Uppdatera"; + "Refresh cadence" = "Uppdateringsintervall"; + "Remote" = "Fjärr"; + "Remove" = "Ta bort"; + "Remove Codex account?" = "Ta bort Codex-konto?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Ta bort \\(account.email) från QuotaKit? Dess hanterade Codex-hem tas bort."; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Ta bort \\(email) från QuotaKit? Dess hanterade Codex-hem tas bort."; + "Remove selected account" = "Ta bort valt konto"; + "Replace critter bars with provider branding icons and a percentage." = "Ersätt figurstaplar med leverantörsikoner och ett procenttal."; + "Replay selected animation" = "Spela vald animation igen"; + "Requires authentication via GitHub Device Flow." = "Kräver autentisering via GitHub Device Flow."; + "Resets: \\(reset)" = "Återställs: \\(reset)"; + "Rolling five-hour limit" = "Rullande femtimmarsgräns"; + "Search hourly" = "Sökning per timme"; + "Secondary (\\(label))" = "Sekundär (\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "Sekundär (\\(metadata.weeklyLabel))"; + "Select a provider" = "Välj en leverantör"; + "Select the IDE to monitor" = "Välj IDE att övervaka"; + "Session quota notifications" = "Aviseringar för sessionskvot"; + "Session tokens" = "Sessionstoken"; + "Settings" = "Inställningar"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; + "Show Debug Settings" = "Visa felsökningsinställningar"; + "Show all token accounts" = "Visa alla tokenkonton"; + "Show cost summary" = "Visa kostnadssammanfattning"; + "Show credits + extra usage" = "Visa krediter och extra användning"; + "Show details" = "Visa detaljer"; + "Show most-used provider" = "Visa mest använda leverantör"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Visa leverantörsikoner i växlaren (annars visas en veckoförloppslinje)."; + "Show reset time as clock" = "Visa återställningstid som klockslag"; + "Show usage as used" = "Visa användning som förbrukad"; + "Sign in via button below" = "Logga in med knappen nedan"; + "Skip teardown between probes (debug-only)." = "Hoppa över nedstängning mellan prober (endast felsökning)."; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapla tokenkonton i menyn (annars visas en kontoväxlare)."; + "Start at Login" = "Starta vid inloggning"; + "Status" = "Status"; + "Store Claude sessionKey cookies or OAuth access tokens." = "Spara Claude-sessionKey-cookies eller OAuth-åtkomsttoken."; + "Store multiple Abacus AI Cookie headers." = "Spara flera Cookie-headers för Abacus AI."; + "Store multiple Augment Cookie headers." = "Spara flera Cookie-headers för Augment."; + "Store multiple Cursor Cookie headers." = "Spara flera Cookie-headers för Cursor."; + "Store multiple Factory Cookie headers." = "Spara flera Cookie-headers för Factory."; + "Store multiple MiniMax Cookie headers." = "Spara flera Cookie-headers för MiniMax."; + "Store multiple Mistral Cookie headers." = "Spara flera Cookie-headers för Mistral."; + "Store multiple Ollama Cookie headers." = "Spara flera Cookie-headers för Ollama."; + "Store multiple OpenCode Cookie headers." = "Spara flera Cookie-headers för OpenCode."; + "Store multiple OpenCode Go Cookie headers." = "Spara flera Cookie-headers för OpenCode Go."; + "Stored in the CodexBar config file." = "Sparas i QuotaKits konfigurationsfil."; + "Stored in ~/.codexbar/config.json. " = "Sparas i ~/.quotakit/config.json. "; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Sparas i ~/.quotakit/config.json. Skapa en på kimi-k2.ai."; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Sparas i ~/.quotakit/config.json. Klistra in nyckeln från Synthetic-instrumentpanelen."; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Sparas i ~/.quotakit/config.json. Klistra in din Coding Plan-API-nyckel från Model Studio."; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Sparas i ~/.quotakit/config.json. Klistra in din MiniMax-API-nyckel."; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Sparas i ~/.quotakit/config.json. Du kan också ange KILO_API_KEY eller "; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Sparar lokal Codex-användningshistorik (8 veckor) för att anpassa taktprognoser."; + "Subscription Utilization" = "Abonnemangsutnyttjande"; + "Surprise me" = "Överraska mig"; + "Switcher shows icons" = "Växlaren visar ikoner"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "System"; + "Temporarily shows the loading animation after the next refresh." = "Visar tillfälligt laddningsanimationen efter nästa uppdatering."; + "Tertiary (\\(label))" = "Tertiär (\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "Tertiär (\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "Standardkontot för Codex på den här Macen."; + "Toggle" = "Växla"; + "Toggle subtitle" = "Växlingsunderrubrik"; + "Token" = "Token"; + "Trigger the menu bar menu from anywhere." = "Öppna menyradsmenyn var du än är."; + "True" = "Sant"; + "Twitter" = "Twitter"; + "Unsupported" = "Stöds inte"; + "Update Channel" = "Uppdateringskanal"; + "Updated" = "Uppdaterad"; + "Updates unavailable in this build." = "Uppdateringar är inte tillgängliga i det här bygget."; + "Usage" = "Användning"; + "Usage breakdown" = "Användningsuppdelning"; + "Usage history (30 days)" = "Användningshistorik"; + "Usage source" = "Användningskälla"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Använd BigModel för slutpunkterna i Fastlandskina (open.bigmodel.cn)."; + "Use a single menu bar icon with a provider switcher." = "Använd en enda menyradsikon med leverantörsväxlare."; + "Use international or China mainland console gateways for quota fetches." = "Använd internationella gatewayar eller gatewayar för Fastlandskina vid kvothämtning."; + "Version" = "Version"; + "Version \\(self.versionString)" = "Version \\(self.versionString)"; + "Version \\(version)" = "Version \\(version)"; + "Version \\(versionString)" = "Version \\(versionString)"; + "Vertex AI Login" = "Vertex AI-inloggning"; + "Wait for the current managed Codex login to finish before adding another account." = "Vänta tills den pågående hanterade Codex-inloggningen är klar innan du lägger till ett konto till."; + "Waiting for Authentication..." = "Väntar på autentisering..."; + "Website" = "Webbplats"; + "Weekly limit confetti" = "Veckogränskonfetti"; + "Weekly token limit" = "Veckogräns för token"; + "Weekly usage" = "Veckoanvändning"; + "Weekly usage unavailable for this account." = "Veckoanvändning är inte tillgänglig för det här kontot."; + "Window: \\(window)" = "Fönster: \\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "Skriv loggar till \\(self.fileLogPath) för felsökning."; + "Yes" = "Ja"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + "\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 d \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name): hämtar…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name): senaste försök \\(when)"; + "\\(name): no data yet" = "\\(name): inga data än"; + "\\(name): unsupported" = "\\(name): stöds inte"; + "all browsers" = "alla webbläsare"; + "available again." = "tillgänglig igen."; + "built_format" = "Byggd %@"; + "copilot_complete_in_browser" = "Slutför inloggningen i webbläsaren."; + "copilot_device_code" = "Enhetskoden kopierades till urklipp: %1$@\n\nVerifiera på: %2$@"; + "copilot_device_code_copied" = "Enhetskoden kopierades."; + "copilot_verify_at" = "Verifiera på %@"; + "copilot_waiting_text" = "Slutför inloggningen i webbläsaren.\nDet här fönstret stängs automatiskt när inloggningen är klar."; + "copilot_window_closes_auto" = "Det här fönstret stängs automatiskt när inloggningen är klar."; + "cost_status_error" = "%1$@: %2$@"; + "cost_status_fetching" = "%1$@: hämtar… %2$@"; + "cost_status_last_attempt" = "%1$@: senaste försök %2$@"; + "cost_status_no_data" = "%@: inga data än"; + "cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@: stöds inte"; + "credits_remaining" = "Krediter: %@"; + "cursor_on_demand" = "Vid behov: %@"; + "cursor_on_demand_with_limit" = "Vid behov: %1$@ / %2$@"; + "extra_usage_format" = "Extra användning: %1$@ / %2$@"; + "jetbrains_detected_generate" = "Hittade: %@. Använd AI-assistenten en gång för att skapa kvotdata och uppdatera sedan QuotaKit."; + "jetbrains_detected_select" = "Hittade: %@. Välj önskad IDE i Inställningar och uppdatera sedan QuotaKit."; + "last_fetch_failed_with_provider" = "Senaste hämtningen för %@ misslyckades:"; + "last_spend" = "Senaste utgift: %@"; + "mcp_model_usage" = "%1$@: %2$@"; + "mcp_resets" = "Återställs: %@"; + "mcp_window" = "Fönster: %@"; + "metric_average" = "Genomsnitt (%1$@ + %2$@)"; + "metric_primary" = "Primär (%@)"; + "metric_secondary" = "Sekundär (%@)"; + "metric_tertiary" = "Tertiär (%@)"; + "multiple_workspaces_found" = "QuotaKit hittade flera arbetsytor för %@. Välj arbetsytan som ska läggas till."; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "Välj upp till %@ leverantörer"; + "remove_account_message" = "Ta bort %@ från QuotaKit? Dess hanterade Codex-hem tas bort."; + "version_format" = "Version %@"; + "vertex_ai_login_instructions" = "Autentisera med Google Cloud för att följa Vertex AI-användning.\n\n1. Öppna Terminal\n2. Kör: gcloud auth application-default login\n3. Följ anvisningarna i webbläsaren för att logga in\n4. Ange ditt projekt: gcloud config set project PROJECT_ID\n\nÖppna Terminal nu?"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID är angivet, men bara opencode, opencodego och deepgram stöder workspaceID."; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-licens."; + /* General Pane */ "section_system" = "System"; + "section_usage" = "Användning"; + "section_automation" = "Automatisering"; + "language_title" = "Språk"; + "language_subtitle" = "Byt visningsspråk. Appen behöver startas om för att ändringen ska slå igenom helt."; + "language_system" = "System"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_dutch" = "Nederlands"; + "language_swedish" = "Svenska"; + +"language_french" = "Franska"; + +"language_ukrainian" = "Ukrainska"; + "start_at_login_title" = "Starta vid inloggning"; + "start_at_login_subtitle" = "Öppnar QuotaKit automatiskt när du startar din Mac."; + "show_cost_summary" = "Visa kostnadssammanfattning"; + "show_cost_summary_subtitle" = "Läser lokala användningsloggar. Visar idag och valt historikfönster i menyn."; + "cost_history_days_title" = "Historikfönster: %d dagar"; + "cost_auto_refresh_info" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; + "refresh_cadence_title" = "Uppdateringsintervall"; + "refresh_cadence_subtitle" = "Hur ofta QuotaKit kontrollerar leverantörer i bakgrunden."; + "manual_refresh_hint" = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; + "check_provider_status_title" = "Kontrollera leverantörsstatus"; + "check_provider_status_subtitle" = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för Gemini/Antigravity och visar incidenter i ikonen och menyn."; + "session_quota_notifications_title" = "Aviseringar för sessionskvot"; + "session_quota_notifications_subtitle" = "Aviserar när femtimmarssessionens kvot når 0 % och när den blir tillgänglig igen."; + "quota_warning_notifications_title" = "Kvotvarningsaviseringar"; + "quota_warning_notifications_subtitle" = "Varnar när återstående sessions- eller veckokvot passerar inställda trösklar."; + "quota_warnings_title" = "Kvotvarningar"; + "quota_warning_session" = "session"; + "quota_warning_session_capitalized" = "Session"; + "quota_warning_weekly" = "veckokvot"; + "quota_warning_weekly_capitalized" = "Veckokvot"; + "quota_warning_notification_title" = "%1$@ %2$@-kvot låg"; + "quota_warning_notification_body" = "%1$@ kvar. Din varningströskel på %2$d %% för %3$@ har nåtts."; + "quota_warning_notification_body_with_account" = "Konto %1$@. %2$@ kvar. Din varningströskel på %3$d %% för %4$@ har nåtts."; + "session_depleted_notification_title" = "%@-sessionen är slut"; + "session_depleted_notification_body" = "0 % kvar. Du får en avisering när den är tillgänglig igen."; + "session_restored_notification_title" = "%@-sessionen är återställd"; + "session_restored_notification_body" = "Sessionskvoten är tillgänglig igen."; + "quota_warning_warn_at" = "Varna vid"; + "quota_warning_global_threshold_subtitle" = "Återstående procent för sessions- och veckofönster, om inte en leverantör åsidosätter dem."; + "quota_warning_sound" = "Spela aviseringsljud"; + "quota_warning_provider_inherits" = "Använder de globala kvotvarningsinställningarna om inte ett fönster anpassas här."; + "quota_warning_customize_thresholds" = "Anpassa trösklar för %@"; + "quota_warning_enable_warnings" = "Aktivera varningar för %@"; + "quota_warning_window_warn_at" = "Varna vid för %@"; + "quota_warning_off" = "Av"; + "quota_warning_inherited" = "Ärvd: %@"; + "quota_warning_depleted_only" = "bara slut"; + "quota_warning_upper" = "Övre"; + "quota_warning_lower" = "Nedre"; + "apply" = "Tillämpa"; + "quit_app" = "Avsluta QuotaKit"; + /* Tab titles */ "tab_general" = "Allmänt"; + "tab_providers" = "Leverantörer"; + "tab_display" = "Visning"; + "tab_advanced" = "Avancerat"; + "tab_about" = "Om"; + "tab_debug" = "Felsök"; + /* Providers Pane */ "select_a_provider" = "Välj en leverantör"; + "cancel" = "Avbryt"; + "last_fetch_failed" = "senaste hämtningen misslyckades"; + "usage_not_fetched_yet" = "användning har inte hämtats än"; + "managed_account_storage_unreadable" = "Hanterad kontolagring går inte att läsa. Direkt kontoåtkomst är fortfarande tillgänglig, men hanterade åtgärder för att lägga till, autentisera om och ta bort är inaktiverade tills lagringen kan återställas."; + "remove_codex_account_title" = "Ta bort Codex-konto?"; + "remove" = "Ta bort"; + "managed_login_already_running" = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till eller autentiserar om ett annat konto."; + "managed_login_failed" = "Den hanterade Codex-inloggningen slutfördes inte. Kontrollera att `codex --version` fungerar i Terminal. Om macOS blockerade eller flyttade `codex` till papperskorgen tar du bort gamla dubblettinstallationer, kör `npm install -g --include=optional @openai/codex@latest` och försöker igen."; + "managed_login_missing_email" = "Codex-inloggningen slutfördes, men ingen e-postadress för kontot var tillgänglig. Försök igen när du har kontrollerat att kontot är helt inloggat."; + "login_success_notification_title" = "%@-inloggning lyckades"; + "login_success_notification_body" = "Du kan återgå till appen. Autentiseringen är klar."; + "workspace_selection_cancelled" = "QuotaKit hittade flera arbetsytor, men ingen arbetsyta valdes."; + "unsafe_managed_home" = "QuotaKit vägrade ändra en oväntad hanterad hem-sökväg: %@"; + "menu_bar_metric_title" = "Menyradsmått"; + "menu_bar_metric_subtitle" = "Välj vilket fönster som styr procenttalet i menyraden."; + "menu_bar_metric_subtitle_deepseek" = "Visar DeepSeek-saldot i menyraden."; + "menu_bar_metric_subtitle_moonshot" = "Visar saldot för Moonshot/Kimi API i menyraden."; + "menu_bar_metric_subtitle_mistral" = "Visar den aktuella månadens Mistral API-utgift i menyraden."; + "menu_bar_metric_subtitle_kimik2" = "Visar Kimi K2-API-nyckelkrediter i menyraden."; + "automatic" = "Automatiskt"; + "primary_api_key_limit" = "Primär (API-nyckelgräns)"; + /* Display Pane */ "section_menu_bar" = "Menyrad"; + "merge_icons_title" = "Slå ihop ikoner"; + "merge_icons_subtitle" = "Använd en enda menyradsikon med leverantörsväxlare."; + "switcher_shows_icons_title" = "Växlaren visar ikoner"; + "switcher_shows_icons_subtitle" = "Visa leverantörsikoner i växlaren (annars visas en veckoförloppslinje)."; + "show_most_used_provider_title" = "Visa mest använda leverantör"; + "show_most_used_provider_subtitle" = "Menyraden visar automatiskt leverantören som ligger närmast sin gräns."; + "menu_bar_shows_percent_title" = "Menyraden visar procent"; + "menu_bar_shows_percent_subtitle" = "Ersätt figurstaplar med leverantörsikoner och ett procenttal."; + "display_mode_title" = "Visningsläge"; + "display_mode_subtitle" = "Välj vad som ska visas i menyraden (takt visar användning mot förväntat)."; + "section_menu_content" = "Menyinnehåll"; + "show_usage_as_used_title" = "Visa användning som förbrukad"; + "show_usage_as_used_subtitle" = "Förloppsstaplar fylls när du förbrukar kvot i stället för att visa återstående."; + "show_quota_warning_markers_title" = "Visa kvotvarningsmarkörer"; + "show_quota_warning_markers_subtitle" = "Rita tröskelmarkeringar på användningsstaplar när kvotvarningar är konfigurerade."; + "weekly_progress_work_days_title" = "Arbetsdagar i veckoförlopp"; + "weekly_progress_work_days_subtitle" = "Rita dagsgränsmarkeringar på veckostaplar."; + "show_reset_time_as_clock_title" = "Visa återställningstid som klockslag"; + "show_reset_time_as_clock_subtitle" = "Visa återställningstider som klockslag i stället för nedräkningar."; + "show_provider_changelog_links_title" = "Visa länkar till leverantörers ändringsloggar"; + "show_provider_changelog_links_subtitle" = "Lägger till länkar till utgåvekommentarer för stödda CLI-baserade leverantörer i menyn."; + "show_credits_extra_usage_title" = "Visa krediter och extra användning"; + "show_credits_extra_usage_subtitle" = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; + "show_all_token_accounts_title" = "Visa alla tokenkonton"; + "show_all_token_accounts_subtitle" = "Stapla tokenkonton i menyn (annars visas en kontoväxlare)."; + "multi_account_layout_title" = "Layout för flera konton"; + "multi_account_layout_subtitle" = "Välj segmenterad kontoväxling eller staplade kontokort."; + "multi_account_layout_segmented" = "Segmenterad"; + "multi_account_layout_stacked" = "Staplad"; + "overview_tab_providers_title" = "Leverantörer på översiktsfliken"; + "configure" = "Konfigurera…"; + "overview_enable_merge_icons_hint" = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; + "overview_no_providers_hint" = "Inga aktiverade leverantörer är tillgängliga för översikten."; + "overview_rows_follow_order" = "Översiktsrader följer alltid leverantörsordningen."; + "overview_no_providers_selected" = "Inga leverantörer valda"; + /* Advanced Pane */ "section_keyboard_shortcut" = "Kortkommando"; + "open_menu_shortcut_title" = "Öppna meny"; + "open_menu_shortcut_subtitle" = "Öppna menyradsmenyn var du än är."; + "install_cli" = "Installera CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "Inga skrivbara bin-kataloger hittades."; + "show_debug_settings_title" = "Visa felsökningsinställningar"; + "show_debug_settings_subtitle" = "Visa felsökningsverktyg på fliken Felsök."; + "surprise_me_title" = "Överraska mig"; + "surprise_me_subtitle" = "Kontrollera om du vill att agenterna ska få leka lite där uppe."; + "weekly_limit_confetti_title" = "Veckogränskonfetti"; + "weekly_limit_confetti_subtitle" = "Spela konfetti i helskärm när veckoförbrukningen återställs."; + "hide_personal_info_title" = "Dölj personuppgifter"; + "hide_personal_info_subtitle" = "Maskera e-postadresser i menyraden och menygränssnittet."; + "show_provider_storage_usage_title" = "Visa leverantörers lagringsanvändning"; + "show_provider_storage_usage_subtitle" = "Visa lokal diskanvändning i menyer. Söker igenom kända leverantörsägda sökvägar i bakgrunden."; + "section_keychain_access" = "Åtkomst till Nyckelring"; + "keychain_access_caption" = "Inaktivera all läsning och skrivning i Nyckelring. Använd detta om macOS fortsätter fråga om 'Chrome/Brave/Edge Safe Storage' även efter att du klickat på Tillåt alltid. Import av webbläsarcookies är inte tillgänglig när detta är aktiverat. Klistra in Cookie-headers manuellt under Leverantörer. Claude/Codex OAuth via CLI fungerar fortfarande."; + "disable_keychain_access_title" = "Inaktivera åtkomst till Nyckelring"; + "disable_keychain_access_subtitle" = "Förhindrar all åtkomst till Nyckelring när det är aktiverat."; + /* About Pane */ "about_tagline" = "Må dina token aldrig ta slut – håll agentgränserna synliga."; + "link_github" = "GitHub"; + "link_website" = "Webbplats"; + "link_twitter" = "Twitter"; + "link_email" = "E-post"; + "check_updates_auto" = "Sök efter uppdateringar automatiskt"; + "update_channel" = "Uppdateringskanal"; + "check_for_updates" = "Sök efter uppdateringar…"; + "updates_unavailable" = "Uppdateringar är inte tillgängliga i det här bygget."; + "copyright" = "© 2026 Peter Steinberger. MIT-licens."; + /* Debug Pane */ "section_logging" = "Loggning"; + "enable_file_logging" = "Aktivera filloggning"; + "enable_file_logging_subtitle" = "Skriv loggar till %@ för felsökning."; + "verbosity_title" = "Detaljnivå"; + "verbosity_subtitle" = "Styr hur detaljerad loggningen är."; + "open_log_file" = "Öppna loggfil"; + "force_animation_next_refresh" = "Tvinga animation vid nästa uppdatering"; + "force_animation_next_refresh_subtitle" = "Visar tillfälligt laddningsanimationen efter nästa uppdatering."; + "section_loading_animations" = "Laddningsanimationer"; + "loading_animations_caption" = "Välj ett mönster och spela upp det i menyraden. \"Slumpmässig\" behåller nuvarande beteende."; + "animation_random_default" = "Slumpmässig (standard)"; + "replay_selected_animation" = "Spela vald animation igen"; + "blink_now" = "Blinka nu"; + "section_probe_logs" = "Probloggar"; + "probe_logs_caption" = "Hämta senaste probutdata för felsökning. Kopiera behåller hela texten."; + "fetch_log" = "Hämta logg"; + "copy" = "Kopiera"; + "save_to_file" = "Spara till fil"; + "load_parse_dump" = "Läs in tolkningsdump"; + "rerun_provider_autodetect" = "Kör automatisk leverantörsidentifiering igen"; + "loading" = "Läser in…"; + "no_log_yet_fetch" = "Ingen logg än. Hämta för att läsa in."; + "section_fetch_strategy" = "Försök med hämtningsstrategi"; + "fetch_strategy_caption" = "Senaste hämtningskedjans beslut och fel för en leverantör."; + "section_openai_cookies" = "OpenAI-cookies"; + "openai_cookies_caption" = "Loggar för cookieimport och WebKit-skrapning från senaste OpenAI-cookieförsöket."; + "no_log_yet" = "Ingen logg än. Uppdatera OpenAI-cookies under Leverantörer → Codex för att köra en import."; + "section_caches" = "Cachar"; + "caches_caption" = "Rensa cachade resultat från kostnadsskanningar eller webbläsarcookiecachar."; + "clear_cookie_cache" = "Rensa cookiecache"; + "clear_cost_cache" = "Rensa kostnadscache"; + "section_notifications" = "Aviseringar"; + "notifications_caption" = "Skicka testaviseringar för femtimmarsfönstret (slut/återställt)."; + "post_depleted" = "Skicka slut"; + "post_restored" = "Skicka återställd"; + "section_cli_sessions" = "CLI-sessioner"; + "cli_sessions_caption" = "Håll Codex/Claude-CLI-sessioner vid liv efter en prob. Standard är att avsluta när data har fångats."; + "keep_cli_sessions_alive" = "Håll CLI-sessioner vid liv"; + "keep_cli_sessions_alive_subtitle" = "Hoppa över nedstängning mellan prober (endast felsökning)."; + "reset_cli_sessions" = "Återställ CLI-sessioner"; + "section_error_simulation" = "Felsimulering"; + "error_simulation_caption" = "Infoga ett falskt felmeddelande i menykortet för layouttestning."; + "set_menu_error" = "Sätt menyfel"; + "clear_menu_error" = "Rensa menyfel"; + "set_cost_error" = "Sätt kostnadsfel"; + "clear_cost_error" = "Rensa kostnadsfel"; + "section_cli_paths" = "CLI-sökvägar"; + "cli_paths_caption" = "Löst Codex-binär och PATH-lager. Inloggningsskalets PATH fångas vid start (kort timeout)."; + "codex_binary" = "Codex-binär"; + "claude_binary" = "Claude-binär"; + "effective_path" = "Effektiv PATH"; + "unavailable" = "Inte tillgänglig"; + "login_shell_path" = "Inloggningsskalets PATH (fångad vid start)"; + "cleared" = "Rensat."; + "no_fetch_attempts" = "Inga hämtningsförsök än."; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe kan blockera menyradsappar i Systeminställningar → Menyrad → Tillåt i menyraden. QuotaKit körs, men macOS kan dölja ikonen. Öppna menyradsinställningarna och slå på QuotaKit."; + /* Metric preferences */ "metric_pref_automatic" = "Automatiskt"; + "metric_pref_primary" = "Primär"; + "metric_pref_secondary" = "Sekundär"; + "metric_pref_tertiary" = "Tertiär"; + "metric_pref_extra_usage" = "Extra användning"; + "metric_pref_average" = "Genomsnitt"; + /* Display modes */ "display_mode_percent" = "Procent"; + "display_mode_pace" = "Takt"; + "display_mode_both" = "Båda"; + "display_mode_percent_desc" = "Visa återstående/förbrukad procent (t.ex. 45 %)"; + "display_mode_pace_desc" = "Visa taktindikator (t.ex. +5 %)"; + "display_mode_both_desc" = "Visa både procent och takt (t.ex. 45 % · +5 %)"; + /* Provider status */ "status_operational" = "Fungerar normalt"; + "status_partial_outage" = "Delvis avbrott"; + "status_major_outage" = "Större avbrott"; + "status_critical_issue" = "Kritiskt problem"; + "status_maintenance" = "Underhåll"; + "status_unknown" = "Okänd status"; + /* Refresh frequency */ "refresh_manual" = "Manuellt"; + "refresh_1min" = "1 min"; + "refresh_2min" = "2 min"; + "refresh_5min" = "5 min"; + "refresh_15min" = "15 min"; + "refresh_30min" = "30 min"; + /* Additional keys */ "not_found" = "Hittades inte"; + /* Cost estimation */ "cost_header_estimated" = "Kostnad (uppskattad)"; + "cost_estimate_hint" = "Uppskattat från lokala loggar · kan skilja sig från din faktura"; + "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Ingen JetBrains IDE med AI Assistant hittades. Installera en JetBrains IDE och aktivera AI Assistant."; + "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter-API-token är inte konfigurerad. Ange miljövariabeln OPENROUTER_API_KEY eller konfigurera i Inställningar."; + "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai-API-token hittades inte. Ange apiKey i ~/.quotakit/config.json eller Z_AI_API_KEY."; + "Missing DeepSeek API key." = "DeepSeek-API-nyckel saknas."; + "%@ is unavailable in the current environment." = "%@ är inte tillgänglig i den aktuella miljön."; + "All Systems Operational" = "Alla system fungerar"; + "Last 30 days" = "Senaste 30 dagarna"; + "Last 30 days:" = "Senaste 30 dagarna:"; + "This month" = "Den här månaden"; + "Store multiple OpenAI API keys." = "Spara flera OpenAI-API-nycklar."; + "Admin API key" = "Admin-API-nyckel"; + "Open billing" = "Öppna fakturering"; + "Google accounts" = "Google-konton"; + "Store multiple Antigravity Google OAuth accounts for quick switching." = "Spara flera Google OAuth-konton för Antigravity så att du snabbt kan växla."; + "Add Google Account" = "Lägg till Google-konto"; + "Open Token Plan" = "Öppna tokenplan"; + "Text Generation" = "Textgenerering"; + "Text to Speech" = "Text till tal"; + "Music Generation" = "Musikgenerering"; + "Image Generation" = "Bildgenerering"; + "No local data found" = "Inga lokala data hittades"; + "Credits unavailable; keep Codex running to refresh." = "Krediter är inte tillgängliga. Håll Codex igång för att uppdatera."; + "No available fetch strategy for minimax." = "Ingen tillgänglig hämtningsstrategi för minimax."; + "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Ingen Cursor-session hittades. Logga in på cursor.com i Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX eller Edge Canary. Om du använder Safari ger du QuotaKit Fullständig skivåtkomst i Systeminställningar ▸ Integritet och säkerhet. Du kan också logga in på Cursor från QuotaKit-menyn (lägg till/byt konto)."; + "No OpenCode session cookies found in browsers." = "Inga OpenCode-sessionscookies hittades i webbläsare."; + "No available fetch strategy for %@." = "Ingen tillgänglig hämtningsstrategi för %@."; + "Today" = "Idag"; + "Today tokens" = "Token idag"; + "30d cost" = "Kostnad 30 d"; + "30d tokens" = "Token 30 d"; + "Latest tokens" = "Senaste token"; + "Top model" = "Toppmodell"; + "Storage" = "Lagring"; + "Add Account..." = "Lägg till konto..."; + "Usage Dashboard" = "Användningsinstrumentpanel"; + "Status Page" = "Statussida"; + "Settings..." = "Inställningar..."; + "About CodexBar" = "Om QuotaKit"; + "Quit" = "Avsluta"; + "Last %d day" = "Senaste %d dagen"; + "Last %d days" = "Senaste %d dagarna"; + "%@ tokens" = "%@ token"; + "Latest billing day" = "Senaste faktureringsdag"; + "Latest billing day (%@)" = "Senaste faktureringsdag (%@)"; + "%@ left" = "%@ kvar"; + "Resets %@" = "Återställs %@"; + "Resets in %@" = "Återställs om %@"; + "Resets now" = "Återställs nu"; + "Lasts until reset" = "Räcker till återställning"; + "Updated %@" = "Uppdaterad %@"; + "Updated %@h ago" = "Uppdaterad för %@ h sedan"; + "Updated %@m ago" = "Uppdaterad för %@ min sedan"; + "Updated just now" = "Uppdaterad nyss"; + "Projected empty in %@" = "Beräknas ta slut om %@"; + "Runs out in %@" = "Tar slut om %@"; + "Pace: %@" = "Takt: %@"; + "Pace: %@ · %@" = "Takt: %@ · %@"; + "%@ · %@" = "%@ · %@"; + "≈ %d%% run-out risk" = "≈ %d %% risk att ta slut"; + "%d%% in deficit" = "%d %% underskott"; + "%d%% in reserve" = "%d %% reserv"; + "usage_percent_suffix_left" = "kvar"; + "usage_percent_suffix_used" = "förbrukat"; + "Store multiple DeepSeek API keys." = "Spara flera DeepSeek-API-nycklar."; + "This week" = "Den här veckan"; + "Week" = "Vecka"; + "Month" = "Månad"; + "Models" = "Modeller"; + "24h tokens" = "Token 24 h"; + "Latest hour" = "Senaste timmen"; + "Peak hour" = "Topptimme"; + "Top method" = "Toppmetod"; + "30d cash" = "Pengar 30 d"; + "30d billing history from MiniMax web session" = "Faktureringshistorik 30 d från MiniMax-webbsession"; + "AWS Cost Explorer billing can lag." = "AWS Cost Explorer-fakturering kan släpa efter."; + "Rate limit: %d / %@" = "Gräns: %d / %@"; + "Key remaining" = "Nyckel återstår"; + "No limit set for the API key" = "Ingen gräns är satt för API-nyckeln"; + "API key limit unavailable right now" = "API-nyckelgränsen är inte tillgänglig just nu"; + "This month: %@ tokens" = "Den här månaden: %@ token"; + "No utilization data yet." = "Inga utnyttjandedata än."; + "No %@ utilization data yet." = "Inga utnyttjandedata för %@ än."; + "%@: %@%% used" = "%@: %@ %% förbrukat"; + "%dd" = "%d d"; + "today" = "idag"; + "just now" = "nyss"; + "On pace" = "I takt"; + "Runs out now" = "Tar slut nu"; + "Projected empty now" = "Beräknas vara slut nu"; + "Switch Account..." = "Byt konto..."; + "Update ready, restart now?" = "Uppdatering redo. Starta om nu?"; + "Daily" = "Dagligen"; + "Hourly Tokens" = "Token per timme"; + "No data" = "Inga data"; + "No usage breakdown data available." = "Ingen användningsuppdelning tillgänglig."; + "Today: %@ · %@ tokens" = "Idag: %@ · %@ token"; + "Today: %@" = "Idag: %@"; + "Today: %@ tokens" = "Idag: %@ token"; + "Last 30 days: %@ · %@ tokens" = "Senaste 30 dagarna: %@ · %@ token"; + "Last 30 days: %@" = "Senaste 30 dagarna: %@"; + "Est. total (30d): %@" = "Uppsk. totalt (30 d): %@"; + "Est. total (%@): %@" = "Uppsk. totalt (%@): %@"; + "Hover a bar for details" = "Håll pekaren över en stapel för detaljer"; + "%@: %@ · %@ tokens" = "%@: %@ · %@ token"; + "No providers selected for Overview." = "Inga leverantörer valda för översikten."; + "No overview data available." = "Inga översiktsdata tillgängliga."; + "Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto använder det lokala IDE-API:t först och sedan Google OAuth när IDE:n är stängd."; + "Login with Google" = "Logga in med Google"; + "Google OAuth" = "Google OAuth"; + "Add accounts via GitHub OAuth Device Flow on the selected host." = "Lägg till konton via GitHub OAuth Device Flow på vald värd."; + "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Sparar varje inloggat Google-konto för snabb växling i Antigravity. Använder Antigravity.app OAuth när det finns, eller ANTIGRAVITY_OAUTH_CLIENT_ID och ANTIGRAVITY_OAUTH_CLIENT_SECRET som ersättning."; + "Manual cleanup: past sessions" = "Manuell rensning: tidigare sessioner"; + "Clearing removes past resume, continue, and rewind history." = "Rensning tar bort historik för tidigare återuppta, fortsätt och spola tillbaka."; + "Manual cleanup: file checkpoints" = "Manuell rensning: filkontrollpunkter"; + "Clearing removes checkpoint restore data for previous edits." = "Rensning tar bort återställningsdata från kontrollpunkter för tidigare ändringar."; + "Manual cleanup: saved plans" = "Manuell rensning: sparade planer"; + "Clearing removes old plan-mode files." = "Rensning tar bort gamla planlägesfiler."; + "Manual cleanup: debug logs" = "Manuell rensning: felsökningsloggar"; + "Clearing removes past debug logs." = "Rensning tar bort tidigare felsökningsloggar."; + "Manual cleanup: attachment cache" = "Manuell rensning: cache för bilagor"; + "Clearing removes cached large pastes or attached images." = "Rensning tar bort cachade stora inklistringar eller bifogade bilder."; + "Manual cleanup: session metadata" = "Manuell rensning: sessionsmetadata"; + "Clearing removes per-session environment metadata." = "Rensning tar bort miljömetadata per session."; + "Manual cleanup: shell snapshots" = "Manuell rensning: skalögonblicksbilder"; + "Clearing removes leftover runtime shell snapshot files." = "Rensning tar bort kvarvarande skalögonblicksbilder från körtid."; + "Manual cleanup: legacy todos" = "Manuell rensning: äldre att göra-listor"; + "Clearing removes legacy per-session task lists." = "Rensning tar bort äldre uppgiftslistor per session."; + "Manual cleanup: sessions" = "Manuell rensning: sessioner"; + "Clearing removes past Codex session history." = "Rensning tar bort tidigare Codex-sessionshistorik."; + "Manual cleanup: archived sessions" = "Manuell rensning: arkiverade sessioner"; + "Clearing removes archived Codex session history." = "Rensning tar bort arkiverad Codex-sessionshistorik."; + "Manual cleanup: cache" = "Manuell rensning: cache"; + "Clearing removes provider-owned cached data." = "Rensning tar bort leverantörsägda cachade data."; + "Manual cleanup: logs" = "Manuell rensning: loggar"; + "Clearing removes local diagnostic logs." = "Rensning tar bort lokala diagnostikloggar."; + "Manual cleanup: file history" = "Manuell rensning: filhistorik"; + "Clearing removes local edit checkpoint history." = "Rensning tar bort lokal redigeringshistorik från kontrollpunkter."; + "Manual cleanup: temporary data" = "Manuell rensning: tillfälliga data"; + "Clearing removes local temporary provider data." = "Rensning tar bort tillfälliga lokala leverantörsdata."; + "Total: %@" = "Totalt: %@"; + "%d more items" = "%d objekt till"; + "Cleanup ideas" = "Rensningsförslag"; + "%d unreadable item(s) skipped" = "%d oläsbara objekt hoppades över"; + "API key limit" = "API-nyckelgräns"; + "Auth" = "Autentisering"; + "Auto" = "Auto"; + "Disabled — no recent data" = "Inaktiverad – inga färska data"; + "Limits not available" = "Gränser är inte tillgängliga"; + "No usage yet" = "Ingen användning än"; + "Not fetched yet" = "Inte hämtat än"; + "Refreshing" = "Uppdaterar"; + "Session" = "Session"; + "Source" = "Källa"; + "State" = "Tillstånd"; + "Unavailable" = "Inte tillgänglig"; + "Weekly" = "Vecka"; + "not detected" = "inte hittad"; + "Estimated from local Codex logs for the selected account." = "Uppskattat från lokala Codex-loggar för valt konto."; + "minimax_usage_amount_format" = "Användning: %@ / %@"; + "minimax_used_percent_format" = "Förbrukat %@"; + "minimax_service_text_generation" = "Textgenerering"; + "minimax_service_text_to_speech" = "Text till tal"; + "minimax_service_music_generation" = "Musikgenerering"; + "minimax_service_image_generation" = "Bildgenerering"; + "minimax_service_lyrics_generation" = "Låttextgenerering"; + "minimax_service_coding_plan_vlm" = "Coding plan VLM"; + "minimax_service_coding_plan_search" = "Coding plan-sökning"; + /* Added after rebasing Swedish localization on current main. */ "Open MiMo Balance" = "Öppna MiMo-saldo"; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Sparas i ~/.quotakit/config.json. Mätvärden kräver åtkomst till Groq Enterprise Prometheus."; + "API spend" = "API-kostnad"; + "Open Command Code Settings" = "Öppna Command Code-inställningar"; + "Plan utilization chart" = "Diagram över plananvändning"; + "The browser login did not complete in time. Try Antigravity login again." = "Webbläsarinloggningen blev inte klar i tid. Försök logga in i Antigravity igen."; + "Organizations" = "Organisationer"; + "Your StepFun platform password. Used to login and obtain a session token." = "Ditt lösenord för StepFun-plattformen. Används för att logga in och hämta en sessionstoken."; + "Open this URL manually to continue login:\n\n%@" = "Öppna denna URL manuellt för att fortsätta inloggningen:\n\n%@"; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit kunde inte ersätta aktiv Codex-autentisering på den här Mac-datorn."; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Sparas i ~/.quotakit/config.json. Används för /v1/quota-stats."; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit kunde inte spara det aktuella systemkontot säkert före bytet."; + "Oasis-Token" = "Oasis-Token"; + "Open Crof dashboard" = "Öppna Crof-översikt"; + "Sonnet" = "Sonnet"; + "No credits history data available." = "Ingen kredithistorik finns tillgänglig."; + "4 days" = "4 dagar"; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Hemlig AWS-åtkomstnyckel. Kan även anges med AWS_SECRET_ACCESS_KEY."; + "Paste a Cookie header or cURL capture from %@." = "Klistra in en Cookie-header eller cURL-fångst från %@."; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Visa eller dölj Kiro-krediter, procent eller båda bredvid menyradsikonen."; + "credits" = "krediter"; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om ”%@” så att webbläsarcookies kan dekrypteras och ditt konto autentiseras. Klicka på OK för att fortsätta."; + "StepFun platform account (phone number or email)." = "StepFun-plattformskonto (telefonnummer eller e-post)."; + "Timed out waiting for Cursor login. %@" = "Tidsgränsen nåddes i väntan på Cursor-inloggning. %@"; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Amp-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Stored in ~/.codexbar/config.json." = "Sparas i ~/.quotakit/config.json."; + "Reported by Mistral billing usage." = "Rapporteras av Mistrals debiteringsanvändning."; + "Usage remaining" = "Användning kvar"; + "Usage used" = "Användning förbrukad"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\neller klistra in värdet för __Secure-next-auth.session-token"; + "Password" = "Lösenord"; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Sparas i ~/.quotakit/config.json. Öppna Settings > Platform > API Keys i Warp och skapa en nyckel."; + "%d percent remaining" = "%d procent kvar"; + "Open Manus" = "Öppna Manus"; + "Unknown" = "Okänt"; + "Open Ollama API Keys" = "Öppna Ollama-API-nycklar"; + "Hourly Usage" = "Användning per timme"; + "Requests" = "Förfrågningar"; + "Antigravity login timed out" = "Antigravity-inloggning tog för lång tid"; + "%@ requests" = "%@ förfrågningar"; + "Open StepFun Platform" = "Öppna StepFun-plattformen"; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din OpenCode-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Endpoint" = "Slutpunkt"; + "Paste the %@ JSON bundle from %@." = "Klistra in %@-JSON-paketet från %@."; + "Uses username + password to login and obtain an %@ automatically." = "Använder användarnamn och lösenord för att logga in och hämta ett %@ automatiskt."; + "Capacity Start" = "Kapacitetsstart"; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Klistra in Oasis-Token från en inloggad webbläsarsession på platform.stepfun.com."; + "Claude Admin API 30 day spend trend" = "30-dagars kostnadstrend för Claude Admin API"; + "%@/%@ left" = "%@/%@ kvar"; + "Monthly" = "Månadsvis"; + "Gemini Flash" = "Gemini Flash"; + "Cost history chart" = "Diagram över kostnadshistorik"; + "Today requests" = "Dagens förfrågningar"; + "Kiro menu bar value" = "Kiro-värde i menyraden"; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit kunde inte läsa hanterad kontolagring. Återställ lagringen innan du lägger till ett konto till."; + "Using CLI fallback" = "Använder CLI-reserv"; + "%@ web API access is disabled." = "%@-åtkomst till webb-API är inaktiverad."; + "tokens" = "token"; + "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ kvar)"; + "Zen balance" = "Zen-saldo"; + "Daily billing data finalizes at 07:00 UTC" = "Dagliga debiteringsdata fastställs kl. 07.00 UTC"; + "Add Account" = "Lägg till konto"; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit hittade ett annat hanterat konto som redan använder det aktuella systemkontot. Lös dubbletten innan du byter."; + "codex login exited with status %d." = "codex login avslutades med status %d."; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit kunde inte läsa sparad autentisering för kontot. Autentisera det igen och försök igen."; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\neller klistra in värdet för kimi-auth-token"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "Valfritt organisations-ID för konton som är kopplade till flera Anthropic-organisationer."; + "Manually paste an %@ from a browser session." = "Klistra in ett %@ manuellt från en webbläsarsession."; + "MiniMax 30 day token usage trend" = "30-dagars tokenanvändningstrend för MiniMax"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Välj Moonshot/Kimi API-värd för internationella konton eller konton i Fastlandskina."; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Sparas i ~/.quotakit/config.json. Hämta nyckeln från openrouter.ai/settings/keys och ange en köpgräns för nyckeln för att aktivera spårning av API-nyckelkvot."; + "Uses username + password to login and obtain an Oasis-Token automatically." = "Använder användarnamn och lösenord för att logga in och hämta en Oasis-Token automatiskt."; + "OpenRouter API key spend trend" = "Kostnadstrend för OpenRouter-API-nyckel"; + "Workspace ID" = "Arbetsyte-ID"; + "Refresh Session" = "Uppdatera session"; + "Today cash" = "Dagens kontanter"; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Logga in på cursor.com i webbläsaren och uppdatera sedan Cursor i QuotaKit."; + "Extra usage spent" = "Extra användning förbrukad"; + "5 days" = "5 dagar"; + "T3 Chat cookie" = "T3 Chat-cookie"; + "Full in ~1 regen" = "Full om cirka 1 regenerering"; + "DeepSeek 30 day token usage trend" = "30-dagars tokenanvändningstrend för DeepSeek"; + "Reorder" = "Ändra ordning"; + "Changelog" = "Ändringslogg"; + "Deployment" = "Distribution"; + "Quota usage" = "Kvotanvändning"; + "No system account" = "Inget systemkonto"; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Sparas i ~/.quotakit/config.json. Kräver en Anthropic Admin API-nyckel."; + "AWS region. Can also be set with AWS_REGION." = "AWS-region. Kan även anges med AWS_REGION."; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Claude-cookie-header så att Claude-webbanvändning kan hämtas. Klicka på OK för att fortsätta."; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din z.ai-API-token så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Show usage for organizations you belong to. Personal account is always shown." = "Visa användning för organisationer du tillhör. Personligt konto visas alltid."; + "%@ of %@ credits left" = "%@ av %@ krediter kvar"; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Sparas i ~/.quotakit/config.json. Du kan också ange CODEBUFF_API_KEY eller låta QuotaKit läsa ~/.config/manicode/credentials.json (skapas av `codebuff login`)."; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importerar Windsurf-sessionsdata från Chromium-webbläsarens localStorage automatiskt."; + "Full in ~%.0f regens" = "Full om cirka %.0f regenereringar"; + "Verbosity" = "Detaljnivå"; + "%d days of usage data across %d services" = "%d dagar med användningsdata för %d tjänster"; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Sparas i ~/.quotakit/config.json. Du kan också ange KILO_API_KEY eller ~/.local/share/kilo/auth.json (kilo.access)."; + "Windsurf session JSON bundle" = "Windsurf-sessionspaket i JSON"; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Cursor-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Drag to reorder" = "Dra för att ändra ordning"; + "cache-hit input" = "cacheträff-indata"; + "Automatically imports browser cookies." = "Importerar webbläsarcookies automatiskt."; + "Open Volcengine Ark Console" = "Öppna Volcengine Ark-konsol"; + "Antigravity login failed" = "Antigravity-inloggning misslyckades"; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Åtkomst till Nyckelring är inaktiverad under Avancerat, så import av webbläsarcookies är inte tillgänglig."; + "Browser cookies" = "Webbläsarcookies"; + "Usage history (%d days)" = "Användningshistorik (%d dagar)"; + "Cache read" = "Cacheläsning"; + "Copied" = "Kopierat"; + "Disable %@ dashboard cookie usage." = "Inaktivera cookie-användning för %@-översikten."; + "30d requests" = "30 d förfrågningar"; + "Adding Account…" = "Lägger till konto…"; + "Base URL" = "Bas-URL"; + "Utilization End" = "Användningsslut"; + "7d spend" = "7 d kostnad"; + "CodexBar could not read the current system account on this Mac." = "QuotaKit kunde inte läsa det aktuella systemkontot på den här Mac-datorn."; + "%@ of %@ bonus credits left" = "%@ av %@ bonuskrediter kvar"; + "Overage usage" = "Överförbrukning"; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-åtkomstnyckel-ID. Kan även anges med AWS_ACCESS_KEY_ID."; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit kunde inte hitta sparad autentisering för kontot. Autentisera det igen och försök igen."; + "Capacity End" = "Kapacitetsslut"; + "Paste the %@ value or a full Cookie header." = "Klistra in %@-värdet eller en fullständig Cookie-header."; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Factory-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Paste a Cookie header or full cURL capture from %@." = "Klistra in en Cookie-header eller fullständig cURL-fångst från %@."; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Klistra in Cookie-headern från en förfrågan till admin.mistral.ai. Den måste innehålla en ory_session_*-cookie."; + "Copy error" = "Kopieringsfel"; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din MiniMax-API-token så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Project ID" = "Projekt-ID"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "Tidsgränsen nåddes i väntan på Cursor-inloggning. %@ Senaste fel: %@"; + "codex_login_output" = "utdata från codex login:"; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Sparas i ~/.quotakit/config.json. Hämta API-nyckeln från Volcengine Ark-konsolen."; + "Overage cost" = "Kostnad för överförbrukning"; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit kan inte ersätta ett systemkonto som är inloggat med en konfiguration som bara använder API-nyckel."; + "Utilization Start" = "Användningsstart"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-nyckeln verifierar åtkomst till Ollama Cloud. Cookies visar fortfarande kvotgränser."; + "Credits remaining" = "Krediter kvar"; + "Activity" = "Aktivitet"; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Sparas i ~/.quotakit/config.json. Hämta nyckeln från console.deepgram.com."; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-distributionens namn. AZURE_OPENAI_DEPLOYMENT_NAME stöds också."; + "Extra usage balance: %@" = "Saldo för extra användning: %@"; + "requests" = "förfrågningar"; + "CodexBar could not save the current system account before switching." = "QuotaKit kunde inte spara det aktuella systemkontot före bytet."; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Sparas i ~/.quotakit/config.json. För det officiella Kimi-API:t använder du Moonshot / Kimi API."; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\neller klistra in en cURL-fångst från Abacus AI-översikten"; + "Last 30 days: %@ tokens" = "Senaste 30 dagarna: %@ token"; + "%@ authentication is disabled." = "%@-autentisering är inaktiverad."; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din GitHub Copilot-token så att användning kan hämtas. Klicka på OK för att fortsätta."; + "z.ai hourly token trend" = "Token-trend per timme för z.ai"; + "Near full" = "Nästan full"; + "Open Moonshot Console" = "Öppna Moonshot-konsol"; + "Open T3 Chat Settings" = "Öppna T3 Chat-inställningar"; + "after next regen" = "efter nästa regenerering"; + "%.0f%% used" = "%.0f%% använt"; + "claude /login exited with status %d." = "claude /login avslutades med status %d."; + "%d days of cost data" = "%d dagar med kostnadsdata"; + "Open legacy provider docs" = "Öppna äldre leverantörsdokumentation"; + "Secret access key" = "Hemlig åtkomstnyckel"; + "Region" = "Region"; + "Paste a Cookie header captured from %@." = "Klistra in en Cookie-header fångad från %@."; + "Credits history chart" = "Diagram över kredithistorik"; + "Re-authenticating…" = "Autentiserar igen…"; + "Paste a full cookie header or the %@ value." = "Klistra in en fullständig cookie-header eller %@-värdet."; + "Reload" = "Läs in igen"; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Sparas i ~/.quotakit/config.json. OPENAI_ADMIN_KEY föredras, men OPENAI_API_KEY fungerar fortfarande."; + "Access key ID" = "Åtkomstnyckel-ID"; + "No output captured." = "Ingen utdata fångades."; + "Refresh organizations" = "Uppdatera organisationer"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\neller klistra bara in session_id-värdet"; + "Open Codebuff Dashboard" = "Öppna Codebuff-översikt"; + "7 days" = "7 dagar"; + "output" = "utdata"; + "Simulated error text" = "Simulerad feltext"; + "Regenerates %@" = "Regenererar %@"; + "Usage history (today)" = "Användningshistorik (i dag)"; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Valfritt. Lämna tomt för att hitta och slå ihop projekt som är synliga för API-nyckeln."; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Sparas i ~/.quotakit/config.json. Hämta nyckeln från elevenlabs.io/app/settings/api-keys."; + "Automatic imports browser cookies from Bailian." = "Importerar webbläsarcookies från Bailian automatiskt."; + "Enterprise host" = "Enterprise-värd"; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Valfritt. Ange din GitHub Enterprise-värd, till exempel octocorp.ghe.com. Lämna tomt för github.com."; + "%d utilization samples" = "%d användningsmätningar"; + "Cap start" = "Gränsstart"; + "Credits used" = "Använda krediter"; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din OpenAI-cookie-header så att extra Codex-översiktsdata kan hämtas. Klicka på OK för att fortsätta."; + "Re-auth" = "Autentisera igen"; + "cache-miss input" = "cachemiss-indata"; + "Day" = "Dag"; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Valfritt. Gäller den konfigurerade Admin API-nyckeln. Valda tokenkonton ärver inte OPENAI_PROJECT_ID."; + "Balance updates in near-real time (up to 5 min lag)" = "Saldot uppdateras nästan i realtid (upp till 5 min fördröjning)"; + "Cap end" = "Gränsslut"; + "Personal account" = "Personligt konto"; + "Automatically imports browser session cookies." = "Importerar webbläsarens sessionscookies automatiskt."; + "Label" = "Etikett"; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Sparas i ~/.quotakit/config.json. Hämta nyckeln från Ollama-inställningarna."; + "Open Augment (Log Out & Back In)" = "Öppna Augment (logga ut och in igen)"; + "Overages" = "Överförbrukning"; + "Open projects" = "Öppna projekt"; + "Reported by OpenAI Admin API organization usage." = "Rapporteras av OpenAI Admin API:s organisationsanvändning."; + "%@: %@ credits" = "%@: %@ krediter"; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Kimi-autentiseringstoken så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Keychain Access Required" = "Åtkomst till Nyckelring krävs"; + "Username" = "Användarnamn"; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din MiniMax-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "30d spend" = "30 d kostnad"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "API-nyckeln har verifierats. Ollama exponerar inte Cloud-kvotgränser via API:t."; + "Series" = "Serie"; + "Total (30d): %@ credits" = "Totalt (30 d): %@ krediter"; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Klistra in en Cookie-header eller fullständig cURL-fångst från T3 Chat-inställningarna."; + "%d days of credits data" = "%d dagar med kreditdata"; + "used after next regen" = "använt efter nästa regenerering"; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Kimi K2-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Usage breakdown chart" = "Diagram över användningsfördelning"; + "Could not open browser for Antigravity" = "Kunde inte öppna webbläsare för Antigravity"; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Sparas i ~/.quotakit/config.json. AZURE_OPENAI_API_KEY stöds också."; + "Could not open Cursor login in your browser." = "Kunde inte öppna Cursor-inloggning i webbläsaren."; + "Latest" = "Senaste"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-resursens slutpunkt. AZURE_OPENAI_ENDPOINT stöds också."; + "No organizations loaded. Click Refresh after setting your API key." = "Inga organisationer har lästs in. Klicka på Uppdatera efter att du har angett API-nyckeln."; + "Copy path" = "Kopiera sökväg"; + "Paste a Cookie header from %@." = "Klistra in en Cookie-header från %@."; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om OAuth-token för Claude Code så att din Claude-användning kan hämtas. Klicka på OK för att fortsätta."; + "Base URL for the LLM-API-Key-Proxy instance." = "Bas-URL för LLM-API-Key-Proxy-instansen."; + "Paste a Cookie or Authorization header from %@." = "Klistra in en Cookie- eller Authorization-header från %@."; + "stale data" = "inaktuella data"; + "Quota" = "Kvot"; + "Auth source" = "Autentiseringskälla"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importerar Chrome-cookies från Xiaomi MiMo automatiskt."; + "No usage configured." = "Ingen användning konfigurerad."; + "Extra usage" = "Extra användning"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "Kontot finns inte längre i QuotaKit. Uppdatera kontolistan och försök igen."; + "%@ is waiting for permission" = "%@ väntar på behörighet"; + "Org ID (optional)" = "Org-ID (valfritt)"; + "Service" = "Tjänst"; + "Azure OpenAI key" = "Azure OpenAI-nyckel"; + "%@ cookies are disabled." = "%@-cookies är inaktiverade."; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Sparas i ~/.quotakit/config.json. Du kan också ange CROF_API_KEY."; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Synthetic-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; + "CodexBar could not update managed account storage." = "QuotaKit kunde inte uppdatera hanterad kontolagring."; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit kommer att be macOS Nyckelring om din Augment-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; + "Clear" = "Rensa"; + "No matching providers" = "Inga matchande leverantörer"; + "Search providers" = "Sök leverantörer"; + + +"language_vietnamese" = "Vietnamesiska"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings new file mode 100644 index 000000000..428341b28 --- /dev/null +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -0,0 +1,2097 @@ +/* Ukrainian localization for CodexBar */ + +" providers" = "провайдерів"; + +"(System)" = "(Система)"; + +"30d" = "30д"; + +"A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; + +"API key" = "Ключ API"; + +"API region" = "регіон API"; + +"API token" = "Маркер API"; + +"API tokens" = "маркери API"; + +"About" = "Про програму"; + +"Account" = "Обліковий запис"; + +"Accounts" = "Облікові записи"; + +"Accounts subtitle" = "Підзаголовок облікових записів"; + +"Active" = "Активний"; + +"Add" = "Додати"; + +"Add Workspace" = "Додати робочу область"; + +"Advanced" = "Розширені"; + +"All" = "Усі"; + +"Always allow prompts" = "Завжди дозволяти підказки"; + +"Animation pattern" = "Шаблон анімації"; + +"Antigravity login is managed in the app" = "Вхід в Antigravity керується в додатку"; + +"Applies only to the Security.framework OAuth keychain reader." = "Застосовується лише до зчитувача брелоків OAuth Security.framework."; + +"Auto falls back to the next source if the preferred one fails." = "Автоматичний перехід до наступного джерела, якщо бажане не вдається."; + +"Auto uses API first, then falls back to CLI on auth failures." = "Auto спочатку використовує API, а потім повертається до CLI у разі помилок авторизації."; + +"Auto-detect" = "Автоматичне визначення"; + +"Auto-refresh is off; use the menu's Refresh command." = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; + +"Auto-refresh: hourly · Timeout: 10m" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; + +"Automatic" = "Автоматично"; + +"Automatic imports browser cookies and WorkOS tokens." = "Автоматично імпортує файли cookie браузера та маркери WorkOS."; + +"Automatic imports browser cookies and local storage tokens." = "Автоматично імпортує файли cookie браузера та маркери локального зберігання."; + +"Automatic imports browser cookies for dashboard extras." = "Автоматично імпортує файли cookie браузера для додаткових функцій панелі інструментів."; + +"Automatic imports browser cookies for the web API." = "Автоматично імпортує файли cookie браузера для веб-API."; + +"Automatic imports browser cookies from Model Studio/Bailian." = "Автоматично імпортує файли cookie браузера з Model Studio/Bailian."; + +"Automatic imports browser cookies from admin.mistral.ai." = "Автоматично імпортує файли cookie браузера з admin.mistral.ai."; + +"Automatic imports browser cookies from opencode.ai." = "Автоматично імпортує файли cookie браузера з opencode.ai."; + +"Automatic imports browser cookies or stored sessions." = "Автоматично імпортує файли cookie браузера або збережені сесії."; + +"Automatic imports browser cookies." = "Автоматично імпортує файли cookie браузера."; + +"Automatically imports browser session cookie." = "Автоматично імпортує файл cookie сесії браузера."; + +"Automatically opens CodexBar when you start your Mac." = "Автоматично відкриває QuotaKit під час запуску Mac."; + +"Automation" = "Автоматизація"; + +"Average (\\(label1) + \\(label2))" = "Середній (\\(label1) + \\(label2))"; + +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Середній (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + +"Avoid Keychain prompts" = "Уникайте підказок Keychain"; + +"Balance" = "Баланс"; + +"Battery Saver" = "Економія батареї"; + +"Bordered" = "З рамкою"; + +"Build" = "Збірка"; + +"Built \\(buildTimestamp)" = "Побудовано \\(buildTimestamp)"; + +"Buy Credits..." = "Купити кредити..."; + +"Buy Credits…" = "Купити кредити…"; + +"CLI paths" = "Шляхи CLI"; + +"CLI sessions" = "Сесії CLI"; + +"Caches" = "Кеші"; + +"Cancel" = "Скасувати"; + +"Check for Updates…" = "Перевірити наявність оновлень…"; + +"Check for updates automatically" = "Автоматично перевіряти наявність оновлень"; + +"Check if you like your agents having some fun up there." = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; + +"Check provider status" = "Перевірте статус провайдера"; + +"Choose Codex workspace" = "Виберіть робочу область Codex"; + +"Choose the MiniMax host (global .io or China mainland .com)." = "Виберіть хост MiniMax (глобальний .io або материковий Китай .com)."; + +"Choose up to " = "Виберіть до"; + +"Choose up to \\(Self.maxOverviewProviders) providers" = "Виберіть до \\(Self.maxOverviewProviders) постачальників"; + +"Choose up to \\(count) providers" = "Виберіть до \\(count) постачальників"; + +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; + +"Choose which Codex account CodexBar should follow." = "Виберіть, який обліковий запис Codex має дотримуватися QuotaKit."; + +"Choose which window drives the menu bar percent." = "Виберіть, яке вікно керує відсотками панелі меню."; + +"Chrome" = "Chrome"; + +"Claude CLI not found" = "Claude CLI не знайдено"; + +"Claude binary" = "Claude бінарний"; + +"Claude cookies" = "Печиво Claude"; + +"Claude login failed" = "Помилка входу Claude"; + +"Claude login timed out" = "Час очікування входу Claude минув"; + +"Close" = "Закрити"; + +"Code review" = "Огляд коду"; + +"Codex CLI not found" = "Codex CLI не знайдено"; + +"Codex account login already running" = "Вхід до облікового запису Codex уже запущено"; + +"Codex binary" = "Двійковий код Codex"; + +"Codex login failed" = "Помилка входу в Codex"; + +"Codex login timed out" = "Час очікування входу в Codex минув"; + +"CodexBar Lifecycle Keepalive" = "Життєвий цикл QuotaKit Keepalive"; + +"CodexBar can't show its menu bar icon" = "QuotaKit не може показати піктограму панелі меню"; + +"CodexBar could not read managed account storage. " = "QuotaKit не вдалося прочитати сховище керованого облікового запису."; + +"Configure…" = "Налаштувати…"; + +"Connected" = "Підключено"; + +"Controls how much detail is logged." = "Контролює, скільки деталей реєструється."; + +"Cookie header" = "Заголовок файлу cookie"; + +"Cookie source" = "Джерело файлів cookie"; + +"Cookie: ..." = "Печиво: ..."; + +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте запис cURL із інформаційної панелі Abacus AI"; + +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення __Secure-next-auth.session-token"; + +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення маркера kimi-auth"; + +"Cookie: …" = "Печиво: …"; + +"CopilotDeviceFlow" = "CopilotDeviceFlow"; + +"Cost" = "Вартість"; + +"Could not add Codex account" = "Не вдалося додати обліковий запис Codex"; + +"Could not open Terminal for Gemini" = "Не вдалося відкрити термінал для Gemini"; + +"Could not start claude /login" = "Не вдалося запустити claude /login"; + +"Could not start codex login" = "Не вдалося розпочати вхід до Codex"; + +"Could not switch system account" = "Не вдалося змінити обліковий запис системи"; + +"Credits" = "Кредити"; + +"Credits history" = "Кредитна історія"; + +"Cursor login failed" = "Помилка входу в систему курсору"; + +"Custom" = "Користувацький"; + +"Custom Path" = "Спеціальний шлях"; + +"Daily Routines" = "Розпорядок дня"; + +"Debug" = "Налагодження"; + +"Default" = "Типово"; + +"Disable Keychain access" = "Вимкнути доступ Keychain"; + +"Disabled" = "Вимкнено"; + +"Dismiss" = "Закрити"; + +"Disconnected" = "Відключено"; + +"Display" = "Відображення"; + +"Display mode" = "Режим відображення"; + +"Display reset times as absolute clock values instead of countdowns." = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; + +"Done" = "Готово"; + +"Effective PATH" = "Ефективний ШЛЯХ"; + +"Email" = "Ел. пошта"; + +"Enable Merge Icons to configure Overview tab providers." = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; + +"Enable file logging" = "Увімкнути журналювання файлів"; + +"Enabled" = "Увімкнено"; + +"Error" = "Помилка"; + +"Error simulation" = "Симуляція помилок"; + +"Expose troubleshooting tools in the Debug tab." = "Розкрийте інструменти усунення несправностей на вкладці Debug."; + +"Failed" = "Помилка"; + +"False" = "Неправда"; + +"Fetch strategy attempts" = "Спроби отримання стратегії"; + +"Fetching" = "Отримання"; + +"Field" = "Поле"; + +"Field subtitle" = "Підзаголовок поля"; + +"Finish the current managed account change before switching the system account." = "Завершіть зміну поточного керованого облікового запису, перш ніж змінювати обліковий запис системи."; + +"Force animation on next refresh" = "Примусово запускати анімацію під час наступного оновлення"; + +"Gateway region" = "Регіон шлюзу"; + +"Gemini CLI not found" = "Gemini CLI не знайдено"; + +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, інциденти на поверхні в іконці та меню."; + +"General" = "Загальні"; + +"GitHub" = "GitHub"; + +"GitHub Copilot Login" = "Логін GitHub Copilot"; + +"GitHub Login" = "Вхід на GitHub"; + +"Hide details" = "Приховати деталі"; + +"Hide personal information" = "Приховати особисту інформацію"; + +"Historical tracking" = "Історичне відстеження"; + +"How often CodexBar polls providers in the background." = "Як часто QuotaKit опитує постачальників у фоновому режимі."; + +"Inactive" = "Неактивний"; + +"Install CLI" = "Встановіть CLI"; + +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Встановіть CLAude CLI (npm i -g @anthropic-ai/claude-code) і повторіть спробу."; + +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Встановіть Codex CLI (npm i -g @openai/codex) і повторіть спробу."; + +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Установіть Gemini CLI (npm i -g @google/gemini-cli) і повторіть спробу."; + +"JetBrains AI is ready" = "JetBrains AI готовий"; + +"JetBrains IDE" = "JetBrains IDE"; + +"Keep CLI sessions alive" = "Підтримуйте сесії CLI"; + +"Keyboard shortcut" = "Комбінація клавіш"; + +"Keychain access" = "Доступ через брелок"; + +"Keychain prompt policy" = "Політика оперативного брелока"; + +"Last \\(name) fetch failed:" = "Помилка останнього \\(name) отримання:"; + +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Помилка отримання останнього \\(self.store.metadata(for: self.provider).displayName):"; + +"Last attempt" = "Остання спроба"; + +"Link" = "Посилання"; + +"Loading animations" = "Завантаження анімацій"; + +"Loading…" = "Завантаження…"; + +"Local" = "Місцевий"; + +"Logging" = "Лісозаготівля"; + +"Login failed" = "Помилка входу"; + +"Login shell PATH (startup capture)" = "ШЛЯХ до оболонки входу (запис під час запуску)"; + +"Login timed out" = "Час входу минув"; + +"MCP details" = "Деталі MCP"; + +"Managed Codex accounts unavailable" = "Керовані облікові записи Codex недоступні"; + +"Managed account storage is unreadable. Live account access is still available, " = "Сховище керованого облікового запису не читається. Доступ до реального облікового запису все ще доступний,"; + +"Manual" = "Вручну"; + +"May your tokens never run out—keep agent limits in view." = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; + +"Menu bar" = "Рядок меню"; + +"Menu bar auto-shows the provider closest to its rate limit." = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; + +"Menu bar metric" = "Метрика панелі меню"; + +"Menu bar shows percent" = "Рядок меню показує відсотки"; + +"Menu content" = "Зміст меню"; + +"Merge Icons" = "Злиття значків"; + +"Never prompt" = "Ніколи не підказуйте"; + +"No" = "Ні"; + +"No Codex accounts detected yet." = "Облікових записів Codex ще не виявлено."; + +"No JetBrains IDE detected" = "JetBrains IDE не виявлено"; + +"No cost history data." = "Немає даних історії витрат."; + +"No data available" = "Немає даних"; + +"No data yet" = "Даних ще немає"; + +"No enabled providers available for Overview." = "Немає активованих постачальників, доступних для огляду."; + +"No providers selected" = "Не вибрано жодного постачальника"; + +"No token accounts yet." = "Жетонів ще немає."; + +"No usage breakdown data." = "Немає даних про використання."; + +"None" = "Жодного"; + +"Notifications" = "Сповіщення"; + +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона стає"; + +"OK" = "OK"; + +"Obscure email addresses in the menu bar and menu UI." = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; + +"Off" = "Вимкнено"; + +"Offline" = "Офлайн"; + +"On" = "Увімкнено"; + +"Online" = "Онлайн"; + +"Only on user action" = "Тільки за діями користувача"; + +"Open" = "Відкрити"; + +"Open API Keys" = "Відкрити ключі API"; + +"Open Amp Settings" = "Відкрийте налаштування підсилювача"; + +"Open Antigravity to sign in, then refresh CodexBar." = "Відкрийте Antigravity, щоб увійти, а потім оновіть QuotaKit."; + +"Open Browser" = "Відкрийте браузер"; + +"Open Coding Plan" = "Відкрити план кодування"; + +"Open Console" = "Відкрийте консоль"; + +"Open Dashboard" = "Відкрийте інформаційну панель"; + +"Open Mistral Admin" = "Відкрийте Mistral Admin"; + +"Open Menu Bar Settings" = "Відкрийте панель меню Параметри"; + +"Open Ollama Settings" = "Відкрийте налаштування Ollama"; + +"Open Terminal" = "Відкрийте термінал"; + +"Open Usage Page" = "Відкрити сторінку використання"; + +"Open Warp API Key Guide" = "Відкрийте посібник з ключів API Warp"; + +"Open menu" = "Відкрити меню"; + +"Open token file" = "Відкрити файл маркера"; + +"OpenAI cookies" = "Файли cookie OpenAI"; + +"OpenAI web extras" = "Веб-додатки OpenAI"; + +"Option A" = "Варіант А"; + +"Option B" = "Варіант Б"; + +"Optional override if workspace lookup fails." = "Додаткове перевизначення, якщо пошук робочої області не вдається."; + +"Options" = "Опції"; + +"Override auto-detection with a custom IDE base path" = "Замініть автоматичне виявлення власним базовим шляхом IDE"; + +"Overview" = "Огляд"; + +"Overview rows always follow provider order." = "Оглядові рядки завжди відповідають порядку постачальника."; + +"Overview tab providers" = "Постачальники вкладок огляду"; + +"Paste API key…" = "Вставити ключ API…"; + +"Paste API token…" = "Вставити маркер API…"; + +"Paste key…" = "Вставити ключ…"; + +"Paste sessionKey or OAuth token…" = "Вставте sessionKey або маркер OAuth…"; + +"Paste the Cookie header from a request to admin.mistral.ai. " = "Вставте заголовок Cookie із запиту до admin.mistral.ai."; + +"Paste token…" = "Вставити маркер…"; + +"Personal" = "Особистий"; + +"Picker" = "Пікер"; + +"Picker subtitle" = "Підзаголовок засобу вибору"; + +"Placeholder" = "Заповнювач"; + +"Plan" = "План"; + +"Play full-screen confetti when weekly usage resets." = "Відтворення конфетті на весь екран, коли тижневе використання скидається."; + +"Polls OpenAI/Claude status pages and Google Workspace for " = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для"; + +"Prevents any Keychain access while enabled." = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; + +"Primary (API key limit)" = "Основний (обмеження ключа API)"; + +"Primary (\\(label))" = "Основний (\\(label))"; + +"Primary (\\(metadata.sessionLabel))" = "Основний (\\(metadata.sessionLabel))"; + +"Probe logs" = "Зондові журнали"; + +"Progress bars fill as you consume quota (instead of showing remaining)." = "Індикатори прогресу заповнюються, коли ви витрачаєте квоту (замість відображення залишку)."; + +"Provider" = "Провайдер"; + +"Providers" = "Провайдери"; + +"Quit CodexBar" = "Закрийте QuotaKit"; + +"Random (default)" = "Випадковий (за замовчуванням)"; + +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; + +"Refresh" = "Оновити"; + +"Refresh cadence" = "Оновити каденцію"; + +"Remote" = "Дистанційний"; + +"Remove" = "Видалити"; + +"Remove Codex account?" = "Видалити обліковий запис Codex?"; + +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(account.email) з QuotaKit? Його керовану домашню сторінку Codex буде видалено."; + +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(email) з QuotaKit? Його керовану домашню сторінку Codex буде видалено."; + +"Remove selected account" = "Видалити вибраний обліковий запис"; + +"Replace critter bars with provider branding icons and a percentage." = "Замініть смужки тварин на значки бренду постачальника та відсоток."; + +"Replay selected animation" = "Повторити вибрану анімацію"; + +"Requires authentication via GitHub Device Flow." = "Потрібна автентифікація через GitHub Device Flow."; + +"Resets: \\(reset)" = "Скидання: \\(reset)"; + +"Rolling five-hour limit" = "Рухливий п'ятигодинний ліміт"; + +"Search hourly" = "Пошук щогодини"; + +"Secondary (\\(label))" = "Вторинний (\\(label))"; + +"Secondary (\\(metadata.weeklyLabel))" = "Вторинний (\\(metadata.weeklyLabel))"; + +"Select a provider" = "Виберіть провайдера"; + +"Select the IDE to monitor" = "Виберіть IDE для моніторингу"; + +"Session quota notifications" = "Сповіщення про квоту сеансу"; + +"Session tokens" = "Токени сесії"; + +"Settings" = "Налаштування"; + +"Show Codex Credits and Claude Extra usage sections in the menu." = "Показати в меню розділи використання кредитів Codex і Claude Extra."; + +"Show Debug Settings" = "Показати налаштування налагодження"; + +"Show all token accounts" = "Показати всі облікові записи маркерів"; + +"Show cost summary" = "Показати підсумок витрат"; + +"Show credits + extra usage" = "Показати кредити + додаткове використання"; + +"Show details" = "Показати деталі"; + +"Show most-used provider" = "Показати постачальника, який найчастіше використовується"; + +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Показувати піктограми постачальників у комутаторі (інакше показувати щотижневий рядок прогресу)."; + +"Show reset time as clock" = "Показувати час скидання як годинник"; + +"Show usage as used" = "Показати використання як використане"; + +"Sign in via button below" = "Увійдіть за допомогою кнопки нижче"; + +"Skip teardown between probes (debug-only)." = "Пропустити демонтаж між зондами (лише для налагодження)."; + +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Облікові записи маркерів стека в меню (інакше відображати панель перемикання облікових записів)."; + +"Start at Login" = "Почніть із входу"; + +"Status" = "Статус"; + +"Store Claude sessionKey cookies or OAuth access tokens." = "Зберігайте файли cookie Claude sessionKey або маркери доступу OAuth."; + +"Store multiple Abacus AI Cookie headers." = "Зберігайте кілька заголовків файлів cookie Abacus AI."; + +"Store multiple Augment Cookie headers." = "Зберігайте кілька заголовків Augment Cookie."; + +"Store multiple Cursor Cookie headers." = "Зберігайте кілька заголовків Cursor Cookie."; + +"Store multiple Factory Cookie headers." = "Зберігайте кілька заголовків Factory Cookie."; + +"Store multiple MiniMax Cookie headers." = "Зберігайте кілька заголовків MiniMax Cookie."; + +"Store multiple Mistral Cookie headers." = "Зберігайте кілька заголовків Mistral Cookie."; + +"Store multiple Ollama Cookie headers." = "Зберігайте кілька заголовків файлів cookie Ollama."; + +"Store multiple OpenCode Cookie headers." = "Зберігайте кілька заголовків файлів cookie OpenCode."; + +"Store multiple OpenCode Go Cookie headers." = "Зберігайте кілька заголовків OpenCode Go Cookie."; + +"Stored in the CodexBar config file." = "Зберігається у конфігураційному файлі QuotaKit."; + +"Stored in ~/.codexbar/config.json. " = "Зберігається в ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Зберігається в ~/.quotakit/config.json. Згенеруйте його на kimi-k2.ai."; + +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Зберігається в ~/.quotakit/config.json. Вставте ключ із панелі приладів Synthetic."; + +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Зберігається в ~/.quotakit/config.json. Вставте ключ API плану кодування з Model Studio."; + +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Зберігається в ~/.quotakit/config.json. Вставте ключ MiniMax API."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Зберігається в ~/.quotakit/config.json. Ви також можете надати KILO_API_KEY або"; + +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Зберігає локальну історію використання Codex (8 тижнів) для персоналізації прогнозів Pace."; + +"Subscription Utilization" = "Використання підписки"; + +"Surprise me" = "Здивуйте мене"; + +"Switcher shows icons" = "Перемикач показує значки"; + +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Символьне посилання QuotaKitCLI на /usr/local/bin і /opt/homebrew/bin як quotakit."; + +"System" = "Система"; + +"Temporarily shows the loading animation after the next refresh." = "Тимчасово показує анімацію завантаження після наступного оновлення."; + +"Tertiary (\\(label))" = "Вищий (\\(label))"; + +"Tertiary (\\(tertiaryTitle))" = "Вищий (\\(tertiaryTitle))"; + +"The default Codex account on this Mac." = "Обліковий запис Codex за умовчанням на цьому Mac."; + +"Toggle" = "Перемикач"; + +"Toggle subtitle" = "Перемкнути субтитри"; + +"Token" = "Токен"; + +"Trigger the menu bar menu from anywhere." = "Викликати меню панелі меню з будь-якого місця."; + +"True" = "Так"; + +"Twitter" = "Twitter"; + +"Unsupported" = "Не підтримується"; + +"Update Channel" = "Оновити канал"; + +"Updated" = "Оновлено"; + +"Updates unavailable in this build." = "Оновлення недоступні в цій збірці."; + +"Usage" = "Використання"; + +"Usage breakdown" = "Розбивка використання"; + +"Usage history (30 days)" = "Історія використання"; + +"Usage source" = "Джерело використання"; + +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Використовуйте BigModel для кінцевих точок материкового Китаю (open.bigmodel.cn)."; + +"Use a single menu bar icon with a provider switcher." = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; + +"Use international or China mainland console gateways for quota fetches." = "Використовуйте міжнародні консольні шлюзи або шлюзи материкової частини Китаю для отримання квот."; + +"Version" = "Версія"; + +"Version \\(self.versionString)" = "Версія \\(self.versionString)"; + +"Version \\(version)" = "Версія \\(version)"; + +"Version \\(versionString)" = "Версія \\(versionString)"; + +"Vertex AI Login" = "Вхід у Vertex AI"; + +"Wait for the current managed Codex login to finish before adding another account." = "Перш ніж додавати інший обліковий запис, дочекайтеся завершення поточного керованого входу в Codex."; + +"Waiting for Authentication..." = "Очікування автентифікації..."; + +"Website" = "Веб-сайт"; + +"Weekly limit confetti" = "Щотижневий ліміт конфетті"; + +"Weekly token limit" = "Тижневий ліміт жетонів"; + +"Weekly usage" = "Щотижневе використання"; + +"Weekly usage unavailable for this account." = "Щотижневе використання недоступне для цього облікового запису."; + +"Window: \\(window)" = "Вікно: \\(window)"; + +"Write logs to \\(self.fileLogPath) for debugging." = "Записати журнали в \\(self.fileLogPath) для налагодження."; + +"Yes" = "Так"; + +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; + +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; + +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 дн \\(cost)"; + +"\\(name): fetching…\\(elapsed)" = "\\(name): отримання…\\(elapsed)"; + +"\\(name): last attempt \\(when)" = "\\(name): остання спроба \\(when)"; + +"\\(name): no data yet" = "\\(name): ще немає даних"; + +"\\(name): unsupported" = "\\(name): не підтримується"; + +"all browsers" = "всі браузери"; + +"available again." = "знову доступний."; + +"built_format" = "Побудовано %@"; + +"copilot_complete_in_browser" = "Завершіть вхід у свій браузер."; + +"copilot_device_code" = "Код пристрою скопійовано в буфер обміну: %1$@\n\nПеревірити за адресою: %2$@"; + +"copilot_device_code_copied" = "Код пристрою скопійовано."; + +"copilot_verify_at" = "Підтвердити в %@"; + +"copilot_waiting_text" = "Завершіть вхід у свій браузер.\nЦе вікно закриється автоматично, коли вхід завершиться."; + +"copilot_window_closes_auto" = "Це вікно закривається автоматично після завершення входу."; + +"cost_status_error" = "%1$@: %2$@"; + +"cost_status_fetching" = "%1$@: отримання… %2$@"; + +"cost_status_last_attempt" = "%1$@: остання спроба %2$@"; + +"cost_status_no_data" = "%@: ще немає даних"; + +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; + +"cost_status_unsupported" = "%@: не підтримується"; + +"credits_remaining" = "Кредити: %@"; + +"cursor_on_demand" = "На вимогу: %@"; + +"cursor_on_demand_with_limit" = "На вимогу: %1$@ / %2$@"; + +"extra_usage_format" = "Додаткове використання: %1$@ / %2$@"; + +"jetbrains_detected_generate" = "Виявлено: %@. Скористайтеся помічником штучного інтелекту один раз, щоб створити дані квоти, а потім оновіть QuotaKit."; + +"jetbrains_detected_select" = "Виявлено: %@. Виберіть бажану IDE у налаштуваннях, а потім оновіть QuotaKit."; + +"last_fetch_failed_with_provider" = "Помилка останнього %@ отримання:"; + +"last_spend" = "Останні витрати: %@"; + +"mcp_model_usage" = "%1$@: %2$@"; + +"mcp_resets" = "Скидання: %@"; + +"mcp_window" = "Вікно: %@"; + +"metric_average" = "Середній (%1$@ + %2$@)"; + +"metric_primary" = "Основний (%@)"; + +"metric_secondary" = "Вторинний (%@)"; + +"metric_tertiary" = "Вищий (%@)"; + +"multiple_workspaces_found" = "QuotaKit знайшов кілька робочих областей для %@. Виберіть робочу область для додавання."; + +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + +"overview_choose_providers" = "Виберіть до %@ постачальників"; + +"remove_account_message" = "Видалити %@ з QuotaKit? Його керовану домашню сторінку Codex буде видалено."; + +"version_format" = "Версія %@"; + +"vertex_ai_login_instructions" = "Щоб відстежувати використання Vertex AI, пройдіть автентифікацію в Google Cloud.\n\n1. Відкрийте термінал\n2. Запустіть: gcloud auth application-default login\n3. Дотримуйтесь підказок браузера, щоб увійти\n4. Налаштуйте свій проект: gcloud config set project PROJECT_ID\n\nВідкрити термінал зараз?"; + +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID встановлено, але лише opencode, opencodego та deepgram підтримують workspaceID."; + +"© 2026 Peter Steinberger. MIT License." = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + + +/* General Pane */ +"section_system" = "Система"; + +"section_usage" = "Використання"; + +"section_automation" = "Автоматизація"; + +"language_title" = "Мова"; + +"language_subtitle" = "Змінює мову інтерфейсу. Для повного застосування потрібно перезапустити застосунок."; + +"language_system" = "Система"; + +"language_english" = "English"; + +"language_spanish" = "Español"; + +"language_catalan" = "Català"; + +"language_chinese_simplified" = "简体中文"; + +"language_chinese_traditional" = "繁體中文"; + +"language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "Svenska"; + +"language_french" = "Français"; + +"language_dutch" = "Нідерландська"; + +"language_ukrainian" = "Українська"; + +"language_vietnamese" = "В'єтнамська"; + +"start_at_login_title" = "Почніть із входу"; + +"start_at_login_subtitle" = "Автоматично відкриває QuotaKit під час запуску Mac."; + +"show_cost_summary" = "Показати підсумок витрат"; + +"show_cost_summary_subtitle" = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; + +"cost_history_days_title" = "Вікно історії: %d днів"; + +"cost_auto_refresh_info" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; + +"refresh_cadence_title" = "Оновити каденцію"; + +"refresh_cadence_subtitle" = "Як часто QuotaKit опитує постачальників у фоновому режимі."; + +"manual_refresh_hint" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; + +"check_provider_status_title" = "Перевірте статус провайдера"; + +"check_provider_status_subtitle" = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для Gemini/Antigravity, виявляючи інциденти в значку та меню."; + +"session_quota_notifications_title" = "Сповіщення про квоту сеансу"; + +"session_quota_notifications_subtitle" = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона знову стає доступною."; + +"quota_warning_notifications_title" = "Попередження про квоту"; + +"quota_warning_notifications_subtitle" = "Попереджає, коли залишок сеансу або тижневої квоти перевищує налаштовані порогові значення."; + +"quota_warnings_title" = "Попередження про квоту"; + +"quota_warning_session" = "сесії"; + +"quota_warning_session_capitalized" = "Сесія"; + +"quota_warning_weekly" = "щотижня"; + +"quota_warning_weekly_capitalized" = "Щотижня"; + +"quota_warning_notification_title" = "%1$@ %2$@ квота низька"; + +"quota_warning_notification_body" = "%1$@ залишилося. Досягнуто %2$d%% %3$@ порогового значення попередження."; + +"quota_warning_notification_body_with_account" = "Рахунок %1$@. Залишилося %2$@. Досягнуто %3$d%% %4$@ порогового значення попередження."; + +"session_depleted_notification_title" = "%@ сеанс вичерпано"; + +"session_depleted_notification_body" = "Залишилося 0%. Надішле сповіщення, коли знову стане доступним."; + +"session_restored_notification_title" = "%@ сеанс відновлено"; + +"session_restored_notification_body" = "Квота сесії знову доступна."; + +"quota_warning_warn_at" = "Попередити при"; + +"quota_warning_global_threshold_subtitle" = "Відсотки, що залишилися для вікон сесії та тижня, якщо постачальник не замінить їх."; + +"quota_warning_sound" = "Відтворити звук сповіщення"; + +"quota_warning_provider_inherits" = "Використовує глобальні параметри попередження про квоту, якщо тут не налаштовано вікно."; + +"quota_warning_customize_thresholds" = "Налаштувати порогові значення %@"; + +"quota_warning_enable_warnings" = "Увімкнути %@ попереджень"; + +"quota_warning_window_warn_at" = "%@ попередити о"; + +"quota_warning_off" = "Вимкнено"; + +"quota_warning_inherited" = "Успадковано: %@"; + +"quota_warning_depleted_only" = "лише виснажені"; + +"quota_warning_upper" = "Верхній"; + +"quota_warning_lower" = "Нижній"; + +"apply" = "Застосувати"; + +"quit_app" = "Закрийте QuotaKit"; + + +/* Tab titles */ +"tab_general" = "Загальні"; + +"tab_providers" = "Провайдери"; + +"tab_display" = "Відображення"; + +"tab_advanced" = "Розширені"; + +"tab_about" = "Про програму"; + +"tab_debug" = "Налагодження"; + + +/* Providers Pane */ +"select_a_provider" = "Виберіть провайдера"; + +"cancel" = "Скасувати"; + +"last_fetch_failed" = "остання вибірка не вдалася"; + +"usage_not_fetched_yet" = "використання ще не отримано"; + +"managed_account_storage_unreadable" = "Сховище керованого облікового запису не читається. Доступ до поточного облікового запису все ще доступний, але керовані дії додавання, повторної авторизації та видалення вимкнено, доки магазин не буде відновлено."; + +"remove_codex_account_title" = "Видалити обліковий запис Codex?"; + +"remove" = "видалити"; + +"managed_login_already_running" = "Керований вхід до Codex вже запущено. Зачекайте, поки це завершиться, перш ніж додавати або повторно автентифікувати інший обліковий запис."; + +"managed_login_failed" = "Керований вхід Codex не завершено. Переконайтеся, що `codex --version` працює в терміналі. Якщо macOS заблокувала або перемістила `codex` у кошик, видаліть застарілі повторювані встановлення, запустіть `npm install -g --include=optional @openai/codex@latest`, а потім повторіть спробу."; + +"codex_login_output" = "код входу в систему:"; + +"managed_login_missing_email" = "Вхід до Codex завершено, але електронна адреса облікового запису недоступна. Повторіть спробу після того, як підтвердите, що обліковий запис повністю ввійшли."; + +"login_success_notification_title" = "%@ вхід успішний"; + +"login_success_notification_body" = "Ви можете повернутися до програми; аутентифікація завершена."; + +"workspace_selection_cancelled" = "QuotaKit знайшов кілька робочих областей, але жодна робоча область не була вибрана."; + +"unsafe_managed_home" = "QuotaKit відмовився змінити неочікуваний керований домашній шлях: %@"; + +"menu_bar_metric_title" = "Метрика панелі меню"; + +"menu_bar_metric_subtitle" = "Виберіть, яке вікно керує відсотками панелі меню."; + +"menu_bar_metric_subtitle_deepseek" = "Показує баланс DeepSeek на панелі меню."; + +"menu_bar_metric_subtitle_moonshot" = "Показує баланс API Moonshot / Kimi на панелі меню."; + +"menu_bar_metric_subtitle_mistral" = "Показує поточні витрати Mistral API на панелі меню."; + +"menu_bar_metric_subtitle_kimik2" = "Показує кредити API-ключа Kimi K2 на панелі меню."; + +"automatic" = "Автоматичний"; + +"primary_api_key_limit" = "Основний (обмеження ключа API)"; + + +/* Display Pane */ +"section_menu_bar" = "Рядок меню"; + +"merge_icons_title" = "Злиття значків"; + +"merge_icons_subtitle" = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; + +"switcher_shows_icons_title" = "Перемикач показує значки"; + +"switcher_shows_icons_subtitle" = "Показувати піктограми постачальників у комутаторі (інакше показувати щотижневий рядок прогресу)."; + +"show_most_used_provider_title" = "Показати постачальника, який найчастіше використовується"; + +"show_most_used_provider_subtitle" = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; + +"menu_bar_shows_percent_title" = "Рядок меню показує відсотки"; + +"menu_bar_shows_percent_subtitle" = "Замініть смужки тварин на значки бренду постачальника та відсоток."; + +"display_mode_title" = "Режим відображення"; + +"display_mode_subtitle" = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; + +"section_menu_content" = "Зміст меню"; + +"show_usage_as_used_title" = "Показати використання як використане"; + +"show_usage_as_used_subtitle" = "Індикатори прогресу заповнюються, коли ви витрачаєте квоту (замість відображення залишку)."; + +"show_quota_warning_markers_title" = "Показати маркери попередження про квоти"; + +"show_quota_warning_markers_subtitle" = "Малюйте порогові позначки на панелях використання, коли налаштовано попередження про квоту."; + +"weekly_progress_work_days_title" = "Щотижневі робочі дні"; + +"weekly_progress_work_days_subtitle" = "Позначте межі дня на смугах тижневого використання."; + +"show_reset_time_as_clock_title" = "Показувати час скидання як годинник"; + +"show_reset_time_as_clock_subtitle" = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; + +"show_provider_changelog_links_title" = "Показати посилання журналу змін провайдера"; + +"show_provider_changelog_links_subtitle" = "Додає в меню посилання на примітки до випуску для підтримуваних постачальників, що підтримують CLI."; + +"show_credits_extra_usage_title" = "Показати кредити + додаткове використання"; + +"show_credits_extra_usage_subtitle" = "Показати в меню розділи використання кредитів Codex і Claude Extra."; + +"show_all_token_accounts_title" = "Показати всі облікові записи маркерів"; + +"show_all_token_accounts_subtitle" = "Облікові записи маркерів стека в меню (інакше відображати панель перемикання облікових записів)."; + +"multi_account_layout_title" = "Макет кількох облікових записів"; + +"multi_account_layout_subtitle" = "Виберіть сегментоване перемикання облікових записів або складені картки облікових записів."; + +"multi_account_layout_segmented" = "Сегментований"; + +"multi_account_layout_stacked" = "складені"; + +"overview_tab_providers_title" = "Постачальники вкладок огляду"; + +"configure" = "Налаштувати…"; + +"overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; + +"overview_no_providers_hint" = "Немає активованих постачальників, доступних для огляду."; + +"overview_rows_follow_order" = "Оглядові рядки завжди відповідають порядку постачальника."; + +"overview_no_providers_selected" = "Не вибрано жодного постачальника"; + + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Комбінація клавіш"; + +"open_menu_shortcut_title" = "Відкрити меню"; + +"open_menu_shortcut_subtitle" = "Викликати меню панелі меню з будь-якого місця."; + +"install_cli" = "Встановіть CLI"; + +"install_cli_subtitle" = "Символьне посилання QuotaKitCLI на /usr/local/bin і /opt/homebrew/bin як quotakit."; + +"cli_not_found" = "QuotaKitCLI не знайдено в пакеті програм."; + +"no_writable_bin_dirs" = "Не знайдено записуваних каталогів кошика."; + +"show_debug_settings_title" = "Показати налаштування налагодження"; + +"show_debug_settings_subtitle" = "Розкрийте інструменти усунення несправностей на вкладці Debug."; + +"surprise_me_title" = "Здивуйте мене"; + +"surprise_me_subtitle" = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; + +"weekly_limit_confetti_title" = "Щотижневий ліміт конфетті"; + +"weekly_limit_confetti_subtitle" = "Відтворення конфетті на весь екран, коли тижневе використання скидається."; + +"hide_personal_info_title" = "Приховати особисту інформацію"; + +"hide_personal_info_subtitle" = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; + +"show_provider_storage_usage_title" = "Показати використання сховища постачальника"; + +"show_provider_storage_usage_subtitle" = "Показати використання локального диска в меню. Сканує відомі шляхи, що належать провайдеру, у фоновому режимі."; + +"section_keychain_access" = "Доступ через брелок"; + +"keychain_access_caption" = "Вимкніть усі функції читання та запису Keychain. Використовуйте це, якщо macOS постійно запитує «Chrome/Brave/Edge Safe Storage» навіть після натискання «Завжди дозволяти». Імпорт файлів cookie браузера недоступний, якщо ввімкнено; вставте заголовки файлів cookie вручну в Провайдери. Claude/Codex OAuth через CLI все ще працює."; + +"disable_keychain_access_title" = "Вимкнути доступ Keychain"; + +"disable_keychain_access_subtitle" = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; + + +/* About Pane */ +"about_tagline" = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; + +"link_github" = "GitHub"; + +"link_website" = "Веб-сайт"; + +"link_twitter" = "Twitter"; + +"link_email" = "Електронна пошта"; + +"check_updates_auto" = "Автоматично перевіряти наявність оновлень"; + +"update_channel" = "Оновити канал"; + +"check_for_updates" = "Перевірити наявність оновлень…"; + +"updates_unavailable" = "Оновлення недоступні в цій збірці."; + +"copyright" = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + + +/* Debug Pane */ +"section_logging" = "Лісозаготівля"; + +"enable_file_logging" = "Увімкнути журналювання файлів"; + +"enable_file_logging_subtitle" = "Записати журнали до %@ для налагодження."; + +"verbosity_title" = "Багатослівність"; + +"verbosity_subtitle" = "Контролює, скільки деталей реєструється."; + +"open_log_file" = "Відкрити файл журналу"; + +"force_animation_next_refresh" = "Примусово запускати анімацію під час наступного оновлення"; + +"force_animation_next_refresh_subtitle" = "Тимчасово показує анімацію завантаження після наступного оновлення."; + +"section_loading_animations" = "Завантаження анімацій"; + +"loading_animations_caption" = "Виберіть шаблон і відтворіть його на панелі меню. \\\"Випадкове\\\" зберігає існуючу поведінку."; + +"animation_random_default" = "Випадковий (за замовчуванням)"; + +"replay_selected_animation" = "Повторити вибрану анімацію"; + +"blink_now" = "Поморгай зараз"; + +"section_probe_logs" = "Зондові журнали"; + +"probe_logs_caption" = "Отримати останній результат тестування для налагодження; Копія зберігає повний текст."; + +"fetch_log" = "Отримати журнал"; + +"copy" = "Копіювати"; + +"save_to_file" = "Зберегти у файл"; + +"load_parse_dump" = "Завантажити дамп аналізу"; + +"rerun_provider_autodetect" = "Повторно запустіть автоматичне визначення постачальника"; + +"loading" = "Завантаження…"; + +"no_log_yet_fetch" = "Журналу ще немає. Отримати для завантаження."; + +"section_fetch_strategy" = "Спроби отримання стратегії"; + +"fetch_strategy_caption" = "Рішення та помилки конвеєра останньої вибірки для постачальника."; + +"section_openai_cookies" = "Файли cookie OpenAI"; + +"openai_cookies_caption" = "Імпорт файлів cookie + сканування журналів WebKit з останньої спроби файлів cookie OpenAI."; + +"no_log_yet" = "Журналу ще немає. Оновіть файли cookie OpenAI у Постачальники → Codex, щоб запустити імпорт."; + +"section_caches" = "Тайники"; + +"caches_caption" = "Очистити кеш-пам’ять результатів сканування витрат або кешу файлів cookie браузера."; + +"clear_cookie_cache" = "Очистити кеш cookie"; + +"clear_cost_cache" = "Очистити кеш вартості"; + +"section_notifications" = "Сповіщення"; + +"notifications_caption" = "Запуск тестових сповіщень для 5-годинного вікна сеансу (вичерпано/відновлено)."; + +"post_depleted" = "Повідомлення вичерпано"; + +"post_restored" = "Пост відновлено"; + +"section_cli_sessions" = "Сесії CLI"; + +"cli_sessions_caption" = "Підтримуйте сесії Codex/Claude CLI після зонду. За замовчуванням виходить після збору даних."; + +"keep_cli_sessions_alive" = "Підтримуйте сесії CLI"; + +"keep_cli_sessions_alive_subtitle" = "Пропустити демонтаж між зондами (лише для налагодження)."; + +"reset_cli_sessions" = "Скидання сеансів CLI"; + +"section_error_simulation" = "Симуляція помилок"; + +"error_simulation_caption" = "Введіть фальшиве повідомлення про помилку в картку меню для тестування макета."; + +"set_menu_error" = "Помилка налаштування меню"; + +"clear_menu_error" = "Помилка очищення меню"; + +"set_cost_error" = "Помилка встановлення вартості"; + +"clear_cost_error" = "Очистити помилку вартості"; + +"section_cli_paths" = "Шляхи CLI"; + +"cli_paths_caption" = "Вирішено двійковий шар Codex і PATH; запуск входу PATH захоплення (короткий тайм-аут)."; + +"codex_binary" = "Двійковий код Codex"; + +"claude_binary" = "Claude бінарний"; + +"effective_path" = "Ефективний ШЛЯХ"; + +"unavailable" = "Недоступний"; + +"login_shell_path" = "ШЛЯХ до оболонки входу (запис під час запуску)"; + +"cleared" = "Очищено."; + +"no_fetch_attempts" = "Ще жодних спроб отримання."; + +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe може блокувати програми панелі меню в системних параметрах → Рядок меню → Дозволити на панелі меню. QuotaKit працює, але macOS може приховувати свій значок. Відкрийте налаштування рядка меню та ввімкніть QuotaKit."; + + +/* Metric preferences */ +"metric_pref_automatic" = "Автоматичний"; + +"metric_pref_primary" = "Первинний"; + +"metric_pref_secondary" = "Вторинний"; + +"metric_pref_tertiary" = "Третинний"; + +"metric_pref_extra_usage" = "Додаткове використання"; + +"metric_pref_average" = "Середній"; + + +/* Display modes */ +"display_mode_percent" = "Відсоток"; + +"display_mode_pace" = "Темп"; + +"display_mode_both" = "Обидва"; + +"display_mode_percent_desc" = "Показати залишок/використаний відсоток (наприклад, 45%)"; + +"display_mode_pace_desc" = "Показати індикатор темпу (наприклад, +5%)"; + +"display_mode_both_desc" = "Показати відсоток і темп (наприклад, 45% · +5%)"; + + +/* Provider status */ +"status_operational" = "Працює"; + +"status_partial_outage" = "Часткове відключення"; + +"status_major_outage" = "Серйозний збій"; + +"status_critical_issue" = "Критична проблема"; + +"status_maintenance" = "Технічне обслуговування"; + +"status_unknown" = "Статус невідомий"; + + +/* Refresh frequency */ +"refresh_manual" = "Інструкція"; + +"refresh_1min" = "1 хв"; + +"refresh_2min" = "2 хв"; + +"refresh_5min" = "5 хв"; + +"refresh_15min" = "15 хв"; + +"refresh_30min" = "30 хв"; + + +/* Additional keys */ +"not_found" = "Не знайдено"; + + +/* Cost estimation */ +"cost_header_estimated" = "Вартість (орієнтовна)"; + +"cost_estimate_hint" = "Оцінка з місцевих журналів · може відрізнятися від вашого рахунку"; + +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Не виявлено JetBrains IDE з AI Assistant. Встановіть JetBrains IDE і ввімкніть AI Assistant."; + +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Маркер OpenRouter API не налаштовано. Установіть змінну середовища OPENROUTER_API_KEY або налаштуйте її в налаштуваннях."; + +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Маркер API z.ai не знайдено. Установіть apiKey у ~/.quotakit/config.json або Z_AI_API_KEY."; + +"Missing DeepSeek API key." = "Відсутній ключ API DeepSeek."; + +"%@ is unavailable in the current environment." = "%@ недоступний у поточному середовищі."; + +"All Systems Operational" = "Всі системи працюють"; + +"Last 30 days" = "Останні 30 днів"; + +"Last 30 days:" = "Останні 30 днів:"; + +"This month" = "Цей місяць"; + +"Store multiple OpenAI API keys." = "Зберігайте кілька ключів OpenAI API."; + +"Admin API key" = "Ключ API адміністратора"; + +"Open billing" = "Відкритий білінг"; + +"Google accounts" = "облікові записи Google"; + +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Зберігайте кілька облікових записів Antigravity Google OAuth для швидкого перемикання."; + +"Add Google Account" = "Додайте обліковий запис Google"; + +"Open Token Plan" = "Відкритий план токенів"; + +"Text Generation" = "Генерація тексту"; + +"Text to Speech" = "Перетворення тексту в мовлення"; + +"Music Generation" = "Музичне покоління"; + +"Image Generation" = "Генерація зображень"; + +"No local data found" = "Немає локальних даних"; + +"Credits unavailable; keep Codex running to refresh." = "Кредити недоступні; продовжуйте працювати Codex для оновлення."; + +"No available fetch strategy for minimax." = "Немає доступної стратегії вибірки для minimax."; + +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Сеанс курсору не знайдено. Увійдіть на cursor.com у Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX або Edge Canary. Якщо ви користуєтеся Safari, надайте QuotaKit повний доступ до диска в системних параметрах ▸ Конфіденційність і безпека. Ви також можете ввійти в Cursor з меню QuotaKit (Додати/змінити обліковий запис)."; + +"No OpenCode session cookies found in browsers." = "У браузерах не знайдено сеансових файлів cookie OpenCode."; + +"No available fetch strategy for %@." = "Немає доступної стратегії отримання для %@."; + +"Today" = "Сьогодні"; + +"Today tokens" = "Сьогодні жетони"; + +"30d cost" = "Вартість 30д"; + +"30d tokens" = "30d жетонів"; + +"Latest tokens" = "Останні жетони"; + +"Top model" = "Топ модель"; + +"Storage" = "Зберігання"; + +"Add Account..." = "Додати обліковий запис..."; + +"Usage Dashboard" = "Панель використання"; + +"Status Page" = "Сторінка стану"; + +"Settings..." = "Налаштування..."; + +"About CodexBar" = "Про QuotaKit"; + +"Quit" = "Вийти"; + +"Last %d day" = "Останній %d день"; + +"Last %d days" = "Останні %d днів"; + +"%@ tokens" = "%@ токенів"; + +"Latest billing day" = "Останній розрахунковий день"; + +"Latest billing day (%@)" = "Останній розрахунковий день (%@)"; + +"%@ left" = "Залишилося %@"; + +"Resets %@" = "Скидання %@"; + +"Resets in %@" = "Скидання через %@"; + +"Resets now" = "Скидає зараз"; + +"Lasts until reset" = "Триває до скидання"; + +"Updated %@" = "Оновлено %@"; + +"Updated %@h ago" = "Оновлено %@ год тому"; + +"Updated %@m ago" = "Оновлено %@хв тому"; + +"Updated just now" = "Оновлено щойно"; + +"Projected empty in %@" = "Передбачається порожній у %@"; + +"Runs out in %@" = "Закінчується за %@"; + +"Pace: %@" = "Темп: %@"; + +"Pace: %@ · %@" = "Темп: %@ · %@"; + +"%@ · %@" = "%@ · %@"; + +"≈ %d%% run-out risk" = "≈ %d%% ризик вичерпання"; + +"%d%% in deficit" = "%d%% в дефіциті"; + +"%d%% in reserve" = "%d%% в резерві"; + +"usage_percent_suffix_left" = "зліва"; + +"usage_percent_suffix_used" = "використовується"; + +"Store multiple DeepSeek API keys." = "Зберігайте кілька ключів DeepSeek API."; + +"This week" = "Цього тижня"; + +"Week" = "тиждень"; + +"Month" = "місяць"; + +"Models" = "Моделі"; + +"24h tokens" = "24-годинні жетони"; + +"Latest hour" = "Остання година"; + +"Peak hour" = "Година пік"; + +"Top method" = "Топовий спосіб"; + +"30d cash" = "30д готівкою"; + +"30d billing history from MiniMax web session" = "30-денна історія платежів з веб-сесії MiniMax"; + +"AWS Cost Explorer billing can lag." = "Виставлення рахунків AWS Cost Explorer може затримуватися."; + +"Rate limit: %d / %@" = "Ліміт швидкості: %d / %@"; + +"Key remaining" = "Ключ залишився"; + +"No limit set for the API key" = "Для ключа API не встановлено обмежень"; + +"API key limit unavailable right now" = "Ліміт ключів API зараз недоступний"; + +"This month: %@ tokens" = "Цей місяць: %@ токенів"; + +"No utilization data yet." = "Даних про використання ще немає."; + +"No %@ utilization data yet." = "Ще немає даних про використання %@."; + +"%@: %@%% used" = "%@: використано %@%%."; + +"%dd" = "%dд"; + +"today" = "сьогодні"; + +"just now" = "тільки зараз"; + +"On pace" = "В темпі"; + +"Runs out now" = "Зараз закінчується"; + +"Projected empty now" = "Зараз проектується порожнім"; + +"Switch Account..." = "Змінити обліковий запис..."; + +"Update ready, restart now?" = "Оновлення готове, перезапустити?"; + +"Daily" = "Щодня"; + +"Hourly Tokens" = "Погодинні жетони"; + +"No data" = "Немає даних"; + +"No usage breakdown data available." = "Немає даних про розподіл використання."; + + +"Today: %@ · %@ tokens" = "Сьогодні: %@ · %@ токенів"; + +"Today: %@" = "Сьогодні: %@"; + +"Today: %@ tokens" = "Сьогодні: %@ токенів"; + +"Last 30 days: %@ · %@ tokens" = "Останні 30 днів: %@ · %@ токенів"; + +"Last 30 days: %@" = "Останні 30 днів: %@"; + +"Est. total (30d): %@" = "Приблизно всього (30 днів): %@"; + +"Est. total (%@): %@" = "Приблизно всього (%@): %@"; + +"Hover a bar for details" = "Щоб переглянути деталі, наведіть курсор на панель"; + +"%@: %@ · %@ tokens" = "%@: %@ · %@ токенів"; + +"No providers selected for Overview." = "Для огляду не вибрано жодного постачальника."; + +"No overview data available." = "Немає оглядових даних."; + +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto спочатку використовує локальний API IDE, а потім Google OAuth, коли IDE закрито."; + +"Login with Google" = "Увійти через Google"; + + +/* Popup panels */ +"No usage configured." = "Використання не налаштовано."; + +"Quota" = "Квота"; + +"tokens" = "жетони"; + +"requests" = "запити"; + +"Latest" = "Останній"; + +"Monthly" = "Щомісяця"; + +"Sonnet" = "Сонет"; + +"Overages" = "Надлишки"; + +"Activity" = "діяльність"; + +"Copied" = "Скопійовано"; + +"Copy error" = "Помилка копіювання"; + +"Copy path" = "Копіювати шлях"; + +"Extra usage spent" = "Витрачено додаткове використання"; + +"Credits remaining" = "Залишок кредитів"; + +"Using CLI fallback" = "Використання запасного CLI"; + +"Balance updates in near-real time (up to 5 min lag)" = "Оновлення балансу майже в реальному часі (затримка до 5 хвилин)"; + +"Daily billing data finalizes at 07:00 UTC" = "Щоденні платіжні дані завершуються о 07:00 UTC"; + +"%@ of %@ credits left" = "Залишилося %@ з %@ кредитів"; + +"%@ of %@ bonus credits left" = "Залишилося %@ з %@ бонусних кредитів"; + +"%@ / %@ (%@ remaining)" = "%@ / %@ (залишилося %@)"; + +"%@/%@ left" = "Залишилося %@/%@"; + +"Gemini Flash" = "Gemini Флеш"; + +"Regenerates %@" = "Регенерує %@"; + +"used after next regen" = "використовується після наступної регенерації"; + +"after next regen" = "після наступної реген"; + +"Near full" = "Майже повний"; + +"Full in ~1 regen" = "Повний за ~1 регенерацію"; + +"Full in ~%.0f regens" = "Повний ~%.0f регенерацій"; + +"Overage usage" = "Надмірне використання"; + +"Overage cost" = "Перевищення вартості"; + +"credits" = "кредити"; + +"Zen balance" = "Дзен баланс"; + +"API spend" = "Витрати API"; + +"Extra usage" = "Додаткове використання"; + +"Quota usage" = "Використання квоти"; + +"%.0f%% used" = "Використано %.0f%%."; + +"Usage history (today)" = "Історія використання (сьогодні)"; + +"Usage history (%d days)" = "Історія використання (%d днів)"; + +"%d percent remaining" = "Залишилося %d відсотків"; + +"Unknown" = "Невідомо"; + +"stale data" = "застарілі дані"; + +"No credits history data." = "Немає даних про кредитну історію."; + +"No credits history data available." = "Немає даних про кредитну історію."; + +"Credits history chart" = "Графік кредитної історії"; + +"%d days of credits data" = "Дані кредитів за %d днів"; + +"Usage breakdown chart" = "Діаграма розподілу використання"; + +"%d days of usage data across %d services" = "%d днів використання даних у %d службах"; + +"Cost history chart" = "Графік історії витрат"; + +"%d days of cost data" = "Дані про витрати за %d днів"; + +"Plan utilization chart" = "Графік використання плану"; + +"%d utilization samples" = "%d зразки використання"; + +"Hourly Usage" = "Погодинне використання"; + +"Usage remaining" = "Залишилося використання"; + +"Usage used" = "Використання використано"; + +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Ключ API перевірено. Ollama не розкриває обмеження квот Cloud через API."; + +"Last 30 days: %@ tokens" = "Останні 30 днів: %@ токенів"; + +"7d spend" = "7д витратити"; + +"30d spend" = "витратити 30 днів"; + +"Cache read" = "Читання кешу"; + +"Claude Admin API 30 day spend trend" = "Claude Admin API 30-денна тенденція витрат"; + +"OpenRouter API key spend trend" = "Тенденція витрат на ключ OpenRouter API"; + +"z.ai hourly token trend" = "погодинний тренд токена z.ai"; + +"MiniMax 30 day token usage trend" = "Тенденція використання токенів MiniMax за 30 днів"; + +"Today cash" = "Сьогодні готівкою"; + +"DeepSeek 30 day token usage trend" = "30-денна тенденція використання токенів DeepSeek"; + +"cache-hit input" = "введення кешу"; + +"cache-miss input" = "cache-miss input"; + +"output" = "вихід"; + +"Requests" = "Запити"; + +"Reported by OpenAI Admin API organization usage." = "Повідомлено про використання організацією OpenAI Admin API."; + +"Reported by Mistral billing usage." = "Повідомлено Mistral billing usage."; + +"Google OAuth" = "Google OAuth"; + +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Додайте облікові записи через GitHub OAuth Device Flow на вибраному хості."; + +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Зберігає кожен обліковий запис Google, у який ви ввійшли, для швидкого перемикання Antigravity. Використовує OAuth Antigravity.app, якщо доступний, або ANTIGRAVITY_OAUTH_CLIENT_ID і ANTIGRAVITY_OAUTH_CLIENT_SECRET як заміну."; + +"Manual cleanup: past sessions" = "Очищення вручну: минулі сесії"; + +"Clearing removes past resume, continue, and rewind history." = "Очищення видаляє історію відновлення, продовження та перемотування назад."; + +"Manual cleanup: file checkpoints" = "Ручне очищення: контрольні точки файлів"; + +"Clearing removes checkpoint restore data for previous edits." = "Очищення видаляє дані відновлення контрольної точки для попередніх змін."; + +"Manual cleanup: saved plans" = "Ручне очищення: збережені плани"; + +"Clearing removes old plan-mode files." = "Очищення видаляє старі файли планового режиму."; + +"Manual cleanup: debug logs" = "Ручне очищення: журнали налагодження"; + +"Clearing removes past debug logs." = "Очищення видаляє попередні журнали налагодження."; + +"Manual cleanup: attachment cache" = "Очищення вручну: кеш вкладень"; + +"Clearing removes cached large pastes or attached images." = "Очищення видаляє кешовані великі вставки або прикріплені зображення."; + +"Manual cleanup: session metadata" = "Очищення вручну: метадані сеансу"; + +"Clearing removes per-session environment metadata." = "Очищення видаляє метадані середовища для кожного сеансу."; + +"Manual cleanup: shell snapshots" = "Ручне очищення: знімки оболонки"; + +"Clearing removes leftover runtime shell snapshot files." = "Очищення видаляє залишкові файли знімків оболонки виконання."; + +"Manual cleanup: legacy todos" = "Очищення вручну: застарілі завдання"; + +"Clearing removes legacy per-session task lists." = "Очищення видаляє застарілі списки сеансових завдань."; + +"Manual cleanup: sessions" = "Ручне очищення: сесії"; + +"Clearing removes past Codex session history." = "Очищення видаляє минулу історію сеансів Codex."; + +"Manual cleanup: archived sessions" = "Очищення вручну: заархівовані сеанси"; + +"Clearing removes archived Codex session history." = "Очищення видаляє архівну історію сеансів Codex."; + +"Manual cleanup: cache" = "Очищення вручну: кеш"; + +"Clearing removes provider-owned cached data." = "Очищення видаляє кешовані дані постачальника."; + +"Manual cleanup: logs" = "Ручне очищення: журнали"; + +"Clearing removes local diagnostic logs." = "Очищення видаляє локальні журнали діагностики."; + +"Manual cleanup: file history" = "Ручне очищення: історія файлів"; + +"Clearing removes local edit checkpoint history." = "Очищення видаляє локальну історію контрольних точок редагування."; + +"Manual cleanup: temporary data" = "Очищення вручну: тимчасові дані"; + +"Clearing removes local temporary provider data." = "Очищення видаляє локальні тимчасові дані постачальника."; + +"Total: %@" = "Усього: %@"; + +"%d more items" = "ще %d елементів"; + +"Cleanup ideas" = "Ідеї ​​очищення"; + +"%d unreadable item(s) skipped" = "%d нечитабельних елементів пропущено"; + + +"API key limit" = "Обмеження ключа API"; + +"Auth" = "Авт"; + +"Auto" = "Авто"; + +"Disabled — no recent data" = "Вимкнено — немає останніх даних"; + +"Limits not available" = "Обмеження недоступні"; + +"No usage yet" = "Поки що не використовується"; + +"Not fetched yet" = "Ще не отримано"; + +"Refreshing" = "Освіжаючий"; + +"Session" = "Сесія"; + +"Source" = "Джерело"; + +"State" = "Держава"; + +"Unavailable" = "Недоступний"; + +"Weekly" = "Щотижня"; + +"not detected" = "не виявлено"; + +"Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; + +"minimax_usage_amount_format" = "Використання: %@ / %@"; + +"minimax_used_percent_format" = "Використаний %@"; + +"minimax_service_text_generation" = "Генерація тексту"; + +"minimax_service_text_to_speech" = "Перетворення тексту в мовлення"; + +"minimax_service_music_generation" = "Музичне покоління"; + +"minimax_service_image_generation" = "Генерація зображень"; + +"minimax_service_lyrics_generation" = "Генерація пісень"; + +"minimax_service_coding_plan_vlm" = "План кодування VLM"; + +"minimax_service_coding_plan_search" = "Пошук плану кодування"; + + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ чекає на дозвіл"; + +"%@ requests" = "%@ запитів"; + +"%@: %@ credits" = "%@: %@ кредитів"; + +"30d requests" = "30d запитів"; + +"4 days" = "4 дні"; + +"5 days" = "5 днів"; + +"7 days" = "7 днів"; + +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Ключ API перевіряє доступ до Ollama Cloud; файли cookie все ще розкривають обмеження квоти."; + +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "Ідентифікатор ключа доступу до AWS. Також можна встановити за допомогою AWS_ACCESS_KEY_ID."; + +"AWS region. Can also be set with AWS_REGION." = "Регіон AWS. Також можна встановити за допомогою AWS_REGION."; + +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Секретний ключ доступу до AWS. Також можна встановити за допомогою AWS_SECRET_ACCESS_KEY."; + +"Access key ID" = "Ідентифікатор ключа доступу"; + +"Add Account" = "Додати обліковий запис"; + +"Adding Account…" = "Додавання облікового запису…"; + +"Antigravity login failed" = "Помилка входу в Antigravity"; + +"Antigravity login timed out" = "Час очікування входу в антигравітацію минув"; + +"Auth source" = "Джерело авторизації"; + +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера Chrome із Xiaomi MiMo."; + +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматично імпортує дані сесії Windsurf із локального сховища браузера Chromium."; + +"Automatic imports browser cookies from Bailian." = "Автоматично імпортує файли cookie браузера з Bailian."; + +"Automatically imports browser cookies." = "Автоматично імпортує файли cookie браузера."; + +"Automatically imports browser session cookies." = "Автоматично імпортує файли cookie сеансу браузера."; + +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Назва розгортання Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME також підтримується."; + +"Azure OpenAI key" = "Ключ Azure OpenAI"; + +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Кінцева точка ресурсу Azure OpenAI. AZURE_OPENAI_ENDPOINT також підтримується."; + +"Base URL" = "Базовий URL"; + +"Base URL for the LLM-API-Key-Proxy instance." = "Базова URL-адреса для примірника LLM-API-Key-Proxy."; + +"Browser cookies" = "Файли cookie браузера"; + +"Cap end" = "Кінець кришки"; + +"Cap start" = "Початок шапки"; + +"Capacity End" = "Кінець ємності"; + +"Capacity Start" = "Ємність Старт"; + +"Changelog" = "Журнал змін"; + +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Виберіть хост API Moonshot/Kimi для міжнародних або материкового Китаю облікових записів."; + +"CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit не може замінити системний обліковий запис, який увійшов лише за допомогою ключа API."; + +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit не може знайти збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; + +"CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit не вдалося прочитати сховище керованого облікового запису. Відновіть магазин перед додаванням іншого облікового запису."; + +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit не зміг прочитати збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; + +"CodexBar could not read the current system account on this Mac." = "QuotaKit не вдалося прочитати поточний обліковий запис системи на цьому Mac."; + +"CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit не зміг замінити поточну автентифікацію Codex на цьому Mac."; + +"CodexBar could not safely preserve the current system account before switching." = "QuotaKit не зміг безпечно зберегти поточний обліковий запис системи перед перемиканням."; + +"CodexBar could not save the current system account before switching." = "QuotaKit не зміг зберегти поточний обліковий запис системи перед перемиканням."; + +"CodexBar could not update managed account storage." = "QuotaKit не вдалося оновити сховище керованого облікового запису."; + +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit знайшов інший керований обліковий запис, який уже використовує поточний системний обліковий запис. Усуньте дублікат облікового запису перед переходом."; + +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit запитає у macOS Keychain «%@», щоб він міг розшифрувати файли cookie браузера та автентифікувати ваш обліковий запис. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain маркер Claude Code OAuth, щоб отримати дані про використання Claude. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок cookie Amp, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок файлу cookie Augment, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок файлу cookie Claude, щоб отримати інформацію про використання веб-сайту Claude. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок cookie Cursor, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок Factory cookie, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш маркер GitHub Copilot, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш ключ API Kimi K2, щоб він міг отримати дані про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш токен автентифікації Kimi, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш токен MiniMax API, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок cookie MiniMax, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок файлів cookie OpenAI, щоб він міг отримати додаткові елементи панелі інструментів Codex. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш заголовок файлу cookie OpenCode, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш синтетичний ключ API, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; + +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit запитає у macOS Keychain ваш токен API z.ai, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; + +"Could not open Cursor login in your browser." = "Не вдалося відкрити Cursor login у вашому браузері."; + +"Could not open browser for Antigravity" = "Не вдалося відкрити браузер для Антигравітації"; + +"Credits used" = "Використані кредити"; + +"Day" = "День"; + +"Deployment" = "Розгортання"; + +"Drag to reorder" = "Перетягніть, щоб змінити порядок"; + +"Endpoint" = "Кінцева точка"; + +"Enterprise host" = "Корпоративний хост"; + +"Extra usage balance: %@" = "Баланс додаткового використання: %@"; + +"Keychain Access Required" = "Потрібен доступ до брелка"; + +"Kiro menu bar value" = "Значення панелі меню Kiro"; + +"Label" = "Мітка"; + +"No organizations loaded. Click Refresh after setting your API key." = "Організації не завантажено. Натисніть «Оновити» після встановлення ключа API."; + +"No output captured." = "Немає вихідних даних."; + +"No system account" = "Немає системного облікового запису"; + +"Oasis-Token" = "Oasis-Token"; + +"Open Augment (Log Out & Back In)" = "Відкрити доповнення (вийти та повернутися)"; + +"Open Codebuff Dashboard" = "Відкрийте інформаційну панель Codebuff"; + +"Open Command Code Settings" = "Відкрийте налаштування коду команди"; + +"Open Crof dashboard" = "Відкрийте інформаційну панель Crof"; + +"Open Manus" = "Відкрийте Manus"; + +"Open MiMo Balance" = "Відкрийте MiMo Balance"; + +"Open Moonshot Console" = "Відкрийте консоль Moonshot"; + +"Open Ollama API Keys" = "Відкрийте ключі Ollama API"; + +"Open StepFun Platform" = "Відкрийте платформу StepFun"; + +"Open T3 Chat Settings" = "Відкрийте налаштування чату T3"; + +"Open Volcengine Ark Console" = "Відкрийте консоль Volcengine Ark"; + +"Open legacy provider docs" = "Відкрити застарілі документи постачальника"; + +"Open projects" = "Відкриті проекти"; + +"Open this URL manually to continue login:\n\n%@" = "Відкрийте цю URL-адресу вручну, щоб продовжити вхід: \n\n%@"; + +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Додатковий ідентифікатор організації для облікових записів, пов’язаних із кількома організаціями Anthropic."; + +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Додатково. Застосовується до налаштованого ключа API адміністратора; вибрані облікові записи маркерів не успадковують OPENAI_PROJECT_ID."; + +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Додатково. Введіть свій хост GitHub Enterprise, наприклад octocorp.ghe.com. Залиште поле порожнім для github.com."; + +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Додатково. Залиште поле порожнім, щоб виявити та об’єднати проекти, видимі для ключа API."; + +"Org ID (optional)" = "Ідентифікатор організації (необов’язково)"; + +"Organizations" = "організації"; + +"Password" = "Пароль"; + +"%@ authentication is disabled." = "Автентифікацію %@ вимкнено."; + +"%@ cookies are disabled." = "Файли cookie %@ вимкнено."; + +"%@ web API access is disabled." = "Доступ до веб-API %@ вимкнено."; + +"Disable %@ dashboard cookie usage." = "Вимкнути використання файлів cookie панелі інструментів %@."; + +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Доступ Keychain вимкнено в Advanced, тому імпорт файлів cookie браузера недоступний."; + +"Manually paste an %@ from a browser session." = "Вручну вставте %@ із сеансу браузера."; + +"Paste a Cookie header captured from %@." = "Вставте заголовок файлу cookie, отриманий із %@."; + +"Paste a Cookie header from %@." = "Вставте заголовок файлу cookie з %@."; + +"Paste a Cookie header or cURL capture from %@." = "Вставте заголовок файлу cookie або запис cURL із %@."; + +"Paste a Cookie header or full cURL capture from %@." = "Вставте заголовок файлу cookie або повний запис cURL із %@."; + +"Paste a Cookie or Authorization header from %@." = "Вставте файл cookie або заголовок авторизації з %@."; + +"Paste a full cookie header or the %@ value." = "Вставте повний заголовок cookie або значення %@."; + +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Вставте заголовок файлу cookie або повний запис cURL із налаштувань T3 Chat."; + +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Вставте заголовок Cookie із запиту до admin.mistral.ai. Має містити файл cookie ory_session_*."; + +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Вставте Oasis-Token із сеансу браузера, у якому ви ввійшли в систему, на platform.stepfun.com."; + +"Paste the %@ JSON bundle from %@." = "Вставте пакет JSON %@ з %@."; + +"Paste the %@ value or a full Cookie header." = "Вставте значення %@ або повний заголовок файлу cookie."; + +"Personal account" = "Особистий рахунок"; + +"Project ID" = "ID проекту"; + +"Re-auth" = "Повторна авторизація"; + +"Re-authenticating…" = "Повторна автентифікація…"; + +"Refresh Session" = "Оновити сеанс"; + +"Refresh organizations" = "Оновити організації"; + +"Region" = "Регіон"; + +"Reload" = "Перезавантажити"; + +"Reorder" = "Змінити порядок"; + +"Secret access key" = "Секретний ключ доступу"; + +"Series" = "Серія"; + +"Service" = "Сервіс"; + +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Показати або приховати кредити Kiro, відсотки або обидва поряд із піктограмою панелі меню."; + +"Show usage for organizations you belong to. Personal account is always shown." = "Показати використання для організацій, до яких ви належите. Особистий рахунок відображається завжди."; + +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Увійдіть на cursor.com у своєму браузері, а потім оновіть курсор у QuotaKit."; + +"Simulated error text" = "Змодельований текст помилки"; + +"StepFun platform account (phone number or email)." = "Обліковий запис на платформі StepFun (номер телефону або електронна пошта)."; + +"Stored in ~/.codexbar/config.json." = "Зберігається в ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Зберігається в ~/.quotakit/config.json. Також підтримується AZURE_OPENAI_API_KEY."; + +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Зберігається в ~/.quotakit/config.json. Для офіційного Kimi API використовуйте Moonshot / Kimi API."; + +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Зберігається в ~/.quotakit/config.json. Отримайте ключ API з консолі Volcengine Ark."; + +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Зберігається в ~/.quotakit/config.json. Отримайте ключ із налаштувань Ollama."; + +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Зберігається в ~/.quotakit/config.json. Отримайте ключ на console.deepgram.com."; + +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Зберігається в ~/.quotakit/config.json. Отримайте свій ключ на сайті elevenlabs.io/app/settings/api-keys."; + +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Зберігається в ~/.quotakit/config.json. Отримайте свій ключ із openrouter.ai/settings/keys і встановіть там ліміт витрат на ключ, щоб увімкнути відстеження квоти ключів API."; + +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Зберігається в ~/.quotakit/config.json. У Warp відкрийте Налаштування > Платформа > Ключі API, а потім створіть один."; + +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Зберігається в ~/.quotakit/config.json. Метрики потребують доступу до Groq Enterprise Prometheus."; + +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Зберігається в ~/.quotakit/config.json. OPENAI_ADMIN_KEY є кращим; OPENAI_API_KEY все ще працює."; + +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Зберігається в ~/.quotakit/config.json. Потрібен ключ API адміністратора Anthropic."; + +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Зберігається в ~/.quotakit/config.json. Використовується для /v1/quota-stats."; + +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Зберігається в ~/.quotakit/config.json. Ви також можете надати CODEBUFF_API_KEY або дозволити QuotaKit читати ~/.config/manicode/credentials.json (створений `codebuff login`)."; + +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Зберігається в ~/.quotakit/config.json. Ви також можете надати CROF_API_KEY."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Зберігається в ~/.quotakit/config.json. Ви також можете надати KILO_API_KEY або ~/.local/share/kilo/auth.json (kilo.access)."; + +"T3 Chat cookie" = "T3 Чат cookie"; + +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Цей обліковий запис більше не доступний у QuotaKit. Оновіть список облікових записів і повторіть спробу."; + +"The browser login did not complete in time. Try Antigravity login again." = "Вхід у браузер не завершено вчасно. Спробуйте ще раз увійти в Antigravity."; + +"Timed out waiting for Cursor login. %@" = "Минув час очікування входу курсору. %@"; + +"Timed out waiting for Cursor login. %@ Last error: %@" = "Минув час очікування входу курсору. %@ Остання помилка: %@"; + +"Today requests" = "Сьогоднішні запити"; + +"Total (30d): %@ credits" = "Усього (30 днів): %@ кредитів"; + +"Username" = "Ім'я користувача"; + +"Uses username + password to login and obtain an Oasis-Token automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання Oasis-Token."; + +"Uses username + password to login and obtain an %@ automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання %@."; + +"Utilization End" = "Кінець використання"; + +"Utilization Start" = "Початок використання"; + +"Verbosity" = "Багатослівність"; + +"Windsurf session JSON bundle" = "Пакет JSON сеансу віндсерфінгу"; + +"Workspace ID" = "Ідентифікатор робочої області"; + +"Your StepFun platform password. Used to login and obtain a session token." = "Ваш пароль платформи StepFun. Використовується для входу та отримання маркера сесії."; + +"claude /login exited with status %d." = "claude /login вийшов зі статусом %d."; + +"codex login exited with status %d." = "вихід із входу в кодек із статусом %d."; + +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: …\n\nабо вставте запис cURL із інформаційної панелі Abacus AI"; + +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Файл cookie: …\n\nабо вставте значення __Secure-next-auth.session-token"; + +"Cookie: …\n\nor paste the kimi-auth token value" = "Файл cookie: …\n\nабо вставте значення маркера kimi-auth"; + +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nабо вставте лише значення session_id"; + +"Clear" = "ясно"; + +"No matching providers" = "Немає відповідних постачальників"; + +"Search providers" = "Пошук провайдерів"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings new file mode 100644 index 000000000..6e8cc0094 --- /dev/null +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,2098 @@ +/* English localization for CodexBar (base/fallback) */ + +" providers" = "nhà cung cấp"; + +"(System)" = "(Hệ thống)"; + +"30d" = "30d"; + +"A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; + +"API key" = "API khóa"; + +"API region" = "API khu vực"; + +"API token" = "API token"; + +"API tokens" = "API mã thông báo"; + +"About" = "Giới thiệu về"; + +"Account" = "Tài khoản"; + +"Accounts" = "Tài khoản"; + +"Accounts subtitle" = "Phụ đề tài khoản"; + +"Active" = "Đang hoạt động"; + +"Add" = "Thêm"; + +"Add Workspace" = "Thêm không gian làm việc"; + +"Advanced" = "Nâng cao"; + +"All" = "Tất cả"; + +"Always allow prompts" = "Luôn cho phép lời nhắc"; + +"Animation pattern" = "Mẫu hoạt ảnh"; + +"Antigravity login is managed in the app" = "Đăng nhập chống trọng lực được quản lý trong ứng dụng"; + +"Applies only to the Security.framework OAuth keychain reader." = "Chỉ áp dụng cho trình đọc chuỗi khóa Security.framework OAuth."; + +"Auto falls back to the next source if the preferred one fails." = "Tự động quay lại nguồn tiếp theo nếu nguồn ưa thích không thành công."; + +"Auto uses API first, then falls back to CLI on auth failures." = "Tự động sử dụng API trước, sau đó quay lại CLI khi xác thực không thành công."; + +"Auto-detect" = "Tự động phát hiện"; + +"Auto-refresh is off; use the menu's Refresh command." = "Tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; + +"Auto-refresh: hourly · Timeout: 10m" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; + +"Automatic" = "Tự động"; + +"Automatic imports browser cookies and WorkOS tokens." = "Tự động nhập cookie trình duyệt và mã thông báo WorkOS."; + +"Automatic imports browser cookies and local storage tokens." = "Tự động nhập cookie trình duyệt và mã thông báo lưu trữ cục bộ."; + +"Automatic imports browser cookies for dashboard extras." = "Tự động nhập cookie trình duyệt cho các tính năng bổ sung của trang tổng quan."; + +"Automatic imports browser cookies for the web API." = "Tự động nhập cookie trình duyệt cho web API ."; + +"Automatic imports browser cookies from Model Studio/Bailian." = "Tự động nhập cookie trình duyệt từ Model Studio/Bailian."; + +"Automatic imports browser cookies from admin.mistral.ai." = "Tự động nhập cookie trình duyệt từ admin.mistral.ai."; + +"Automatic imports browser cookies from opencode.ai." = "Tự động nhập cookie trình duyệt từ opencode.ai."; + +"Automatic imports browser cookies or stored sessions." = "Tự động nhập cookie trình duyệt hoặc các phiên được lưu trữ."; + +"Automatic imports browser cookies." = "Tự động nhập cookie trình duyệt."; + +"Automatically imports browser session cookie." = "Tự động nhập cookie phiên trình duyệt."; + +"Automatically opens CodexBar when you start your Mac." = "Tự động mở QuotaKit khi bạn khởi động máy Mac."; + +"Automation" = "Tự động hóa"; + +"Average (\\(label1) + \\(label2))" = "Trung bình (\\(label1) + \\(label2))"; + +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Trung bình (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + +"Avoid Keychain prompts" = "Tránh Keychain lời nhắc"; + +"Balance" = "Số dư"; + +"Battery Saver" = "Trình tiết kiệm pin"; + +"Bordered" = "Có viền"; + +"Build" = "Xây dựng"; + +"Built \\(buildTimestamp)" = "Đã xây dựng \\(buildTimestamp)"; + +"Buy Credits..." = "Mua tín dụng..."; + +"Buy Credits…" = "Mua tín dụng... Đường dẫn"; + +"CLI paths" = "CLI"; + +"CLI sessions" = "CLI phiên"; + +"Caches" = "Bộ nhớ đệm"; + +"Cancel" = "Hủy"; + +"Check for Updates…" = "Kiểm tra cập nhật…"; + +"Check for updates automatically" = "Tự động kiểm tra cập nhật"; + +"Check if you like your agents having some fun up there." = "Kiểm tra xem bạn có muốn nhân viên của mình vui vẻ ở đó không."; + +"Check provider status" = "Kiểm tra Nhà cung cấp trạng thái"; + +"Choose Codex workspace" = "Chọn không gian làm việc Codex"; + +"Choose the MiniMax host (global .io or China mainland .com)." = "Chọn máy chủ MiniMax (toàn cầu .io hoặc Trung Quốc đại lục .com)."; + +"Choose up to " = "Chọn tối đa"; + +"Choose up to \\(Self.maxOverviewProviders) providers" = "Chọn tối đa nhà cung cấp \\(Self.maxOverviewProviders"; + +"Choose up to \\(count) providers" = "Chọn tối đa \\(count) nhà cung cấp"; + +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; + +"Choose which Codex account CodexBar should follow." = "Chọn tài khoản Codex mà QuotaKit sẽ tuân theo."; + +"Choose which window drives the menu bar percent." = "Chọn cửa sổ nào điều khiển phần trăm thanh menu."; + +"Chrome" = "Chrome"; + +"Claude CLI not found" = "Claude CLI không tìm thấy"; + +"Claude binary" = "Claude nhị phân"; + +"Claude cookies" = "Claude cookie"; + +"Claude login failed" = "Claude đăng nhập không thành công"; + +"Claude login timed out" = "Claude hết thời gian đăng nhập"; + +"Close" = "Đóng"; + +"Code review" = "Xem xét mã"; + +"Codex CLI not found" = "Không tìm thấy Codex CLI"; + +"Codex account login already running" = "Đăng nhập tài khoản Codex đã chạy"; + +"Codex binary" = "Codex nhị phân"; + +"Codex login failed" = "Đăng nhập Codex không thành công"; + +"Codex login timed out" = "Đăng nhập Codex đã hết thời gian chờ"; + +"CodexBar Lifecycle Keepalive" = "QuotaKit Lifecycle Keepalive"; + +"CodexBar can't show its menu bar icon" = "QuotaKit không thể hiển thị biểu tượng thanh menu"; + +"CodexBar could not read managed account storage. " = "QuotaKit không thể đọc bộ nhớ tài khoản được quản lý."; + +"Configure…" = "Định cấu hình…"; + +"Connected" = "Đã kết nối"; + +"Controls how much detail is logged." = "Kiểm soát lượng chi tiết được ghi lại."; + +"Cookie header" = "Tiêu đề cookie"; + +"Cookie source" = "Nguồn cookie"; + +"Cookie: ..." = "Cookie: ..."; + +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nhoặc dán bản chụp cURL từ bảng điều khiển Abacus AI"; + +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value"; + +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nhoặc dán giá trị kimi-auth token"; + +"Cookie: …" = "Cookie: …"; + +"CopilotDeviceFlow" = "CopilotDeviceFlow"; + +"Cost" = "Chi phí"; + +"Could not add Codex account" = "Không thể thêm tài khoản Codex"; + +"Could not open Terminal for Gemini" = "Không thể mở Terminal cho Gemini"; + +"Could not start claude /login" = "Không thể bắt đầu claude /đăng nhập"; + +"Could not start codex login" = "Không thể bắt đầu đăng nhập codex"; + +"Could not switch system account" = "Không thể không chuyển đổi tài khoản hệ thống"; + +"Credits" = "Tín dụng"; + +"Credits history" = "Lịch sử tín dụng"; + +"Cursor login failed" = "Đăng nhập con trỏ không thành công"; + +"Custom" = "Tùy chỉnh"; + +"Custom Path" = "Đường dẫn tùy chỉnh"; + +"Daily Routines" = "Quy trình hàng ngày"; + +"Debug" = "Gỡ lỗi"; + +"Default" = "Mặc định"; + +"Disable Keychain access" = "Tắt quyền truy cập Keychain"; + +"Disabled" = "Đã tắt"; + +"Dismiss" = "Loại bỏ"; + +"Disconnected" = "Đã ngắt kết nối"; + +"Display" = "Hiển thị"; + +"Display mode" = "Chế độ hiển thị"; + +"Display reset times as absolute clock values instead of countdowns." = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; + +"Done" = "Xong"; + +"Effective PATH" = "PATH hiệu quả"; + +"Email" = "Email"; + +"Enable Merge Icons to configure Overview tab providers." = "Bật Biểu tượng Hợp nhất để định cấu hình nhà cung cấp tab Tổng quan."; + +"Enable file logging" = "Bật ghi nhật ký tệp"; + +"Enabled" = "Đã bật"; + +"Error" = "Lỗi"; + +"Error simulation" = "Mô phỏng lỗi"; + +"Expose troubleshooting tools in the Debug tab." = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; + +"Failed" = "Không thành công"; + +"False" = "Sai"; + +"Fetch strategy attempts" = "Thử tìm nạp chiến lược"; + +"Fetching" = "Đang tìm nạp"; + +"Field" = "Trường"; + +"Field subtitle" = "Tiêu đề phụ của trường"; + +"Finish the current managed account change before switching the system account." = "Hoàn tất thay đổi tài khoản được quản lý hiện tại trước khi chuyển đổi tài khoản hệ thống."; + +"Force animation on next refresh" = "Không tìm thấy hoạt ảnh bắt buộc trong lần làm mới tiếp theo"; + +"Gateway region" = "Vùng cổng"; + +"Gemini CLI not found" = "Gemini CLI"; + +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini /Phản trọng lực, xuất hiện các sự cố trong biểu tượng và menu."; + +"General" = "Chung"; + +"GitHub" = "GitHub"; + +"GitHub Copilot Login" = "Đăng nhập GitHub Copilot"; + +"GitHub Login" = "Đăng nhập GitHub"; + +"Hide details" = "Ẩn chi tiết"; + +"Hide personal information" = "Ẩn thông tin cá nhân"; + +"Historical tracking" = "Theo dõi lịch sử"; + +"How often CodexBar polls providers in the background." = "Tần suất QuotaKit thăm dò ý kiến ​​các nhà cung cấp trong nền."; + +"Inactive" = "Không hoạt động"; + +"Install CLI" = "Cài đặt CLI"; + +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Cài đặt Claude CLI (npm i -g @anthropic-ai/claude-code) và thử lại."; + +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Cài đặt Codex CLI (npm i -g @openai/codex) và thử lại."; + +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Cài đặt Gemini CLI (npm i -g @google/gemini-cli) và thử lại."; + +"JetBrains AI is ready" = "JetBrains AI đã sẵn sàng"; + +"JetBrains IDE" = "JetBrains IDE"; + +"Keep CLI sessions alive" = "Duy trì CLI phiên hoạt động"; + +"Keyboard shortcut" = "Phím tắt"; + +"Keychain access" = "Keychain truy cập"; + +"Keychain prompt policy" = "Keychain chính sách nhắc"; + +"Last \\(name) fetch failed:" = "Tìm nạp \\(name) lần cuối không thành công:"; + +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Lần tìm nạp \\(self.store.metadata(for: self. Nhà cung cấp ) .displayName) lần cuối không thành công:"; + +"Last attempt" = "Lần thử cuối cùng"; + +"Link" = "Liên kết"; + +"Loading animations" = "Đang tải hình động"; + +"Loading…" = "Đang tải…"; + +"Local" = "Cục bộ"; + +"Logging" = "Ghi nhật ký"; + +"Login failed" = "Đăng nhập không thành công"; + +"Login shell PATH (startup capture)" = "Shell đăng nhập PATH (chụp khởi động)"; + +"Login timed out" = "Đã hết thời gian đăng nhập"; + +"MCP details" = "Chi tiết MCP"; + +"Managed Codex accounts unavailable" = "Tài khoản Codex được quản lý không khả dụng"; + +"Managed account storage is unreadable. Live account access is still available, " = "Không thể đọc được bộ nhớ tài khoản được quản lý. Quyền truy cập tài khoản trực tiếp vẫn khả dụng,"; + +"Manual" = "Thủ công"; + +"May your tokens never run out—keep agent limits in view." = "Cầu mong mã thông báo của bạn không bao giờ hết—luôn theo dõi giới hạn đại lý."; + +"Menu bar" = "thanh menu"; + +"Menu bar auto-shows the provider closest to its rate limit." = "thanh menu tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; + +"Menu bar metric" = "thanh menu số liệu"; + +"Menu bar shows percent" = "thanh menu hiển thị phần trăm"; + +"Menu content" = "Nội dung menu"; + +"Merge Icons" = "Hợp nhất các biểu tượng"; + +"Never prompt" = "Không bao giờ nhắc"; + +"No" = "Không có"; + +"No Codex accounts detected yet." = "Chưa phát hiện thấy tài khoản Codex nào."; + +"No JetBrains IDE detected" = "Không phát hiện thấy JetBrains IDE"; + +"No cost history data." = "Không có dữ liệu lịch sử chi phí."; + +"No data available" = "Không có dữ liệu"; + +"No data yet" = "Chưa có dữ liệu"; + +"No enabled providers available for Overview." = "Không có nhà cung cấp nào được bật cho Tổng quan."; + +"No providers selected" = "Chưa có nhà cung cấp nào được chọn"; + +"No token accounts yet." = "Chưa có tài khoản token."; + +"No usage breakdown data." = "Không có dữ liệu phân tích Mức sử dụng."; + +"None" = "Không có"; + +"Notifications" = "Thông báo"; + +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi nó trở thành"; + +"OK" = "OK"; + +"Obscure email addresses in the menu bar and menu UI." = "Ẩn địa chỉ email trong thanh menu và giao diện người dùng menu."; + +"Off" = "Tắt"; + +"Offline" = "Ngoại tuyến"; + +"On" = "Bật"; + +"Online" = "Trực tuyến"; + +"Only on user action" = "Chỉ khi hành động của người dùng"; + +"Open" = "Mở"; + +"Open API Keys" = "Mở API Phím"; + +"Open Amp Settings" = "Mở Amp Cài đặt"; + +"Open Antigravity to sign in, then refresh CodexBar." = "Mở Chống trọng lực để đăng nhập, sau đó làm mới QuotaKit ."; + +"Open Browser" = "Mở trình duyệt"; + +"Open Coding Plan" = "Mở kế hoạch mã hóa"; + +"Open Console" = "Mở bảng điều khiển"; + +"Open Dashboard" = "Mở bảng điều khiển"; + +"Open Mistral Admin" = "Mở quản trị viên Mistral"; + +"Open Menu Bar Settings" = "Mở thanh menu Cài đặt"; + +"Open Ollama Settings" = "Mở Ollama Cài đặt"; + +"Open Terminal" = "Mở Terminal"; + +"Open Usage Page" = "Mở Mức sử dụng Trang"; + +"Open Warp API Key Guide" = "Hướng dẫn chính về Open Warp API"; + +"Open menu" = "Mở menu"; + +"Open token file" = "Mở token tệp"; + +"OpenAI cookies" = "OpenAI cookie"; + +"OpenAI web extras" = "OpenAI phần bổ sung web"; + +"Option A" = "Tùy chọn A"; + +"Option B" = "Tùy chọn B"; + +"Optional override if workspace lookup fails." = "Ghi đè tùy chọn nếu tra cứu không gian làm việc không thành công."; + +"Options" = "Tùy chọn"; + +"Override auto-detection with a custom IDE base path" = "Ghi đè tính năng tự động phát hiện bằng đường dẫn cơ sở IDE tùy chỉnh"; + +"Overview" = "Tổng quan"; + +"Overview rows always follow provider order." = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; + +"Overview tab providers" = "Nhà cung cấp tab tổng quan"; + +"Paste API key…" = "Dán API key…"; + +"Paste API token…" = "Dán API token …"; + +"Paste key…" = "Dán khóa…"; + +"Paste sessionKey or OAuth token…" = "Dán sessionKey hoặc OAuth token …"; + +"Paste the Cookie header from a request to admin.mistral.ai. " = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai."; + +"Paste token…" = "Dán token …"; + +"Personal" = "Cá nhân"; + +"Picker" = "Bộ chọn"; + +"Picker subtitle" = "Tiêu đề phụ của bộ chọn"; + +"Placeholder" = "Trình giữ chỗ"; + +"Plan" = "Kế hoạch"; + +"Play full-screen confetti when weekly usage resets." = "Phát hoa giấy toàn màn hình khi đặt lại Mức sử dụng hàng tuần."; + +"Polls OpenAI/Claude status pages and Google Workspace for " = "Cuộc thăm dò ý kiến ​​OpenAI / Claude trang trạng thái và Google Không gian làm việc dành cho"; + +"Prevents any Keychain access while enabled." = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; + +"Primary (API key limit)" = "Chính ( API giới hạn khóa)"; + +"Primary (\\(label))" = "Chính (\\(label))"; + +"Primary (\\(metadata.sessionLabel))" = "Chính (\\(metadata.sessionLabel))"; + +"Probe logs" = "Nhật ký thăm dò"; + +"Progress bars fill as you consume quota (instead of showing remaining)." = "Thanh tiến trình sẽ lấp đầy khi bạn sử dụng Hạn mức (thay vì hiển thị phần còn lại)."; + +"Provider" = "Nhà cung cấp"; + +"Providers" = "Nhà cung cấp"; + +"Quit CodexBar" = "Thoát QuotaKit"; + +"Random (default)" = "Ngẫu nhiên (mặc định)"; + +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; + +"Refresh" = "Làm mới"; + +"Refresh cadence" = "Nhịp làm mới"; + +"Remote" = "Từ xa"; + +"Remove" = "Xóa"; + +"Remove Codex account?" = "Xóa tài khoản Codex?"; + +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(account.email) khỏi QuotaKit ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; + +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(email) khỏi QuotaKit ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; + +"Remove selected account" = "Xóa tài khoản đã chọn"; + +"Replace critter bars with provider branding icons and a percentage." = "Thay thế các thanh sinh vật bằng Nhà cung cấp biểu tượng nhãn hiệu và tỷ lệ phần trăm."; + +"Replay selected animation" = "Phát lại hoạt ảnh đã chọn"; + +"Requires authentication via GitHub Device Flow." = "Yêu cầu xác thực thông qua GitHub Device Flow."; + +"Resets: \\(reset)" = "Đặt lại: \\( Đặt lại )"; + +"Rolling five-hour limit" = "Giới hạn 5 giờ liên tục"; + +"Search hourly" = "Tìm kiếm hàng giờ"; + +"Secondary (\\(label))" = "Phụ (\\(label))"; + +"Secondary (\\(metadata.weeklyLabel))" = "Phụ (\\(metadata.weeklyLabel))"; + +"Select a provider" = "Chọn một Nhà cung cấp"; + +"Select the IDE to monitor" = "Chọn IDE để giám sát"; + +"Session quota notifications" = "Thông báo phiên Hạn mức"; + +"Session tokens" = "Mã thông báo phiên"; + +"Settings" = "Cài đặt"; + +"Show Codex Credits and Claude Extra usage sections in the menu." = "Hiển thị Tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; + +"Show Debug Settings" = "Hiển thị gỡ lỗi Cài đặt"; + +"Show all token accounts" = "Hiển thị tất cả token tài khoản"; + +"Show cost summary" = "Hiển thị tóm tắt chi phí"; + +"Show credits + extra usage" = "Hiển thị tín dụng + bổ sung Mức sử dụng"; + +"Show details" = "Hiển thị chi tiết"; + +"Show most-used provider" = "Hiển thị Nhà cung cấp"; + +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "được sử dụng nhiều nhất Hiển thị các biểu tượng Nhà cung cấp trong trình chuyển đổi (nếu không thì hiển thị dòng tiến trình hàng tuần)."; + +"Show reset time as clock" = "Hiển thị thời gian Đặt lại dưới dạng đồng hồ"; + +"Show usage as used" = "Hiển thị Mức sử dụng như đã sử dụng"; + +"Sign in via button below" = "Đăng nhập bằng nút bên dưới"; + +"Skip teardown between probes (debug-only)." = "Bỏ qua việc phân tích giữa các thăm dò (chỉ dành cho gỡ lỗi)."; + +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Xếp chồng các tài khoản token trong menu (nếu không sẽ hiển thị thanh trình chuyển đổi tài khoản)."; + +"Start at Login" = "Bắt đầu khi đăng nhập"; + +"Status" = "Trạng thái"; + +"Store Claude sessionKey cookies or OAuth access tokens." = "Lưu trữ Claude cookie sessionKey hoặc OAuth mã thông báo truy cập."; + +"Store multiple Abacus AI Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Abacus AI."; + +"Store multiple Augment Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie tăng cường."; + +"Store multiple Cursor Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie con trỏ."; + +"Store multiple Factory Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie gốc."; + +"Store multiple MiniMax Cookie headers." = "Lưu trữ nhiều tiêu đề cookie MiniMax."; + +"Store multiple Mistral Cookie headers." = "Lưu trữ nhiều tiêu đề Mistral Cookie."; + +"Store multiple Ollama Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Ollama."; + +"Store multiple OpenCode Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode."; + +"Store multiple OpenCode Go Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode Go."; + +"Stored in the CodexBar config file." = "Được lưu trữ trong tệp cấu hình QuotaKit."; + +"Stored in ~/.codexbar/config.json. " = "Được lưu trữ trong ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Được lưu trữ trong ~/.quotakit/config.json. Tạo một cái tại kimi-k2.ai."; + +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Được lưu trữ trong ~/.quotakit/config.json. Dán khóa từ bảng điều khiển Tổng hợp."; + +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Được lưu trữ trong ~/.quotakit/config.json. Dán khóa Kế hoạch mã hóa API của bạn từ Model Studio."; + +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Được lưu trữ trong ~/.quotakit/config.json. Dán khóa MiniMax API của bạn."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Được lưu trữ trong ~/.quotakit/config.json. Bạn cũng có thể cung cấp lịch sử KILO_API_KEY hoặc"; + +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores Codex cục bộ Mức sử dụng (8 tuần) để cá nhân hóa dự đoán Pace."; + +"Subscription Utilization" = "Gói đăng ký Mức sử dụng"; + +"Surprise me" = "Làm tôi ngạc nhiên"; + +"Switcher shows icons" = "Trình chuyển đổi hiển thị các biểu tượng"; + +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink QuotaKitCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng quotakit."; + +"System" = "Hệ thống"; + +"Temporarily shows the loading animation after the next refresh." = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; + +"Tertiary (\\(label))" = "Cấp ba (\\(label))"; + +"Tertiary (\\(tertiaryTitle))" = "Cấp ba (\\(tertiaryTitle))"; + +"The default Codex account on this Mac." = "Tài khoản Codex mặc định trên máy Mac này."; + +"Toggle" = "Chuyển đổi"; + +"Toggle subtitle" = "Chuyển đổi phụ đề"; + +"Token" = "token"; + +"Trigger the menu bar menu from anywhere." = "Kích hoạt menu thanh menu từ mọi nơi."; + +"True" = "Đúng"; + +"Twitter" = "Twitter"; + +"Unsupported" = "Không được hỗ trợ"; + +"Update Channel" = "Kênh cập nhật"; + +"Updated" = "Đã cập nhật"; + +"Updates unavailable in this build." = "Các bản cập nhật không có sẵn trong bản dựng này."; + +"Usage" = "Mức sử dụng"; + +"Usage breakdown" = "Mức sử dụng sự cố"; + +"Usage history (30 days)" = "Mức sử dụng lịch sử"; + +"Usage source" = "Mức sử dụng nguồn"; + +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Sử dụng BigModel cho các điểm cuối ở Trung Quốc đại lục (open.bigmodel.cn)."; + +"Use a single menu bar icon with a provider switcher." = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; + +"Use international or China mainland console gateways for quota fetches." = "Sử dụng cổng bảng điều khiển quốc tế hoặc Trung Quốc đại lục để tìm nạp Hạn mức."; + +"Version" = "Phiên bản"; + +"Version \\(self.versionString)" = "Phiên bản \\(self.versionString)"; + +"Version \\(version)" = "Phiên bản \\(version)"; + +"Version \\(versionString)" = "Phiên bản \\(versionString)"; + +"Vertex AI Login" = "Vertex AI Đăng nhập"; + +"Wait for the current managed Codex login to finish before adding another account." = "Đợi quá trình đăng nhập Codex được quản lý hiện tại hoàn tất trước khi thêm tài khoản khác."; + +"Waiting for Authentication..." = "Đang chờ xác thực..."; + +"Website" = "Trang web"; + +"Weekly limit confetti" = "Hoa giấy giới hạn hàng tuần"; + +"Weekly token limit" = "token giới hạn"; + +"Weekly usage" = "Hàng tuần Mức sử dụng"; + +"Weekly usage unavailable for this account." = "Hàng tuần Mức sử dụng không khả dụng cho tài khoản này."; + +"Window: \\(window)" = "Cửa sổ: \\(window)"; + +"Write logs to \\(self.fileLogPath) for debugging." = "Ghi nhật ký vào \\(self.fileLogPath) để gỡ lỗi."; + +"Yes" = "Có"; + +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\( Mức sử dụng )"; + +"\\(name): \\(truncated)" = "\\(name): \\(cắt ngắn)"; + +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; + +"\\(name): fetching…\\(elapsed)" = "\\(name): đang tìm nạp…\\(elapsed)"; + +"\\(name): last attempt \\(when)" = "\\(name): lần thử cuối cùng \\(when)"; + +"\\(name): no data yet" = "\\(name): chưa có dữ liệu"; + +"\\(name): unsupported" = "\\(name): không được hỗ trợ"; + +"all browsers" = "tất cả các trình duyệt"; + +"available again." = "khả dụng trở lại."; + +"built_format" = "Đã xây dựng %@"; + +"copilot_complete_in_browser" = "Hoàn tất đăng nhập vào trình duyệt của bạn."; + +"copilot_device_code" = "Mã thiết bị được sao chép vào bảng nhớ tạm: %1$@\n\nXác minh tại: %2$@"; + +"copilot_device_code_copied" = "Đã sao chép mã thiết bị."; + +"copilot_verify_at" = "Xác minh tại %@"; + +"copilot_waiting_text" = "Hoàn tất đăng nhập vào trình duyệt của bạn.\nCửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; + +"copilot_window_closes_auto" = "Cửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; + +"cost_status_error" = "%1$@ : %2$@"; + +"cost_status_fetching" = "%1$@ : đang tìm nạp… %2$@"; + +"cost_status_last_attempt" = "%1$@ : lần thử cuối cùng %2$@"; + +"cost_status_no_data" = "%@ : không có dữ liệu chưa"; + +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; + +"cost_status_unsupported" = "%@ : không được hỗ trợ"; + +"credits_remaining" = "Tín dụng: %@"; + +"cursor_on_demand" = "Theo yêu cầu: %@"; + +"cursor_on_demand_with_limit" = "Theo yêu cầu: %1$@ / %2$@"; + +"extra_usage_format" = "Mức sử dụng bổ sung : %1$@ / %2$@"; + +"jetbrains_detected_generate" = "Đã phát hiện: %@ . Sử dụng trợ lý AI một lần để tạo dữ liệu Hạn mức, sau đó làm mới QuotaKit ."; + +"jetbrains_detected_select" = "Đã phát hiện: %@ . Chọn IDE ưa thích của bạn trong Cài đặt , sau đó làm mới QuotaKit ."; + +"last_fetch_failed_with_provider" = "Tìm nạp %@ lần cuối không thành công:"; + +"last_spend" = "Chi tiêu lần cuối: %@"; + +"mcp_model_usage" = "%1$@ : %2$@"; + +"mcp_resets" = "Đặt lại: %@"; + +"mcp_window" = "Cửa sổ: %@"; + +"metric_average" = "Trung bình ( %1$@ + %2$@ )"; + +"metric_primary" = "Sơ cấp ( %@ )"; + +"metric_secondary" = "Trung học ( %@ )"; + +"metric_tertiary" = "Cấp ba ( %@ )"; + +"multiple_workspaces_found" = "QuotaKit đã tìm thấy nhiều không gian làm việc cho %@ . Vui lòng chọn không gian làm việc để thêm."; + +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + +"overview_choose_providers" = "Chọn tối đa %@ nhà cung cấp"; + +"remove_account_message" = "Xóa %@ khỏi QuotaKit ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; + +"version_format" = "Phiên bản %@"; + +"vertex_ai_login_instructions" = "Để theo dõi Vertex AI Mức sử dụng , hãy xác thực bằng Google Cloud.\n\n1. Mở Terminal\n2. Chạy: gcloud auth application-default login\n3. Làm theo lời nhắc của trình duyệt để đăng nhập\n4. Đặt dự án của bạn: gcloud config set project PROJECT_ID\n\nMở Thiết bị đầu cuối bây giờ?"; + +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "ID không gian làm việc được đặt nhưng chỉ có mã mở, opencodego và deepgram hỗ trợ ID không gian làm việc."; + +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Giấy phép MIT."; + + +/* General Pane */ +"section_system" = "Hệ thống"; + +"section_usage" = "Mức sử dụng"; + +"section_automation" = "Tự động hóa"; + +"language_title" = "Ngôn ngữ"; + +"language_subtitle" = "Thay đổi ngôn ngữ hiển thị. Yêu cầu khởi động lại ứng dụng để có hiệu lực đầy đủ."; + +"language_system" = "Hệ thống"; + +"language_english" = "Tiếng Anh"; + +"language_spanish" = "Español"; + +"language_catalan" = "Català"; + +"language_chinese_simplified" = "简体中文"; + +"language_chinese_traditional" = "繁體中文"; + +"language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "Svenska"; + +"language_french" = "Tiếng Pháp"; + +"language_dutch" = "Tiếng Hà Lan"; + +"language_ukrainian" = "Tiếng Ukraina"; + +"start_at_login_title" = "Bắt đầu khi đăng nhập"; + +"start_at_login_subtitle" = "Tự động mở QuotaKit khi bạn khởi động máy Mac."; + +"show_cost_summary" = "Hiển thị tóm tắt chi phí"; + +"show_cost_summary_subtitle" = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; + +"cost_history_days_title" = "Cửa sổ lịch sử: %d ngày"; + +"cost_auto_refresh_info" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; + +"refresh_cadence_title" = "Nhịp làm mới"; + +"refresh_cadence_subtitle" = "Tần suất QuotaKit thăm dò ý kiến ​​các nhà cung cấp trong nền."; + +"manual_refresh_hint" = "Tính năng tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; + +"check_provider_status_title" = "Kiểm tra Nhà cung cấp trạng thái"; + +"check_provider_status_subtitle" = "Thăm dò ý kiến ​​OpenAI / Claude các trang trạng thái và Google Không gian làm việc dành cho Gemini /AntiGravity, phát hiện các sự cố trong biểu tượng và menu."; + +"session_quota_notifications_title" = "Thông báo về phiên Hạn mức"; + +"session_quota_notifications_subtitle" = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi phiên này khả dụng trở lại."; + +"quota_warning_notifications_title" = "Hạn mức thông báo cảnh báo"; + +"quota_warning_notifications_subtitle" = "Cảnh báo khi phiên hoặc Hạn mức còn lại hàng tuần vượt qua ngưỡng được định cấu hình."; + +"quota_warnings_title" = "Hạn mức cảnh báo"; + +"quota_warning_session" = "phiên"; + +"quota_warning_session_capitalized" = "Phiên"; + +"quota_warning_weekly" = "hàng tuần"; + +"quota_warning_weekly_capitalized" = "Hàng tuần"; + +"quota_warning_notification_title" = "%1$@ %2$@ Hạn mức thấp"; + +"quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; + +"quota_warning_notification_body_with_account" = "Tài khoản %1$@ . Còn lại %2$@. Đã đạt đến ngưỡng cảnh báo %3$d %% %4$@ của bạn."; + +"session_depleted_notification_title" = "%@ phiên đã hết"; + +"session_depleted_notification_body" = "còn lại 0%. Sẽ thông báo khi có lại."; + +"session_restored_notification_title" = "%@ phiên đã được khôi phục"; + +"session_restored_notification_body" = "Phiên Hạn mức đã có sẵn trở lại."; + +"quota_warning_warn_at" = "Cảnh báo ở"; + +"quota_warning_global_threshold_subtitle" = "Tỷ lệ phần trăm còn lại cho phiên và thời lượng hàng tuần trừ khi Nhà cung cấp ghi đè chúng."; + +"quota_warning_sound" = "Phát âm thanh thông báo"; + +"quota_warning_provider_inherits" = "Sử dụng cảnh báo Hạn mức toàn cầu Cài đặt trừ khi một cửa sổ được tùy chỉnh tại đây."; + +"quota_warning_customize_thresholds" = "Tùy chỉnh %@ ngưỡng"; + +"quota_warning_enable_warnings" = "Bật %@ cảnh báo"; + +"quota_warning_window_warn_at" = "%@ cảnh báo lúc"; + +"quota_warning_off" = "Tắt"; + +"quota_warning_inherited" = "Đã kế thừa: %@"; + +"quota_warning_depleted_only" = "chỉ đã cạn"; + +"quota_warning_upper" = "Thượng"; + +"quota_warning_lower" = "Hạ"; + +"apply" = "Áp dụng"; + +"quit_app" = "Thoát QuotaKit"; + + +/* Tab titles */ +"tab_general" = "Chung"; + +"tab_providers" = "Nhà cung cấp"; + +"tab_display" = "Hiển thị"; + +"tab_advanced" = "Nâng cao"; + +"tab_about" = "Giới thiệu về"; + +"tab_debug" = "Gỡ lỗi"; + + +/* Providers Pane */ +"select_a_provider" = "Chọn một Nhà cung cấp"; + +"cancel" = "Hủy"; + +"last_fetch_failed" = "lần tìm nạp cuối cùng không thành công"; + +"usage_not_fetched_yet" = "Mức sử dụng chưa được tìm nạp"; + +"managed_account_storage_unreadable" = "Bộ nhớ tài khoản được quản lý không thể đọc được. Quyền truy cập tài khoản trực tiếp vẫn khả dụng nhưng các hành động thêm, xác thực lại và xóa được quản lý sẽ bị vô hiệu hóa cho đến khi có thể khôi phục được cửa hàng."; + +"remove_codex_account_title" = "Xóa tài khoản Codex?"; + +"remove" = "Xóa"; + +"managed_login_already_running" = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình hoàn tất trước khi thêm hoặc xác thực lại tài khoản khác."; + +"managed_login_failed" = "Đăng nhập Codex được quản lý không hoàn tất. Xác minh rằng `codex --version` hoạt động trong Terminal. Nếu macOS đã chặn hoặc di chuyển `codex` vào Thùng rác, hãy xóa các bản cài đặt trùng lặp cũ, chạy `npm install -g --include=Optional @openai/codex@latest`, sau đó thử lại."; + +"codex_login_output" = "đầu ra đăng nhập codex:"; + +"managed_login_missing_email" = "Đăng nhập Codex đã hoàn tất nhưng không có email tài khoản. Hãy thử lại sau khi xác nhận tài khoản đã đăng nhập đầy đủ."; + +"login_success_notification_title" = "%@ đăng nhập thành công"; + +"login_success_notification_body" = "Bạn có thể quay lại ứng dụng; xác thực xong."; + +"workspace_selection_cancelled" = "QuotaKit đã tìm thấy nhiều không gian làm việc nhưng không có không gian làm việc nào được chọn."; + +"unsafe_managed_home" = "QuotaKit từ chối sửa đổi đường dẫn chính được quản lý không mong muốn: %@"; + +"menu_bar_metric_title" = "thanh menu chỉ số"; + +"menu_bar_metric_subtitle" = "Chọn cửa sổ nào thúc đẩy phần trăm thanh menu."; + +"menu_bar_metric_subtitle_deepseek" = "Hiển thị số dư DeepSeek trong thanh menu ."; + +"menu_bar_metric_subtitle_moonshot" = "Hiển thị số dư Moonshot / Kimi API trong thanh menu ."; + +"menu_bar_metric_subtitle_mistral" = "Hiển thị mức chi tiêu API của Mistral trong tháng hiện tại trong thanh menu ."; + +"menu_bar_metric_subtitle_kimik2" = "Hiển thị Kimi K2 API -các khoản tín dụng chính trong thanh menu ."; + +"automatic" = "Tự động"; + +"primary_api_key_limit" = "Chính ( API giới hạn khóa)"; + + +/* Display Pane */ +"section_menu_bar" = "thanh menu"; + +"merge_icons_title" = "Hợp nhất các biểu tượng"; + +"merge_icons_subtitle" = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; + +"switcher_shows_icons_title" = "Trình chuyển đổi hiển thị các biểu tượng"; + +"switcher_shows_icons_subtitle" = "Hiển thị các biểu tượng Nhà cung cấp trong trình chuyển đổi (nếu không thì hiển thị dòng tiến trình hàng tuần)."; + +"show_most_used_provider_title" = "Hiển thị Nhà cung cấp"; + +"show_most_used_provider_subtitle" = "thanh menu được sử dụng nhiều nhất tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; + +"menu_bar_shows_percent_title" = "thanh menu hiển thị phần trăm"; + +"menu_bar_shows_percent_subtitle" = "Thay thế thanh sinh vật bằng Nhà cung cấp biểu tượng thương hiệu và tỷ lệ phần trăm."; + +"display_mode_title" = "Chế độ hiển thị"; + +"display_mode_subtitle" = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; + +"section_menu_content" = "Nội dung menu"; + +"show_usage_as_used_title" = "Hiển thị Mức sử dụng dưới dạng đã sử dụng"; + +"show_usage_as_used_subtitle" = "Thanh tiến trình sẽ lấp đầy khi bạn sử dụng Hạn mức (thay vì hiển thị phần còn lại)."; + +"show_quota_warning_markers_title" = "Hiển thị Hạn mức dấu cảnh báo"; + +"show_quota_warning_markers_subtitle" = "Vẽ dấu kiểm ngưỡng trên thanh Mức sử dụng khi cảnh báo Hạn mức được định cấu hình."; + +"weekly_progress_work_days_title" = "Tiến độ ngày làm việc hàng tuần"; + +"weekly_progress_work_days_subtitle" = "Vẽ các dấu kiểm ranh giới ngày trên các thanh Mức sử dụng hàng tuần."; + +"show_reset_time_as_clock_title" = "Hiển thị Đặt lại thời gian dưới dạng đồng hồ"; + +"show_reset_time_as_clock_subtitle" = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; + +"show_provider_changelog_links_title" = "Hiển thị Nhà cung cấp liên kết nhật ký thay đổi"; + +"show_provider_changelog_links_subtitle" = "Thêm liên kết ghi chú phát hành cho các nhà cung cấp được hỗ trợ CLI vào menu."; + +"show_credits_extra_usage_title" = "Hiển thị tín dụng + phần Mức sử dụng"; + +"show_credits_extra_usage_subtitle" = "Hiển thị tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; + +"show_all_token_accounts_title" = "Hiển thị tất cả các tài khoản token"; + +"show_all_token_accounts_subtitle" = "Xếp chồng các tài khoản token trong menu (nếu không thì hiển thị thanh trình chuyển đổi tài khoản)."; + +"multi_account_layout_title" = "Bố cục nhiều tài khoản"; + +"multi_account_layout_subtitle" = "Chọn thẻ tài khoản chuyển đổi phân đoạn hoặc thẻ tài khoản xếp chồng."; + +"multi_account_layout_segmented" = "Được phân đoạn"; + +"multi_account_layout_stacked" = "Xếp chồng"; + +"overview_tab_providers_title" = "Nhà cung cấp tab tổng quan"; + +"configure" = "Định cấu hình…"; + +"overview_enable_merge_icons_hint" = "Bật Hợp nhất Biểu tượng để định cấu hình nhà cung cấp tab Tổng quan."; + +"overview_no_providers_hint" = "Không có nhà cung cấp nào được bật cho phần Tổng quan."; + +"overview_rows_follow_order" = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; + +"overview_no_providers_selected" = "Không có nhà cung cấp nào được chọn"; + + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Phím tắt"; + +"open_menu_shortcut_title" = "Mở menu"; + +"open_menu_shortcut_subtitle" = "Kích hoạt menu thanh menu từ mọi nơi."; + +"install_cli" = "Cài đặt CLI"; + +"install_cli_subtitle" = "Liên kết tượng trưng QuotaKitCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng quotakit."; + +"cli_not_found" = "Không tìm thấy QuotaKitCLI trong gói ứng dụng."; + +"no_writable_bin_dirs" = "Không tìm thấy thư mục bin có thể ghi."; + +"show_debug_settings_title" = "Hiển thị gỡ lỗi Cài đặt"; + +"show_debug_settings_subtitle" = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; + +"surprise_me_title" = "Làm tôi ngạc nhiên"; + +"surprise_me_subtitle" = "Kiểm tra xem bạn có thích các đại lý của mình vui vẻ ở đó không."; + +"weekly_limit_confetti_title" = "Hoa giấy giới hạn hàng tuần"; + +"weekly_limit_confetti_subtitle" = "Phát hoa giấy toàn màn hình khi đặt lại Mức sử dụng hàng tuần."; + +"hide_personal_info_title" = "Ẩn thông tin cá nhân"; + +"hide_personal_info_subtitle" = "Địa chỉ email tối nghĩa trong thanh menu và giao diện người dùng menu."; + +"show_provider_storage_usage_title" = "Hiển thị Nhà cung cấp bộ nhớ Mức sử dụng"; + +"show_provider_storage_usage_subtitle" = "Hiển thị ổ đĩa cục bộ Mức sử dụng trong menu. Quét các đường dẫn thuộc quyền sở hữu của Nhà cung cấp đã biết ở chế độ nền."; + +"section_keychain_access" = "Keychain quyền truy cập"; + +"keychain_access_caption" = "Tắt tất cả Keychain đọc và ghi. Hãy sử dụng tùy chọn này nếu macOS liên tục nhắc về ' Chrome /Brave/Edge Safe Storage' ngay cả sau khi nhấp vào Luôn cho phép. Nhập cookie trình duyệt không khả dụng khi được bật; dán tiêu đề Cookie theo cách thủ công vào Nhà cung cấp. Claude /Codex OAuth thông qua CLI vẫn hoạt động."; + +"disable_keychain_access_title" = "Vô hiệu hóa quyền truy cập Keychain"; + +"disable_keychain_access_subtitle" = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; + + +/* About Pane */ +"about_tagline" = "Cầu mong mã thông báo của bạn không bao giờ hết—giữ giới hạn đại lý trong tầm mắt."; + +"link_github" = "GitHub"; + +"link_website" = "Trang web"; + +"link_twitter" = "Twitter"; + +"link_email" = "Email"; + +"check_updates_auto" = "Tự động kiểm tra các bản cập nhật"; + +"update_channel" = "Kênh cập nhật"; + +"check_for_updates" = "Kiểm tra các bản cập nhật…"; + +"updates_unavailable" = "Các bản cập nhật không có sẵn trong bản dựng này."; + +"copyright" = "© 2026 Peter Steinberger. Giấy phép MIT."; + + +/* Debug Pane */ +"section_logging" = "Ghi nhật ký"; + +"enable_file_logging" = "Cho phép ghi nhật ký tệp"; + +"enable_file_logging_subtitle" = "Ghi nhật ký vào %@ để gỡ lỗi."; + +"verbosity_title" = "Độ chi tiết"; + +"verbosity_subtitle" = "Kiểm soát lượng chi tiết được ghi lại."; + +"open_log_file" = "Mở tệp nhật ký"; + +"force_animation_next_refresh" = "Buộc hoạt ảnh vào lần làm mới tiếp theo"; + +"force_animation_next_refresh_subtitle" = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; + +"section_loading_animations" = "Đang tải hình động"; + +"loading_animations_caption" = "Chọn một mẫu và phát lại nó trong thanh menu . \" Ngẫu nhiên \" giữ nguyên hành vi hiện có."; + +"animation_random_default" = "Ngẫu nhiên (mặc định)"; + +"replay_selected_animation" = "Phát lại hoạt ảnh đã chọn"; + +"blink_now" = "Nhấp nháy ngay"; + +"section_probe_logs" = "Nhật ký thăm dò"; + +"probe_logs_caption" = "Tìm nạp đầu ra thăm dò mới nhất để gỡ lỗi; Sao chép giữ toàn bộ văn bản."; + +"fetch_log" = "Nhật ký tìm nạp"; + +"copy" = "Sao chép"; + +"save_to_file" = "Lưu vào tệp"; + +"load_parse_dump" = "Tải kết xuất phân tích cú pháp"; + +"rerun_provider_autodetect" = "Chạy lại Nhà cung cấp tự động phát hiện"; + +"loading" = "Đang tải…"; + +"no_log_yet_fetch" = "Chưa có nhật ký nào. Tìm nạp để tải."; + +"section_fetch_strategy" = "Lần thử chiến lược tìm nạp"; + +"fetch_strategy_caption" = "Tìm nạp lần cuối các quyết định và lỗi về đường dẫn cho Nhà cung cấp ."; + +"section_openai_cookies" = "OpenAI cookie"; + +"openai_cookies_caption" = "Nhập cookie + nhật ký trích xuất WebKit từ lần thử cookie OpenAI gần đây nhất."; + +"no_log_yet" = "Chưa có nhật ký nào. Cập nhật cookie OpenAI trong Nhà cung cấp → Codex để chạy quá trình nhập."; + +"section_caches" = "Bộ nhớ đệm"; + +"caches_caption" = "Xóa kết quả quét chi phí được lưu trong bộ nhớ đệm hoặc bộ nhớ đệm cookie của trình duyệt."; + +"clear_cookie_cache" = "Xóa bộ nhớ đệm cookie"; + +"clear_cost_cache" = "Xóa bộ nhớ đệm chi phí"; + +"section_notifications" = "Thông báo"; + +"notifications_caption" = "Kích hoạt thông báo kiểm tra cho khoảng thời gian phiên 5 giờ (đã cạn/được khôi phục)."; + +"post_depleted" = "Đã hết bài đăng"; + +"post_restored" = "Đã khôi phục bài đăng"; + +"section_cli_sessions" = "CLI phiên"; + +"cli_sessions_caption" = "Giữ cho các phiên Codex/ Claude CLI vẫn tồn tại sau khi thăm dò. Thoát mặc định sau khi dữ liệu được ghi lại."; + +"keep_cli_sessions_alive" = "Duy trì CLI phiên"; + +"keep_cli_sessions_alive_subtitle" = "Bỏ qua việc phân tích giữa các lần thăm dò (chỉ gỡ lỗi)."; + +"reset_cli_sessions" = "Đặt lại CLI phiên"; + +"section_error_simulation" = "Mô phỏng lỗi"; + +"error_simulation_caption" = "Đưa thông báo lỗi giả vào thẻ menu để kiểm tra bố cục."; + +"set_menu_error" = "Đặt lỗi menu"; + +"clear_menu_error" = "Xóa lỗi menu"; + +"set_cost_error" = "Lỗi đặt chi phí"; + +"clear_cost_error" = "Xóa lỗi chi phí"; + +"section_cli_paths" = "CLI đường dẫn"; + +"cli_paths_caption" = "Đã giải quyết các lớp nhị phân Codex và PATH; chụp PATH đăng nhập khởi động (thời gian chờ ngắn)."; + +"codex_binary" = "Codex nhị phân"; + +"claude_binary" = "Claude nhị phân"; + +"effective_path" = "PATH hiệu quả"; + +"unavailable" = "Không khả dụng"; + +"login_shell_path" = "Shell đăng nhập PATH (chụp khởi động)"; + +"cleared" = "Đã xóa."; + +"no_fetch_attempts" = "Chưa có lần tìm nạp nào."; + +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe có thể chặn thanh menu ứng dụng trong Hệ thống Cài đặt → thanh menu → Cho phép trong thanh menu . QuotaKit đang chạy nhưng macOS có thể đang ẩn biểu tượng của nó. Mở thanh menu Cài đặt và bật QuotaKit."; + + +/* Metric preferences */ +"metric_pref_automatic" = "Tự động"; + +"metric_pref_primary" = "Chính"; + +"metric_pref_secondary" = "Trung học"; + +"metric_pref_tertiary" = "Đại học"; + +"metric_pref_extra_usage" = "Bổ sung Mức sử dụng"; + +"metric_pref_average" = "Trung bình"; + + +/* Display modes */ +"display_mode_percent" = "Phần trăm"; + +"display_mode_pace" = "Tốc độ"; + +"display_mode_both" = "Cả hai"; + +"display_mode_percent_desc" = "Hiển thị phần trăm còn lại/đã sử dụng (ví dụ: 45%)"; + +"display_mode_pace_desc" = "Hiển thị chỉ báo tốc độ (ví dụ: +5%)"; + +"display_mode_both_desc" = "Hiển thị cả phần trăm và tốc độ (ví dụ: 45% · +5%)"; + + +/* Provider status */ +"status_operational" = "Hoạt động"; + +"status_partial_outage" = "Mất điện một phần"; + +"status_major_outage" = "Mất điện lớn"; + +"status_critical_issue" = "Sự cố nghiêm trọng"; + +"status_maintenance" = "Bảo trì"; + +"status_unknown" = "Trạng thái không xác định"; + + +/* Refresh frequency */ +"refresh_manual" = "Thủ công"; + +"refresh_1min" = "1 phút"; + +"refresh_2min" = "2 phút"; + +"refresh_5min" = "5 phút"; + +"refresh_15min" = "15 phút"; + +"refresh_30min" = "30 phút"; + + +/* Additional keys */ +"not_found" = "Không tìm thấy"; + + +/* Cost estimation */ +"cost_header_estimated" = "Chi phí (ước tính)"; + +"cost_estimate_hint" = "Ước tính từ nhật ký cục bộ · có thể khác với hóa đơn của bạn"; + +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Không phát hiện thấy IDE JetBrains nào có Trợ lý AI. Cài đặt JetBrains IDE và bật Trợ lý AI."; + +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token chưa được định cấu hình. Đặt biến môi trường OPENROUTER_API_KEY hoặc định cấu hình trong Cài đặt ."; + +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "không tìm thấy z.ai API token. Đặt apiKey trong ~/.quotakit/config.json hoặc Z_AI_API_KEY."; + +"Missing DeepSeek API key." = "Thiếu khóa DeepSeek API."; + +"%@ is unavailable in the current environment." = "%@ không khả dụng trong môi trường hiện tại."; + +"All Systems Operational" = "Tất cả hệ thống đều hoạt động"; + +"Last 30 days" = "30 ngày qua"; + +"Last 30 days:" = "30 ngày qua:"; + +"This month" = "Tháng này"; + +"Store multiple OpenAI API keys." = "Lưu trữ nhiều khóa OpenAI API."; + +"Admin API key" = "Khóa quản trị API"; + +"Open billing" = "Mở thanh toán"; + +"Google accounts" = "Google tài khoản"; + +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Lưu trữ nhiều tài khoản AntiGravity Google OAuth để chuyển đổi nhanh chóng."; + +"Add Google Account" = "Thêm Google Tài khoản"; + +"Open Token Plan" = "Mở token Kế hoạch"; + +"Text Generation" = "Tạo văn bản"; + +"Text to Speech" = "Chuyển văn bản thành giọng nói"; + +"Music Generation" = "Tạo nhạc"; + +"Image Generation" = "Tạo hình ảnh"; + +"No local data found" = "Không tìm thấy dữ liệu cục bộ"; + +"Credits unavailable; keep Codex running to refresh." = "Không có tín dụng; giữ Codex chạy để làm mới."; + +"No available fetch strategy for minimax." = "Không có chiến lược tìm nạp nào cho minimax."; + +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Không tìm thấy phiên Con trỏ. Vui lòng đăng nhập vào con trỏ.com bằng Safari , Chrome , Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chrome, Helium, Vivaldi, Yandex Browser, Firefox , Zen, Colibri, Sidekick, Opera, Opera GX hoặc Edge Canary. Nếu bạn sử dụng Safari , hãy cấp cho QuotaKit Quyền truy cập toàn bộ đĩa trong Hệ thống Cài đặt ▸ Quyền riêng tư & Bảo mật. Bạn cũng có thể đăng nhập vào Cursor từ menu QuotaKit (Thêm/chuyển đổi tài khoản)."; + +"No OpenCode session cookies found in browsers." = "Không tìm thấy cookie phiên OpenCode trong trình duyệt."; + +"No available fetch strategy for %@." = "Không có chiến lược tìm nạp nào cho %@ ."; + +"Today" = "Hôm nay"; + +"Today tokens" = "Mã thông báo hôm nay"; + +"30d cost" = "giá 30d"; + +"30d tokens" = "mã thông báo 30d"; + +"Latest tokens" = "Mã thông báo mới nhất"; + +"Top model" = "Mô hình hàng đầu"; + +"Storage" = "Bộ nhớ"; + +"Add Account..." = "Thêm tài khoản..."; + +"Usage Dashboard" = "Mức sử dụng Trang tổng quan"; + +"Status Page" = "Trang trạng thái"; + +"Settings..." = "Cài đặt ..."; + +"About CodexBar" = "Giới thiệu về QuotaKit"; + +"Quit" = "Thoát"; + +"Last %d day" = "Ngày %d cuối cùng"; + +"Last %d days" = "%d ngày cuối cùng"; + +"%@ tokens" = "%@ mã thông báo"; + +"Latest billing day" = "Ngày thanh toán muộn nhất"; + +"Latest billing day (%@)" = "Ngày thanh toán muộn nhất ( %@ )"; + +"%@ left" = "còn lại %@"; + +"Resets %@" = "Đặt lại %@"; + +"Resets in %@" = "Đặt lại sau %@"; + +"Resets now" = "Đặt lại ngay"; + +"Lasts until reset" = "Kéo dài cho đến Đặt lại"; + +"Updated %@" = "Đã cập nhật %@"; + +"Updated %@h ago" = "Đã cập nhật %@ h trước"; + +"Updated %@m ago" = "Đã cập nhật %@ tháng trước"; + +"Updated just now" = "Vừa cập nhật"; + +"Projected empty in %@" = "Dự kiến trống trong %@"; + +"Runs out in %@" = "Hết trong %@"; + +"Pace: %@" = "Tốc độ: %@"; + +"Pace: %@ · %@" = "Pace: %@ · %@"; + +"%@ · %@" = "%@ · %@"; + +"≈ %d%% run-out risk" = "≈ %d %% rủi ro cạn kiệt"; + +"%d%% in deficit" = "%d %% thâm hụt"; + +"%d%% in reserve" = "%d %% dự trữ"; + +"usage_percent_suffix_left" = "left"; + +"usage_percent_suffix_used" = "đã sử dụng"; + +"Store multiple DeepSeek API keys." = "Lưu trữ nhiều khóa DeepSeek API."; + +"This week" = "Tuần này"; + +"Week" = "Tuần"; + +"Month" = "Tháng"; + +"Models" = "Mô hình"; + +"24h tokens" = "Token 24 giờ"; + +"Latest hour" = "Giờ mới nhất"; + +"Peak hour" = "Giờ cao điểm"; + +"Top method" = "Phương thức hàng đầu"; + +"30d cash" = "tiền mặt 30d"; + +"30d billing history from MiniMax web session" = "Thanh toán 30 ngày lịch sử từ MiniMax phiên web"; + +"AWS Cost Explorer billing can lag." = "Việc thanh toán AWS Cost Explorer có thể bị trễ."; + +"Rate limit: %d / %@" = "Giới hạn tốc độ: %d / %@"; + +"Key remaining" = "Khóa còn lại"; + +"No limit set for the API key" = "Không có giới hạn nào được đặt cho khóa API"; + +"API key limit unavailable right now" = "Giới hạn khóa API hiện không khả dụng"; + +"This month: %@ tokens" = "Tháng này: mã thông báo %@"; + +"No utilization data yet." = "Chưa có dữ liệu sử dụng."; + +"No %@ utilization data yet." = "Chưa có dữ liệu sử dụng %@."; + +"%@: %@%% used" = "%@ : %@ %% đã sử dụng"; + +"%dd" = "%d d"; + +"today" = "hôm nay"; + +"just now" = "vừa rồi"; + +"On pace" = "Đang tiến hành"; + +"Runs out now" = "Sắp hết"; + +"Projected empty now" = "Dự kiến trống"; + +"Switch Account..." = "Chuyển tài khoản..."; + +"Update ready, restart now?" = "Cập nhật đã sẵn sàng, khởi động lại ngay bây giờ?"; + +"Daily" = "Hàng ngày"; + +"Hourly Tokens" = "Mã thông báo hàng giờ"; + +"No data" = "Không có dữ liệu"; + +"No usage breakdown data available." = "Không có dữ liệu phân tích Mức sử dụng."; + + +"Today: %@ · %@ tokens" = "Hôm nay: %@ · %@ mã thông báo"; + +"Today: %@" = "Hôm nay: %@"; + +"Today: %@ tokens" = "Hôm nay: %@ mã thông báo"; + +"Last 30 days: %@ · %@ tokens" = "30 ngày qua: %@ · %@ mã thông báo"; + +"Last 30 days: %@" = "30 ngày qua: %@"; + +"Est. total (30d): %@" = "Ước tính tổng cộng (30 ngày): %@"; + +"Est. total (%@): %@" = "Ước tính tổng ( %@ ): %@"; + +"Hover a bar for details" = "Di chuột qua thanh để biết thông tin chi tiết"; + +"%@: %@ · %@ tokens" = "%@ : %@ · %@ mã thông báo"; + +"No providers selected for Overview." = "Không có nhà cung cấp nào được chọn cho Tổng quan."; + +"No overview data available." = "Không có dữ liệu tổng quan."; + +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Tự động sử dụng IDE cục bộ API trước, sau đó là Google OAuth khi IDE đóng."; + +"Login with Google" = "Đăng nhập bằng Google"; + + +/* Popup panels */ +"No usage configured." = "Chưa định cấu hình Mức sử dụng."; + +"Quota" = "Hạn mức"; + +"tokens" = "mã thông báo"; + +"requests" = "yêu cầu"; + +"Latest" = ""; + +"Monthly" = "Mới nhất"; + +"Sonnet" = "Sonnet"; + +"Overages" = "Quá tải"; + +"Activity" = "Hoạt động"; + +"Copied" = "Đã sao chép"; + +"Copy error" = "Lỗi sao chép"; + +"Copy path" = "Sao chép đường dẫn"; + +"Extra usage spent" = "Thêm Mức sử dụng đã chi tiêu"; + +"Credits remaining" = "Tín dụng còn lại"; + +"Using CLI fallback" = "Sử dụng CLI dự phòng"; + +"Balance updates in near-real time (up to 5 min lag)" = "Cập nhật số dư trong thời gian gần như thực (độ trễ tối đa 5 phút)"; + +"Daily billing data finalizes at 07:00 UTC" = "Dữ liệu thanh toán hàng ngày sẽ hoàn tất lúc 07:00 UTC"; + +"%@ of %@ credits left" = "%@ trong số %@ tín dụng còn lại"; + +"%@ of %@ bonus credits left" = "%@ trong số %@ tín dụng thưởng còn lại"; + +"%@ / %@ (%@ remaining)" = "%@ / %@ ( %@ còn lại)"; + +"%@/%@ left" = "%@ / %@ left"; + +"Gemini Flash" = "Gemini Flash"; + +"Regenerates %@" = "Tái tạo %@"; + +"used after next regen" = "được sử dụng sau lần tái sinh tiếp theo"; + +"after next regen" = "sau đợt regen tiếp theo"; + +"Near full" = "Gần đầy"; + +"Full in ~1 regen" = "Đầy đủ trong ~1 regen"; + +"Full in ~%.0f regens" = "Đầy đủ trong ~%.0f regens"; + +"Overage usage" = "Quá mức Mức sử dụng"; + +"Overage cost" = "Chi phí quá mức"; + +"credits" = "tín dụng"; + +"Zen balance" = "Số dư Zen"; + +"API spend" = "API chi tiêu"; + +"Extra usage" = "Thêm Mức sử dụng"; + +"Quota usage" = "Hạn mức Mức sử dụng"; + +"%.0f%% used" = "%.0f%% đã sử dụng"; + +"Usage history (today)" = "Mức sử dụng lịch sử (hôm nay)"; + +"Usage history (%d days)" = "Mức sử dụng lịch sử ( %d ngày)"; + +"%d percent remaining" = "%d phần trăm còn lại"; + +"Unknown" = "Không xác định"; + +"stale data" = "dữ liệu cũ"; + +"No credits history data." = "Không có dữ liệu lịch sử tín dụng."; + +"No credits history data available." = "Không có dữ liệu lịch sử tín dụng."; + +"Credits history chart" = "Biểu đồ lịch sử tín dụng"; + +"%d days of credits data" = "%d ngày dữ liệu tín dụng"; + +"Usage breakdown chart" = "Mức sử dụng biểu đồ phân tích"; + +"%d days of usage data across %d services" = "%d ngày của dữ liệu Mức sử dụng trên %d dịch vụ"; + +"Cost history chart" = "Biểu đồ lịch sử chi phí"; + +"%d days of cost data" = "%d ngày của dữ liệu chi phí"; + +"Plan utilization chart" = "Biểu đồ sử dụng kế hoạch"; + +"%d utilization samples" = "%d mẫu sử dụng"; + +"Hourly Usage" = "Hàng giờ Mức sử dụng"; + +"Usage remaining" = "Mức sử dụng"; + +"Usage used" = "Mức sử dụng đã sử dụng khóa"; + +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API đã được xác minh. Ollama không đưa ra các giới hạn Hạn mức của Đám mây thông qua API ."; + +"Last 30 days: %@ tokens" = "30 ngày qua: %@ mã thông báo"; + +"7d spend" = "chi tiêu 7 ngày"; + +"30d spend" = "chi tiêu 30 ngày"; + +"Cache read" = "Đọc bộ nhớ đệm"; + +"Claude Admin API 30 day spend trend" = "Claude Quản trị viên API Xu hướng chi tiêu 30 ngày"; + +"OpenRouter API key spend trend" = "Xu hướng chi tiêu khóa API OpenRouter"; + +"z.ai hourly token trend" = "z.ai hàng giờ token xu hướng"; + +"MiniMax 30 day token usage trend" = "MiniMax 30 ngày token Mức sử dụng xu hướng"; + +"Today cash" = "Tiền mặt hôm nay"; + +"DeepSeek 30 day token usage trend" = "DeepSeek 30 ngày token Mức sử dụng xu hướng"; + +"cache-hit input" = "đầu vào truy cập bộ đệm"; + +"cache-miss input" = "đầu vào bỏ lỡ bộ đệm"; + +"output" = "đầu ra"; + +"Requests" = "Yêu cầu"; + +"Reported by OpenAI Admin API organization usage." = "Được báo cáo bởi OpenAI Quản trị viên API tổ chức Mức sử dụng ."; + +"Reported by Mistral billing usage." = "Được báo cáo bởi thanh toán Mistral Mức sử dụng ."; + +"Google OAuth" = "Google OAuth"; + +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Thêm tài khoản qua GitHub OAuth Luồng thiết bị trên máy chủ đã chọn."; + +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Lưu trữ từng tài khoản Google đã đăng nhập để chuyển đổi Chống hấp dẫn nhanh chóng. Sử dụng AntiGravity.app OAuth khi có sẵn hoặc ANTIGRAVITY_OAUTH_CLIENT_ID và ANTIGRAVITY_OAUTH_CLIENT_SECRET làm ghi đè."; + +"Manual cleanup: past sessions" = "Dọn dẹp thủ công: các phiên trước đây"; + +"Clearing removes past resume, continue, and rewind history." = "Việc xóa sẽ xóa lịch sử tiếp tục, tiếp tục và tua lại trong quá khứ."; + +"Manual cleanup: file checkpoints" = "Dọn dẹp thủ công: điểm kiểm tra tệp"; + +"Clearing removes checkpoint restore data for previous edits." = "Việc xóa sẽ xóa dữ liệu khôi phục điểm kiểm tra cho các chỉnh sửa trước đó."; + +"Manual cleanup: saved plans" = "Dọn dẹp thủ công: các gói đã lưu"; + +"Clearing removes old plan-mode files." = "Việc xóa sẽ xóa các tệp chế độ gói cũ."; + +"Manual cleanup: debug logs" = "Dọn dẹp thủ công: nhật ký gỡ lỗi"; + +"Clearing removes past debug logs." = "Việc xóa sẽ xóa nhật ký gỡ lỗi trước đây."; + +"Manual cleanup: attachment cache" = "Dọn dẹp thủ công: bộ đệm đính kèm"; + +"Clearing removes cached large pastes or attached images." = "Việc xóa sẽ xóa các miếng dán lớn hoặc hình ảnh đính kèm được lưu trong bộ nhớ đệm."; + +"Manual cleanup: session metadata" = "Dọn dẹp thủ công: siêu dữ liệu phiên"; + +"Clearing removes per-session environment metadata." = "Việc xóa sẽ xóa siêu dữ liệu môi trường mỗi phiên."; + +"Manual cleanup: shell snapshots" = "Dọn dẹp thủ công: ảnh chụp nhanh shell"; + +"Clearing removes leftover runtime shell snapshot files." = "Việc xóa sẽ xóa các tệp ảnh chụp nhanh shell thời gian chạy còn sót lại."; + +"Manual cleanup: legacy todos" = "Dọn dẹp thủ công: việc cần làm cũ"; + +"Clearing removes legacy per-session task lists." = "Việc xóa sẽ xóa danh sách nhiệm vụ cũ mỗi phiên."; + +"Manual cleanup: sessions" = "Dọn dẹp thủ công: phiên"; + +"Clearing removes past Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex trước đây."; + +"Manual cleanup: archived sessions" = "Dọn dẹp thủ công: các phiên đã lưu trữ"; + +"Clearing removes archived Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex đã lưu trữ."; + +"Manual cleanup: cache" = "Dọn dẹp thủ công: bộ đệm"; + +"Clearing removes provider-owned cached data." = "Việc xóa sẽ xóa dữ liệu được lưu trong bộ nhớ đệm thuộc quyền sở hữu của Nhà cung cấp."; + +"Manual cleanup: logs" = "Dọn dẹp thủ công: nhật ký"; + +"Clearing removes local diagnostic logs." = "Việc xóa sẽ xóa nhật ký chẩn đoán cục bộ."; + +"Manual cleanup: file history" = "Dọn dẹp thủ công: lịch sử tệp"; + +"Clearing removes local edit checkpoint history." = "Việc xóa sẽ xóa lịch sử điểm kiểm tra chỉnh sửa cục bộ."; + +"Manual cleanup: temporary data" = "Dọn dẹp thủ công: dữ liệu tạm thời"; + +"Clearing removes local temporary provider data." = "Việc xóa sẽ xóa dữ liệu Nhà cung cấp tạm thời cục bộ."; + +"Total: %@" = "Tổng cộng: %@"; + +"%d more items" = "%d mục khác"; + +"Cleanup ideas" = "Ý tưởng dọn dẹp"; + +"%d unreadable item(s) skipped" = "%d (các) mục không thể đọc được đã bỏ qua"; + + +"API key limit" = "API giới hạn khóa"; + +"Auth" = "Xác thực"; + +"Auto" = "Tự động"; + +"Disabled — no recent data" = "Đã tắt — không có dữ liệu gần đây"; + +"Limits not available" = "Không có giới hạn"; + +"No usage yet" = "Chưa có Mức sử dụng"; + +"Not fetched yet" = "Chưa được tìm nạp"; + +"Refreshing" = "Đang làm mới"; + +"Session" = "Phiên"; + +"Source" = "Nguồn"; + +"State" = "Trạng thái"; + +"Unavailable" = "Không có sẵn"; + +"Weekly" = "Không phát hiện được"; + +"not detected" = "hàng tuần"; + +"Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; + +"minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; + +"minimax_used_percent_format" = "Đã sử dụng %@"; + +"minimax_service_text_generation" = "Tạo văn bản"; + +"minimax_service_text_to_speech" = "Chuyển văn bản thành giọng nói"; + +"minimax_service_music_generation" = "Tạo nhạc"; + +"minimax_service_image_generation" = "Tạo hình ảnh"; + +"minimax_service_lyrics_generation" = "Tạo lời bài hát"; + +"minimax_service_coding_plan_vlm" = "Kế hoạch mã hóa VLM"; + +"minimax_service_coding_plan_search" = "Tìm kiếm kế hoạch mã hóa"; + + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ đang chờ cấp phép"; + +"%@ requests" = "%@ yêu cầu"; + +"%@: %@ credits" = "%@: %@ credits"; + +"30d requests" = "yêu cầu 30 ngày"; + +"4 days" = "4 ngày"; + +"5 days" = "5 ngày"; + +"7 days" = "7 ngày"; + +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API khóa xác minh quyền truy cập vào Đám mây Ollama; cookie vẫn hiển thị giới hạn Hạn mức."; + +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID khóa truy cập AWS. Cũng có thể được đặt bằng AWS_ACCESS_KEY_ID."; + +"AWS region. Can also be set with AWS_REGION." = "Khu vực AWS. Cũng có thể được đặt bằng AWS_REGION."; + +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Khóa truy cập bí mật AWS. Cũng có thể được đặt bằng AWS_SECRET_ACCESS_KEY."; + +"Access key ID" = "ID khóa truy cập"; + +"Add Account" = "Thêm tài khoản"; + +"Adding Account…" = "Đang thêm tài khoản…"; + +"Antigravity login failed" = "Đăng nhập chống trọng lực không thành công"; + +"Antigravity login timed out" = "Đã hết thời gian đăng nhập chống trọng lực"; + +"Auth source" = "Nguồn xác thực"; + +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt Chrome từ Xiaomi MiMo."; + +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Tự động nhập dữ liệu phiên Windsurf từ localStorage của trình duyệt Chrome."; + +"Automatic imports browser cookies from Bailian." = "Tự động nhập cookie trình duyệt từ Bailian."; + +"Automatically imports browser cookies." = "Tự động nhập cookie trình duyệt."; + +"Automatically imports browser session cookies." = "Tự động nhập cookie phiên trình duyệt."; + +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "tên triển khai Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME cũng được hỗ trợ."; + +"Azure OpenAI key" = "Khóa Azure OpenAI"; + +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Điểm cuối tài nguyên Azure OpenAI. AZURE_OPENAI_ENDPOINT cũng được hỗ trợ."; + +"Base URL" = "Base URL"; + +"Base URL for the LLM-API-Key-Proxy instance." = "Base URL cho phiên bản LLM- API -Key-Proxy."; + +"Browser cookies" = "Cookie trình duyệt"; + +"Cap end" = "Cap end"; + +"Cap start" = "Cap start"; + +"Capacity End" = "Dung lượng End"; + +"Capacity Start" = "Dung lượng Start"; + +"Changelog" = "Changelog"; + +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Chọn máy chủ Moonshot/Kimi API cho các tài khoản quốc tế hoặc Trung Quốc đại lục."; + +"CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit không thể thay thế tài khoản hệ thống được đăng nhập bằng thiết lập chỉ khóa API."; + +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit không thể tìm thấy xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; + +"CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit không thể đọc bộ nhớ tài khoản được quản lý. Khôi phục cửa hàng trước khi thêm tài khoản khác."; + +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit không thể đọc xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; + +"CodexBar could not read the current system account on this Mac." = "QuotaKit không thể đọc tài khoản hệ thống hiện tại trên máy Mac này."; + +"CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit không thể thay thế xác thực Codex trực tiếp trên máy Mac này."; + +"CodexBar could not safely preserve the current system account before switching." = "QuotaKit không thể bảo toàn tài khoản hệ thống hiện tại một cách an toàn trước khi chuyển đổi."; + +"CodexBar could not save the current system account before switching." = "QuotaKit không thể lưu tài khoản hệ thống hiện tại trước khi chuyển đổi."; + +"CodexBar could not update managed account storage." = "QuotaKit không thể cập nhật bộ nhớ tài khoản được quản lý."; + +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit đã tìm thấy một tài khoản được quản lý khác đã sử dụng tài khoản hệ thống hiện tại. Giải quyết tài khoản trùng lặp trước khi chuyển đổi."; + +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho “ %@ ” để nó có thể giải mã cookie của trình duyệt và xác thực tài khoản của bạn. Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cung cấp Claude Mã OAuth token để nó có thể tìm nạp Claude Mức sử dụng của bạn. Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie Amp của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie Augment của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain về tiêu đề cookie Claude của bạn để nó có thể tìm nạp Claude web Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie Con trỏ của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie Factory của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho GitHub Copilot token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cung cấp khóa Kimi K2 API của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho Kimi auth token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cung cấp MiniMax API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie MiniMax của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenAI của bạn để nó có thể tìm nạp các tính năng bổ sung của trang tổng quan Codex. Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenCode của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cung cấp khóa Tổng hợp API của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit sẽ yêu cầu macOS Keychain cung cấp z.ai API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; + +"Could not open Cursor login in your browser." = "Không thể mở đăng nhập Con trỏ trong trình duyệt của bạn."; + +"Could not open browser for Antigravity" = "Không thể mở trình duyệt cho AntiGravity"; + +"Credits used" = "Tín dụng đã sử dụng"; + +"Day" = "Ngày"; + +"Deployment" = "Triển khai"; + +"Drag to reorder" = "Kéo để sắp xếp lại"; + +"Endpoint" = "Điểm cuối"; + +"Enterprise host" = "Máy chủ doanh nghiệp"; + +"Extra usage balance: %@" = "Extra usage balance: %@"; + +"Keychain Access Required" = "Keychain Yêu cầu quyền truy cập"; + +"Kiro menu bar value" = "Kiro thanh menu value"; + +"Label" = "Nhãn"; + +"No organizations loaded. Click Refresh after setting your API key." = "Chưa có tổ chức nào được tải. Nhấp vào Làm mới sau khi đặt khóa API của bạn."; + +"No output captured." = "Không ghi được đầu ra nào."; + +"No system account" = "Không có tài khoản hệ thống"; + +"Oasis-Token" = "Oasis- token"; + +"Open Augment (Log Out & Back In)" = "Mở phần mở rộng (Đăng xuất và quay lại)"; + +"Open Codebuff Dashboard" = "Mở bảng điều khiển Codebuff"; + +"Open Command Code Settings" = "Mở mã lệnh Cài đặt"; + +"Open Crof dashboard" = "Mở bảng điều khiển Crof"; + +"Open Manus" = "Mở Manus"; + +"Open MiMo Balance" = "Mở MiMo Balance"; + +"Open Moonshot Console" = "Mở Bảng điều khiển Moonshot"; + +"Open Ollama API Keys" = "Mở Ollama API Phím"; + +"Open StepFun Platform" = "Mở Nền tảng StepFun"; + +"Open T3 Chat Settings" = "Mở Trò chuyện T3 Cài đặt"; + +"Open Volcengine Ark Console" = "Mở Bảng điều khiển Volcengine Ark"; + +"Open legacy provider docs" = "Mở di sản Nhà cung cấp docs"; + +"Open projects" = "Mở dự án"; + +"Open this URL manually to continue login:\n\n%@" = "Open this URL manually to continue login:\n\n%@"; + +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID tổ chức tùy chọn cho các tài khoản được liên kết với nhiều tổ chức Anthropic."; + +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Tùy chọn. Áp dụng cho khóa Quản trị viên API đã định cấu hình; tài khoản token đã chọn không kế thừa OPENAI_PROJECT_ID."; + +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Tùy chọn. Nhập máy chủ GitHub Enterprise của bạn, ví dụ: octocorp.ghe.com. Để trống cho github.com."; + +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Tùy chọn. Để trống để khám phá và tổng hợp các dự án hiển thị với khóa API."; + +"Org ID (optional)" = "ID tổ chức (tùy chọn)"; + +"Organizations" = "Tổ chức"; + +"Password" = "Mật khẩu"; + +"%@ authentication is disabled." = "%@ xác thực bị tắt. Cookie"; + +"%@ cookies are disabled." = "%@ bị tắt. Quyền truy cập"; + +"%@ web API access is disabled." = "%@ web API bị vô hiệu hóa."; + +"Disable %@ dashboard cookie usage." = "Tắt %@ cookie trang tổng quan Mức sử dụng . Quyền truy cập"; + +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain bị vô hiệu hóa trong Nâng cao, do đó, tính năng nhập cookie trình duyệt không khả dụng."; + +"Manually paste an %@ from a browser session." = "Dán %@ theo cách thủ công từ phiên trình duyệt."; + +"Paste a Cookie header captured from %@." = "Dán tiêu đề Cookie được lấy từ %@ ."; + +"Paste a Cookie header from %@." = "Dán tiêu đề Cookie từ %@ ."; + +"Paste a Cookie header or cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL từ %@ ."; + +"Paste a Cookie header or full cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ %@ ."; + +"Paste a Cookie or Authorization header from %@." = "Dán tiêu đề Cookie hoặc Ủy quyền từ %@ ."; + +"Paste a full cookie header or the %@ value." = "Dán tiêu đề cookie đầy đủ hoặc giá trị %@."; + +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ Trò chuyện T3 Cài đặt ."; + +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai. Phải chứa cookie ory_session_*."; + +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Dán Oasis- token từ phiên trình duyệt đã đăng nhập trên platform.stepfun.com."; + +"Paste the %@ JSON bundle from %@." = "Dán gói %@ JSON từ %@ ."; + +"Paste the %@ value or a full Cookie header." = "Dán giá trị %@ hoặc tiêu đề Cookie đầy đủ."; + +"Personal account" = "Tài khoản cá nhân"; + +"Project ID" = "ID dự án"; + +"Re-auth" = "Xác thực lại"; + +"Re-authenticating…" = "Xác thực lại…"; + +"Refresh Session" = "Làm mới phiên"; + +"Refresh organizations" = "Làm mới tổ chức"; + +"Region" = "Khu vực"; + +"Reload" = "Tải lại"; + +"Reorder" = "Sắp xếp lại"; + +"Secret access key" = "Khóa truy cập bí mật"; + +"Series" = "Chuỗi"; + +"Service" = "Dịch vụ"; + +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Hiển thị hoặc ẩn tín dụng Kiro, phần trăm hoặc cả hai bên cạnh biểu tượng thanh menu."; + +"Show usage for organizations you belong to. Personal account is always shown." = "Hiển thị Mức sử dụng cho các tổ chức mà bạn là thành viên. Tài khoản cá nhân luôn được hiển thị."; + +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Đăng nhập vào con trỏ.com trong trình duyệt của bạn, sau đó làm mới Con trỏ trong QuotaKit ."; + +"Simulated error text" = "Văn bản lỗi mô phỏng"; + +"StepFun platform account (phone number or email)." = "Tài khoản nền tảng StepFun (số điện thoại hoặc email)."; + +"Stored in ~/.codexbar/config.json." = "Được lưu trữ trong ~/.quotakit/config.json."; + +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Được lưu trữ trong ~/.quotakit/config.json. AZURE_OPENAI_API_KEY cũng được hỗ trợ."; + +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Được lưu trữ trong ~/.quotakit/config.json. Đối với Kimi API chính thức, hãy sử dụng Moonshot / Kimi API ."; + +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Được lưu trữ trong ~/.quotakit/config.json. Nhận khóa API của bạn từ bảng điều khiển Volcengine Ark."; + +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Được lưu trữ trong ~/.quotakit/config.json. Nhận chìa khóa của bạn từ Ollama Cài đặt ."; + +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Được lưu trữ trong ~/.quotakit/config.json. Nhận chìa khóa của bạn từ console.deepgram.com."; + +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Được lưu trữ trong ~/.quotakit/config.json. Nhận khóa của bạn từ Elevenlabs.io/app/ Cài đặt /api-keys."; + +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Được lưu trữ trong ~/.quotakit/config.json. Nhận khóa của bạn từ openrouter.ai/ Cài đặt /keys và đặt giới hạn chi tiêu cho khóa ở đó để cho phép theo dõi API khóa Hạn mức."; + +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Được lưu trữ trong ~/.quotakit/config.json. Trong Warp, hãy mở Khóa Cài đặt > Nền tảng > API, sau đó tạo một khóa."; + +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Được lưu trữ trong ~/.quotakit/config.json. Các số liệu yêu cầu quyền truy cập Groq Enterprise Prometheus."; + +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Được lưu trữ trong ~/.quotakit/config.json. OPENAI_ADMIN_KEY được ưu tiên; OPENAI_API_KEY vẫn hoạt động."; + +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Được lưu trữ trong ~/.quotakit/config.json. Yêu cầu khóa Anthropic Quản trị viên API."; + +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Được lưu trữ trong ~/.quotakit/config.json. Được sử dụng cho /v1/ Hạn mức -stats."; + +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Được lưu trữ trong ~/.quotakit/config.json. Bạn cũng có thể cung cấp CODEBUFF_API_KEY hoặc để QuotaKit đọc ~/.config/manicode/credentials.json (được tạo bởi `codebuff login`)."; + +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Được lưu trữ trong ~/.quotakit/config.json. Bạn cũng có thể cung cấp CROF_API_KEY."; + +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Được lưu trữ trong ~/.quotakit/config.json. Bạn cũng có thể cung cấp KILO_API_KEY hoặc ~/.local/share/kilo/auth.json (kilo.access)."; + +"T3 Chat cookie" = "Cookie trò chuyện T3"; + +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Tài khoản đó không còn khả dụng trong QuotaKit . Hãy làm mới danh sách tài khoản và thử lại."; + +"The browser login did not complete in time. Try Antigravity login again." = "Quá trình đăng nhập trình duyệt không hoàn tất kịp thời. Hãy thử đăng nhập lại bằng AntiGravity."; + +"Timed out waiting for Cursor login. %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@"; + +"Timed out waiting for Cursor login. %@ Last error: %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@ Lỗi cuối cùng: %@"; + +"Today requests" = "Hôm nay yêu cầu"; + +"Total (30d): %@ credits" = "Tổng cộng (30d): %@ tín dụng"; + +"Username" = "Tên người dùng"; + +"Uses username + password to login and obtain an Oasis-Token automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và nhận Oasis- token một cách tự động."; + +"Uses username + password to login and obtain an %@ automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và tự động nhận được %@."; + +"Utilization End" = "Kết thúc sử dụng"; + +"Utilization Start" = "Bắt đầu sử dụng"; + +"Verbosity" = "Độ chi tiết"; + +"Windsurf session JSON bundle" = "Phiên lướt ván buồm JSON gói"; + +"Workspace ID" = "ID không gian làm việc"; + +"Your StepFun platform password. Used to login and obtain a session token." = "Mật khẩu nền tảng StepFun của bạn. Được sử dụng để đăng nhập và nhận phiên token ."; + +"claude /login exited with status %d." = "claude /đăng nhập đã thoát với trạng thái %d ."; + +"codex login exited with status %d." = "đăng nhập codex đã thoát với trạng thái %d ."; + +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nhoặc dán bản chụp cURL từ bảng thông tin Abacus AI"; + +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nhoặc dán giá trị __Secure-next-auth.session- token"; + +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nhoặc dán giá trị kimi-auth token"; + +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nhoặc chỉ dán giá trị session_id"; + +"Clear" = "Xóa"; + +"No matching providers" = "Không có nhà cung cấp phù hợp"; + +"Search providers" = "Tìm kiếm nhà cung cấp"; + + +"language_vietnamese" = "Tiếng Việt"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index d1681aa1b..5974577c1 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1,1062 +1,2122 @@ /* Chinese (Simplified) localization for CodexBar */ " providers" = " 提供商"; + "(System)" = "(System)"; + "30d" = "30 天"; + "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; + "API key" = "API 密钥"; + "API key limit" = "API 密钥限制"; + "API region" = "API 区域"; + "API token" = "API 令牌"; + "API tokens" = "API 令牌"; + "About" = "关于"; + "Account" = "账户"; + "Accounts" = "账户"; + "Accounts subtitle" = "账户副标题"; + "Active" = "活跃"; + "Add" = "添加"; + "Add Workspace" = "添加工作区"; + "Advanced" = "高级"; + "All" = "全部"; + "Always allow prompts" = "始终允许提示"; + "Animation pattern" = "动画模式"; + "Antigravity login is managed in the app" = "Antigravity 登录由应用管理"; + "Applies only to the Security.framework OAuth keychain reader." = "仅适用于 Security.framework OAuth 钥匙串读取器。"; + "Auth" = "认证"; + "Auto" = "自动"; + "Auto falls back to the next source if the preferred one fails." = "如果首选来源失败,自动回退到下一个来源。"; + "Auto uses API first, then falls back to CLI on auth failures." = "自动优先使用 API,认证失败时回退到 CLI。"; + "Auto-detect" = "自动检测"; + "Auto-refresh is off; use the menu's Refresh command." = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; + "Auto-refresh: hourly · Timeout: 10m" = "自动刷新:每小时 · 超时:10 分钟"; + "Automatic" = "自动"; + "Automatic imports browser cookies and WorkOS tokens." = "自动导入浏览器 Cookie 和 WorkOS 令牌。"; + "Automatic imports browser cookies and local storage tokens." = "自动导入浏览器 Cookie 和本地存储令牌。"; + "Automatic imports browser cookies for dashboard extras." = "自动导入用于仪表盘附加功能的浏览器 Cookie。"; + "Automatic imports browser cookies for the web API." = "自动导入用于 Web API 的浏览器 Cookie。"; + "Automatic imports browser cookies from Model Studio/Bailian." = "自动从 Model Studio/百炼导入浏览器 Cookie。"; + "Automatic imports browser cookies from admin.mistral.ai." = "自动从 admin.mistral.ai 导入浏览器 Cookie。"; + "Automatic imports browser cookies from opencode.ai." = "自动从 opencode.ai 导入浏览器 Cookie。"; + "Automatic imports browser cookies or stored sessions." = "自动导入浏览器 Cookie 或已存储的会话。"; + "Automatic imports browser cookies." = "自动导入浏览器 Cookie。"; + "Automatically imports browser session cookie." = "自动导入浏览器会话 Cookie。"; + "Automatically opens CodexBar when you start your Mac." = "启动 Mac 时自动打开 QuotaKit。"; + "Automation" = "自动化"; + "Average (\\(label1) + \\(label2))" = "平均(\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均(\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "避免钥匙串提示"; + "Balance" = "余额"; + "Battery Saver" = "省电模式"; + "Bordered" = "带边框"; + "Build" = "构建"; + "Built \\(buildTimestamp)" = "构建于 \\(buildTimestamp)"; + "Buy Credits..." = "购买额度…"; + "Buy Credits…" = "购买额度…"; + "CLI paths" = "CLI 路径"; + "CLI sessions" = "CLI 会话"; + "Caches" = "缓存"; + "Cancel" = "取消"; + "Check for Updates…" = "检查更新…"; + "Check for updates automatically" = "自动检查更新"; + "Check if you like your agents having some fun up there." = "看看你是否喜欢你的智能体在上面找点乐子。"; + "Check provider status" = "检查提供商状态"; + "Choose Codex workspace" = "选择 Codex 工作区"; + "Choose the MiniMax host (global .io or China mainland .com)." = "选择 MiniMax 主机(全球 .io 或中国大陆 .com)。"; + "Choose up to " = "选择至多 "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "选择至多 \\(Self.maxOverviewProviders) 个提供商"; + "Choose up to \\(count) providers" = "选择至多 \\(count) 个提供商"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "选择菜单栏中显示的内容(进度会显示实际用量与预期的对比)。"; + "Choose which Codex account CodexBar should follow." = "选择 QuotaKit 要跟随的 Codex 账户。"; + "Choose which window drives the menu bar percent." = "选择用于驱动菜单栏百分比的窗口。"; + "Chrome" = "Chrome"; + "Claude CLI not found" = "未找到 Claude CLI"; + "Claude binary" = "Claude 二进制文件"; + "Claude cookies" = "Claude Cookie"; + "Claude login failed" = "Claude 登录失败"; + "Claude login timed out" = "Claude 登录超时"; + "Close" = "关闭"; + "Code review" = "代码审查"; + "Codex CLI not found" = "未找到 Codex CLI"; + "Codex account login already running" = "Codex 账户登录已在运行"; + "Codex binary" = "Codex 二进制文件"; + "Codex login failed" = "Codex 登录失败"; + "Codex login timed out" = "Codex 登录超时"; + "CodexBar Lifecycle Keepalive" = "QuotaKit 生命周期保活"; + "CodexBar could not read managed account storage. " = "QuotaKit 无法读取托管账户存储。"; + "Configure…" = "配置…"; + "Connected" = "已连接"; + "Controls how much detail is logged." = "控制日志记录的详细程度。"; + "Cookie header" = "Cookie 标头"; + "Cookie source" = "Cookie 来源"; + "Cookie: ..." = "Cookie:..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或粘贴来自 Abacus AI 仪表盘的 cURL 捕获内容"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie:\\u{2026}\\\n\\\n或粘贴 __Secure-next-auth.session-token 的值"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie:\\u{2026}\\\n\\\n或粘贴 kimi-auth 令牌值"; + "Cookie: …" = "Cookie:…"; + "CopilotDeviceFlow" = "Copilot 设备流程"; + "Cost" = "费用"; + "Could not add Codex account" = "无法添加 Codex 账户"; + "Could not open Terminal for Gemini" = "无法为 Gemini 打开终端"; + "Could not start claude /login" = "无法启动 claude /login"; + "Could not start codex login" = "无法启动 codex login"; + "Could not switch system account" = "无法切换系统账户"; + "Credits" = "额度"; + "Credits history" = "额度记录"; + "Cursor login failed" = "Cursor 登录失败"; + "Custom" = "自定义"; + "Custom Path" = "自定义路径"; + "Daily Routines" = "日常任务"; + "Debug" = "调试"; + "Default" = "默认"; + "Disable Keychain access" = "禁用钥匙串访问"; + "Disabled" = "已禁用"; + "Disabled — no recent data" = "已禁用 — 无近期数据"; + "Disconnected" = "已断开连接"; + "Display" = "显示"; + "Display mode" = "显示模式"; + "Display reset times as absolute clock values instead of countdowns." = "将重置时间显示为绝对时钟值,而不是倒计时。"; + "Done" = "完成"; + "Effective PATH" = "有效 PATH"; + "Email" = "电子邮件"; + "Enable Merge Icons to configure Overview tab providers." = "启用“合并图标”以配置“概览”标签中的提供商。"; + "Enable file logging" = "启用文件日志"; + "Enabled" = "已启用"; + "Error" = "错误"; + "Error simulation" = "错误模拟"; + "Expose troubleshooting tools in the Debug tab." = "在“调试”标签中显示故障排除工具。"; + "Failed" = "失败"; + "False" = "假"; + "Fetch strategy attempts" = "获取策略尝试"; + "Fetching" = "获取中"; + "Field" = "字段"; + "Field subtitle" = "字段副标题"; + "Finish the current managed account change before switching the system account." = "请先完成当前托管账户变更,再切换系统账户。"; + "Force animation on next refresh" = "下次刷新时强制动画"; + "Gateway region" = "网关区域"; + "Gemini CLI not found" = "未找到 Gemini CLI"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,并在图标和菜单中显示故障事件。"; + "General" = "通用"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "GitHub Copilot 登录"; + "GitHub Login" = "GitHub 登录"; + "Hide details" = "隐藏详情"; + "Hide personal information" = "隐藏个人信息"; + "Historical tracking" = "历史跟踪"; + "How often CodexBar polls providers in the background." = "QuotaKit 在后台轮询提供商的频率。"; + "Inactive" = "非活跃"; + "Install CLI" = "安装 CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安装 Claude CLI(npm i -g @anthropic-ai/claude-code)后重试。"; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "安装 Codex CLI(npm i -g @openai/codex)后重试。"; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安装 Gemini CLI(npm i -g @google/gemini-cli)后重试。"; + "JetBrains AI is ready" = "JetBrains AI 已就绪"; + "JetBrains IDE" = "JetBrains IDE"; + "Keep CLI sessions alive" = "保持 CLI 会话存活"; + "Keyboard shortcut" = "快捷键"; + "Keychain access" = "钥匙串访问"; + "Keychain prompt policy" = "钥匙串提示策略"; + "Last \\(name) fetch failed:" = "上次获取 \\(name) 失败:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次获取 \\(self.store.metadata(for: self.provider).displayName) 失败:"; + "Last attempt" = "上次尝试"; + "Limits not available" = "限制不可用"; + "Link" = "链接"; + "Loading animations" = "加载动画"; + "Loading…" = "加载中…"; + "Local" = "本地"; + "Logging" = "日志"; + "Login failed" = "登录失败"; + "Login shell PATH (startup capture)" = "登录 shell PATH(启动时捕获)"; + "Login timed out" = "登录超时"; + "MCP details" = "MCP 详情"; + "Managed Codex accounts unavailable" = "托管 Codex 账户不可用"; + "Managed account storage is unreadable. Live account access is still available, " = "托管账户存储不可读。实时账户访问仍可用,"; + "Manual" = "手动"; + "May your tokens never run out—keep agent limits in view." = "愿你的 token 永不耗尽——随时关注智能体额度。"; + "Menu bar" = "菜单栏"; + "Menu bar auto-shows the provider closest to its rate limit." = "菜单栏会自动显示最接近速率限制的提供商。"; + "Menu bar metric" = "菜单栏指标"; + "Menu bar shows percent" = "菜单栏显示百分比"; + "Menu content" = "菜单内容"; + "Merge Icons" = "合并图标"; + "Never prompt" = "从不提示"; + "No" = "否"; + "No Codex accounts detected yet." = "未检测到 Codex 账户。"; + "No JetBrains IDE detected" = "未检测到 JetBrains IDE"; + "No cost history data." = "暂无费用历史数据。"; + "No usage yet" = "尚无用量"; + "Not fetched yet" = "尚未获取"; + "No credits history data." = "暂无额度记录。"; + "No data available" = "无可用数据"; + "No data yet" = "暂无数据"; + "No enabled providers available for Overview." = "“概览”中没有可用的已启用提供商。"; + "No providers selected" = "未选择提供商"; + "No token accounts yet." = "尚无令牌账户。"; + "No usage breakdown data." = "暂无用量明细数据。"; + "None" = "无"; + "Notifications" = "通知"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "当 5 小时会话配额降至 0% 以及重新"; + "OK" = "确定"; + "Obscure email addresses in the menu bar and menu UI." = "在菜单栏和菜单界面中隐藏电子邮件地址。"; + "Off" = "关闭"; + "Offline" = "离线"; + "On" = "开启"; + "Online" = "在线"; + "Only on user action" = "仅在用户操作时"; + "Open" = "打开"; + "Open API Keys" = "打开 API 密钥"; + "Open Amp Settings" = "打开 Amp 设置"; + "Open Antigravity to sign in, then refresh CodexBar." = "打开 Antigravity 登录,然后刷新 QuotaKit。"; + "Open Browser" = "打开浏览器"; + "Open Coding Plan" = "打开 Coding Plan"; + "Open Console" = "打开控制台"; + "Open Dashboard" = "打开仪表盘"; + "Open Mistral Admin" = "打开 Mistral 管理后台"; + "Open Ollama Settings" = "打开 Ollama 设置"; + "Open Terminal" = "打开终端"; + "Open Usage Page" = "打开用量页面"; + "Open Warp API Key Guide" = "打开 Warp API 密钥指南"; + "Open menu" = "打开菜单"; + "Open token file" = "打开令牌文件"; + "OpenAI cookies" = "OpenAI Cookie"; + "OpenAI web extras" = "OpenAI Web 附加功能"; + "Option A" = "选项 A"; + "Option B" = "选项 B"; + "Optional override if workspace lookup fails." = "工作区查找失败时可选的覆盖项。"; + "Options" = "选项"; + "Override auto-detection with a custom IDE base path" = "使用自定义 IDE 基础路径覆盖自动检测"; + "Overview" = "概览"; + "Overview rows always follow provider order." = "概览行始终遵循提供商顺序。"; + "Overview tab providers" = "概览标签提供商"; + "Paste API key…" = "粘贴 API 密钥…"; + "Paste API token…" = "粘贴 API 令牌…"; + "Paste key…" = "粘贴密钥…"; + "Paste sessionKey or OAuth token…" = "粘贴 sessionKey 或 OAuth 令牌…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "粘贴发往 admin.mistral.ai 请求中的 Cookie 标头。"; + "Paste token…" = "粘贴令牌…"; + "Personal" = "个人"; + "Picker" = "选择器"; + "Picker subtitle" = "选择器副标题"; + "Placeholder" = "占位符"; + "Plan" = "套餐"; + "Play full-screen confetti when weekly usage resets." = "当每周用量重置时播放全屏彩纸。"; + "Polls OpenAI/Claude status pages and Google Workspace for " = "轮询 OpenAI/Claude 状态页面和 Google Workspace,以检查"; + "Prevents any Keychain access while enabled." = "启用时阻止任何钥匙串访问。"; + "Primary (API key limit)" = "主要(API 密钥限制)"; + "Primary (\\(label))" = "主要(\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "主要(\\(metadata.sessionLabel))"; + "Probe logs" = "探测日志"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "进度条会随配额消耗而填充(而不是显示剩余量)。"; + "Provider" = "提供商"; + "Providers" = "提供商"; + "Quit CodexBar" = "退出 QuotaKit"; + "Random (default)" = "随机(默认)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "读取本地用量日志。在菜单中显示今天及所选历史窗口的费用。"; + "Refresh" = "刷新"; + "Refreshing" = "正在刷新"; + "Refresh cadence" = "刷新频率"; + "Remote" = "远程"; + "Remove" = "移除"; + "Remove Codex account?" = "移除 Codex 账户?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "要从 QuotaKit 中移除 \\(account.email) 吗?其托管的 Codex 主目录将被删除。"; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "要从 QuotaKit 中移除 \\(email) 吗?其托管的 Codex 主目录将被删除。"; + "Remove selected account" = "移除所选账户"; + "Replace critter bars with provider branding icons and a percentage." = "将小动物进度条替换为提供商品牌图标和百分比。"; + "Replay selected animation" = "重放选中的动画"; + "Requires authentication via GitHub Device Flow." = "需要通过 GitHub 设备流程进行认证。"; + "Resets: \\(reset)" = "重置:\\(reset)"; + "Rolling five-hour limit" = "滚动 5 小时限制"; + "Search hourly" = "每小时搜索"; + "Secondary (\\(label))" = "次要(\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "次要(\\(metadata.weeklyLabel))"; + "Select a provider" = "选择一个提供商"; + "Select the IDE to monitor" = "选择要监控的 IDE"; + "Session" = "会话"; + "Session quota notifications" = "会话配额通知"; + "Session tokens" = "会话令牌"; + "Settings" = "设置"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "在菜单中显示 Codex 额度和 Claude 额外用量部分。"; + "Show Debug Settings" = "显示调试设置"; + "Show all token accounts" = "显示所有令牌账户"; + "Show cost summary" = "显示费用摘要"; + "Show credits + extra usage" = "显示额度 + 额外用量"; + "Show details" = "显示详情"; + "Show most-used provider" = "显示用量最高的提供商"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切换器中显示提供商图标(否则显示每周进度线)。"; + "Show reset time as clock" = "将重置时间显示为时钟"; + "Show usage as used" = "显示已使用用量"; + "Sign in via button below" = "通过下方按钮登录"; + "Skip teardown between probes (debug-only)." = "探测之间跳过清理(仅限调试)。"; + "Source" = "来源"; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "在菜单中堆叠令牌账户(否则显示账户切换栏)。"; + "Start at Login" = "开机启动"; + "State" = "状态"; + "Status" = "状态"; + "Store Claude sessionKey cookies or OAuth access tokens." = "存储 Claude sessionKey Cookie 或 OAuth 访问令牌。"; + "Store multiple Abacus AI Cookie headers." = "存储多个 Abacus AI Cookie 标头。"; + "Store multiple Augment Cookie headers." = "存储多个 Augment Cookie 标头。"; + "Store multiple Cursor Cookie headers." = "存储多个 Cursor Cookie 标头。"; + "Store multiple Factory Cookie headers." = "存储多个 Factory Cookie 标头。"; + "Store multiple MiniMax Cookie headers." = "存储多个 MiniMax Cookie 标头。"; + "Store multiple Mistral Cookie headers." = "存储多个 Mistral Cookie 标头。"; + "Store multiple Ollama Cookie headers." = "存储多个 Ollama Cookie 标头。"; + "Store multiple OpenCode Cookie headers." = "存储多个 OpenCode Cookie 标头。"; + "Store multiple OpenCode Go Cookie headers." = "存储多个 OpenCode Go Cookie 标头。"; + "Stored in the CodexBar config file." = "存储在 QuotaKit 配置文件中。"; + "Stored in ~/.codexbar/config.json. " = "存储在 ~/.quotakit/config.json 中。"; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "存储在 ~/.quotakit/config.json 中。可在 kimi-k2.ai 生成。"; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "存储在 ~/.quotakit/config.json 中。请粘贴来自 Synthetic 仪表盘的密钥。"; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "存储在 ~/.quotakit/config.json 中。请粘贴来自 Model Studio 的 Coding Plan API 密钥。"; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存储在 ~/.quotakit/config.json 中。请粘贴你的 MiniMax API 密钥。"; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "存储在 ~/.quotakit/config.json 中。你也可以提供 KILO_API_KEY 或"; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "存储本地 Codex 用量历史(8 周),用于个性化进度预测。"; + "Subscription Utilization" = "订阅使用率"; + "Surprise me" = "给我惊喜"; + "Switcher shows icons" = "切换器显示图标"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "系统"; + "Temporarily shows the loading animation after the next refresh." = "下次刷新后临时显示加载动画。"; + "Tertiary (\\(label))" = "第三(\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "第三(\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "此 Mac 上的默认 Codex 账户。"; + "Toggle" = "切换"; + "Toggle subtitle" = "切换副标题"; + "Token" = "token"; + "Trigger the menu bar menu from anywhere." = "从任意位置触发菜单栏菜单。"; + "True" = "真"; + "Twitter" = "Twitter"; + "Unsupported" = "不支持"; + "Unavailable" = "不可用"; + "Update Channel" = "更新频道"; + "Updated" = "已更新"; + "Updates unavailable in this build." = "此构建中更新不可用。"; + "Usage" = "用量"; + "Usage breakdown" = "用量明细"; + "Usage history (30 days)" = "用量历史"; + "Usage source" = "用量来源"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中国大陆端点使用 BigModel(open.bigmodel.cn)。"; + "Use a single menu bar icon with a provider switcher." = "使用单个菜单栏图标并带提供商切换器。"; + "Use international or China mainland console gateways for quota fetches." = "使用国际或中国大陆控制台网关获取配额。"; + "Version" = "版本"; + "Version \\(self.versionString)" = "版本 \\(self.versionString)"; + "Version \\(version)" = "版本 \\(version)"; + "Version \\(versionString)" = "版本 \\(versionString)"; + "Vertex AI Login" = "Vertex AI 登录"; + "Wait for the current managed Codex login to finish before adding another account." = "请等待当前托管 Codex 登录完成后再添加其他账户。"; + "Waiting for Authentication..." = "等待认证…"; + "Website" = "网站"; + "Weekly" = "每周"; + "Weekly limit confetti" = "每周限制彩纸"; + "Weekly token limit" = "每周 token 限制"; + "Weekly usage" = "每周用量"; + "Weekly usage unavailable for this account." = "此账户的每周用量不可用。"; + "Window: \\(window)" = "窗口:\\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "将日志写入 \\(self.fileLogPath) 以进行调试。"; + "Yes" = "是"; + "not detected" = "未检测到"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode):\\(usage)"; + "\\(name): \\(truncated)" = "\\(name):\\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name):\\(updated) · 30 天 \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name):获取中…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name):上次尝试 \\(when)"; + "\\(name): no data yet" = "\\(name):暂无数据"; + "\\(name): unsupported" = "\\(name):不支持"; + "all browsers" = "所有浏览器"; + "available again." = "可用时发送通知。"; + "built_format" = "构建于 %@"; + "copilot_complete_in_browser" = "请在浏览器中完成登录。"; + "copilot_device_code_copied" = "设备代码已复制。"; + "copilot_verify_at" = "请在 %@ 验证"; + "copilot_window_closes_auto" = "登录完成后,此窗口会自动关闭。"; + "cost_status_error" = "%1$@:%2$@"; + "cost_status_fetching" = "%1$@:获取中… %2$@"; + "cost_status_last_attempt" = "%1$@:上次尝试 %2$@"; + "cost_status_no_data" = "%@:暂无数据"; + "cost_status_snapshot" = "%1$@:%2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@:不支持"; + "credits_remaining" = "额度:%@"; + "cursor_on_demand" = "按需计费:%@"; + "cursor_on_demand_with_limit" = "按需计费:%1$@ / %2$@"; + "extra_usage_format" = "额外用量:%1$@ / %2$@"; + "jetbrains_detected_generate" = "检测到:%@。使用一次 AI 助手以生成配额数据,然后刷新 QuotaKit。"; + "jetbrains_detected_select" = "检测到:%@。在设置中选择你偏好的 IDE,然后刷新 QuotaKit。"; + "last_fetch_failed_with_provider" = "上次获取 %@ 失败:"; + "last_spend" = "上次支出:%@"; + "mcp_model_usage" = "%1$@:%2$@"; + "mcp_resets" = "重置:%@"; + "mcp_window" = "窗口:%@"; + "metric_average" = "平均(%1$@ + %2$@)"; + "metric_primary" = "主要(%@)"; + "metric_secondary" = "次要(%@)"; + "metric_tertiary" = "第三(%@)"; + "multiple_workspaces_found" = "QuotaKit 发现 %@ 有多个工作区。请选择要添加的工作区。"; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "最多选择 %@ 个提供商"; + "remove_account_message" = "要从 QuotaKit 中移除 %@ 吗?其托管的 Codex 主目录将被删除。"; + "version_format" = "版本 %@"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已设置 workspaceID,但只有 opencode、opencodego 和 deepgram 支持 workspaceID。"; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 许可证。"; + "section_system" = "系统"; + "section_usage" = "用量"; + "section_automation" = "自动化"; + "language_title" = "语言"; + "language_subtitle" = "更改显示语言。需要重启应用才能完全生效。"; + "language_system" = "跟随系统"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "瑞典语"; + +"language_dutch" = "Nederlands"; + +"language_french" = "法语"; + +"language_ukrainian" = "乌克兰语"; + "start_at_login_title" = "开机启动"; + "start_at_login_subtitle" = "启动 Mac 时自动打开 QuotaKit。"; + "show_cost_summary" = "显示费用摘要"; + "show_cost_summary_subtitle" = "读取本地用量日志。在菜单中显示今天及所选历史窗口的费用。"; + "cost_history_days_title" = "历史窗口:%d 天"; + "cost_auto_refresh_info" = "自动刷新:每小时 · 超时:10 分钟"; + "refresh_cadence_title" = "刷新频率"; + "refresh_cadence_subtitle" = "QuotaKit 在后台轮询提供商的频率。"; + "manual_refresh_hint" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; + "check_provider_status_title" = "检查提供商状态"; + "check_provider_status_subtitle" = "轮询 OpenAI/Claude 状态页面和 Google Workspace 的 Gemini/Antigravity,在图标和菜单中显示故障信息。"; + "session_quota_notifications_title" = "会话配额通知"; + "session_quota_notifications_subtitle" = "当 5 小时会话配额用完及恢复时发送通知。"; + "quota_warning_notifications_title" = "配额预警通知"; + "quota_warning_notifications_subtitle" = "当会话或每周剩余配额低于设置的阈值时提醒。"; + "quota_warnings_title" = "配额预警"; + "quota_warning_session" = "会话"; + "quota_warning_session_capitalized" = "会话"; + "quota_warning_weekly" = "每周"; + "quota_warning_weekly_capitalized" = "每周"; + "quota_warning_warn_at" = "预警阈值"; + "quota_warning_global_threshold_subtitle" = "会话和每周窗口的剩余百分比,除非提供商单独覆盖。"; + "quota_warning_sound" = "播放通知声音"; + "quota_warning_provider_inherits" = "默认使用全局配额预警设置,除非在这里自定义窗口。"; + "quota_warning_customize_thresholds" = "自定义 %@ 阈值"; + "quota_warning_enable_warnings" = "启用 %@ 预警"; + "quota_warning_window_warn_at" = "%@ 预警阈值"; + "quota_warning_off" = "关闭"; + "quota_warning_inherited" = "继承:%@"; + "quota_warning_depleted_only" = "仅耗尽时"; + "quota_warning_upper" = "上限"; + "quota_warning_lower" = "下限"; + "apply" = "应用"; + "quit_app" = "退出 QuotaKit"; + "tab_general" = "通用"; + "tab_mobile" = "移动"; + "tab_providers" = "提供商"; + "tab_display" = "显示"; + "tab_advanced" = "高级"; + "tab_about" = "关于"; + "tab_debug" = "调试"; + "select_a_provider" = "选择一个提供商"; + "cancel" = "取消"; + "last_fetch_failed" = "上次获取失败"; + "usage_not_fetched_yet" = "尚未获取用量"; + "managed_account_storage_unreadable" = "托管账户存储不可读。实时账户访问仍可用,但托管添加、重新认证和移除操作已被禁用,直到存储恢复。"; + "remove_codex_account_title" = "移除 Codex 账户?"; + "remove" = "移除"; + "managed_login_already_running" = "托管 Codex 登录已在运行。请等待完成后再添加或重新认证其他账户。"; + "managed_login_failed" = "托管 Codex 登录未完成。请先在终端确认 `codex --version` 可以运行。如果 macOS 阻止了 `codex` 或将它移到废纸篓,请移除旧的重复安装,运行 `npm install -g --include=optional @openai/codex@latest`,然后重试。"; + "codex_login_output" = "codex login 输出:"; + "managed_login_missing_email" = "Codex 登录已完成,但无法获取账户邮箱。请在确认账户已完全登录后重试。"; + "workspace_selection_cancelled" = "QuotaKit 发现多个工作区,但未选择任何工作区。"; + "unsafe_managed_home" = "QuotaKit 拒绝修改意外的托管主目录路径:%@"; + "menu_bar_metric_title" = "菜单栏指标"; + "menu_bar_metric_subtitle" = "选择哪个窗口驱动菜单栏百分比。"; + "menu_bar_metric_subtitle_deepseek" = "在菜单栏显示 DeepSeek 余额。"; + "menu_bar_metric_subtitle_moonshot" = "在菜单栏显示 Moonshot / Kimi API 余额。"; + "menu_bar_metric_subtitle_mistral" = "在菜单栏显示 Mistral API 本月支出。"; + "menu_bar_metric_subtitle_kimik2" = "在菜单栏显示 Kimi K2 API 密钥额度。"; + "automatic" = "自动"; + "primary_api_key_limit" = "主要(API 密钥限制)"; + "section_menu_bar" = "菜单栏"; + "merge_icons_title" = "合并图标"; + "merge_icons_subtitle" = "使用单个菜单栏图标并带提供商切换器。"; + "switcher_shows_icons_title" = "切换器显示图标"; + "switcher_shows_icons_subtitle" = "在切换器中显示提供商图标(否则显示每周进度线)。"; + "show_most_used_provider_title" = "显示用量最高的提供商"; + "show_most_used_provider_subtitle" = "菜单栏会自动显示最接近速率限制的提供商。"; + "menu_bar_shows_percent_title" = "菜单栏显示百分比"; + "menu_bar_shows_percent_subtitle" = "将小动物进度条替换为提供商品牌图标和百分比。"; + "display_mode_title" = "显示模式"; + "display_mode_subtitle" = "选择菜单栏中显示的内容(进度会显示实际用量与预期的对比)。"; + "section_menu_content" = "菜单内容"; + "show_usage_as_used_title" = "显示已使用用量"; + "show_usage_as_used_subtitle" = "进度条会随配额消耗而填充(而不是显示剩余量)。"; + "show_quota_warning_markers_title" = "显示配额预警标记"; + "show_quota_warning_markers_subtitle" = "配置配额预警后,在用量条上绘制阈值刻度标记。"; + "show_reset_time_as_clock_title" = "将重置时间显示为时钟"; + "show_reset_time_as_clock_subtitle" = "将重置时间显示为绝对时钟值,而不是倒计时。"; + "show_provider_changelog_links_title" = "显示提供商变更日志链接"; + "show_provider_changelog_links_subtitle" = "在菜单中为支持的 CLI 提供商添加发布说明链接。"; + "show_credits_extra_usage_title" = "显示额度 + 额外用量"; + "show_credits_extra_usage_subtitle" = "在菜单中显示 Codex 额度和 Claude 额外用量部分。"; + "show_all_token_accounts_title" = "显示所有令牌账户"; + "show_all_token_accounts_subtitle" = "在菜单中堆叠令牌账户(否则显示账户切换栏)。"; + "multi_account_layout_title" = "多账户布局"; + "multi_account_layout_subtitle" = "选择分段账户切换或堆叠账户卡片。"; + "multi_account_layout_segmented" = "分段"; + "multi_account_layout_stacked" = "堆叠"; + "overview_tab_providers_title" = "概览标签提供商"; + "configure" = "配置…"; + "overview_enable_merge_icons_hint" = "启用“合并图标”以配置“概览”标签中的提供商。"; + "overview_no_providers_hint" = "“概览”中没有可用的已启用提供商。"; + "overview_rows_follow_order" = "概览行始终遵循提供商顺序。"; + "overview_no_providers_selected" = "未选择提供商"; + "section_keyboard_shortcut" = "快捷键"; + "open_menu_shortcut_title" = "打开菜单"; + "open_menu_shortcut_subtitle" = "从任意位置触发菜单栏菜单。"; + "install_cli" = "安装 CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "未找到可写的 bin 目录。"; + "show_debug_settings_title" = "显示调试设置"; + "show_debug_settings_subtitle" = "在“调试”标签中显示故障排除工具。"; + "surprise_me_title" = "给我惊喜"; + "surprise_me_subtitle" = "看看你是否喜欢你的智能体在上面找点乐子。"; + "weekly_limit_confetti_title" = "每周限制彩纸"; + "weekly_limit_confetti_subtitle" = "当每周用量重置时播放全屏彩纸。"; + "hide_personal_info_title" = "隐藏个人信息"; + "hide_personal_info_subtitle" = "在菜单栏和菜单界面中隐藏电子邮件地址。"; + "show_provider_storage_usage_title" = "显示提供商存储用量"; + "show_provider_storage_usage_subtitle" = "在菜单中显示本地磁盘用量。会在后台扫描已知的提供商自有路径。"; + "section_keychain_access" = "钥匙串访问"; + "keychain_access_caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入不可用;请在“提供商”中手动粘贴 Cookie 标头。"; + "disable_keychain_access_title" = "禁用钥匙串访问"; + "disable_keychain_access_subtitle" = "启用时阻止任何钥匙串访问。"; + "about_tagline" = "愿你的 token 永不耗尽——随时关注智能体额度。"; + "link_github" = "GitHub"; + "link_website" = "网站"; + "link_twitter" = "Twitter"; + "link_email" = "电子邮件"; + "check_updates_auto" = "自动检查更新"; + "update_channel" = "更新频道"; + "check_for_updates" = "检查更新…"; + "updates_unavailable" = "此构建中更新不可用。"; + "copyright" = "© 2026 Peter Steinberger。MIT 许可证。"; + "section_logging" = "日志"; + "enable_file_logging" = "启用文件日志"; + "enable_file_logging_subtitle" = "将日志写入 %@ 以进行调试。"; + "verbosity_title" = "详细程度"; + "verbosity_subtitle" = "控制日志记录的详细程度。"; + "open_log_file" = "打开日志文件"; + "force_animation_next_refresh" = "下次刷新时强制动画"; + "force_animation_next_refresh_subtitle" = "下次刷新后临时显示加载动画。"; + "section_loading_animations" = "加载动画"; + "loading_animations_caption" = "选择一个模式并在菜单栏中重放。“随机”保持现有行为。"; + "animation_random_default" = "随机(默认)"; + "replay_selected_animation" = "重放选中的动画"; + "blink_now" = "立即闪烁"; + "section_probe_logs" = "探测日志"; + "probe_logs_caption" = "获取最新的探测输出以进行调试;复制会保留完整文本。"; + "fetch_log" = "获取日志"; + "copy" = "复制"; + "save_to_file" = "保存到文件"; + "load_parse_dump" = "加载解析转储"; + "rerun_provider_autodetect" = "重新运行提供商自动检测"; + "loading" = "加载中…"; + "no_log_yet_fetch" = "尚无日志。获取后加载。"; + "section_fetch_strategy" = "获取策略尝试"; + "fetch_strategy_caption" = "提供商上次获取流程中的决策和错误。"; + "section_openai_cookies" = "OpenAI Cookie"; + "openai_cookies_caption" = "上次 OpenAI Cookie 尝试中的 Cookie 导入和 WebKit 抓取日志。"; + "no_log_yet" = "尚无日志。请在“提供商”→“Codex”中更新 OpenAI Cookie 以运行导入。"; + "section_caches" = "缓存"; + "caches_caption" = "清除缓存的费用扫描结果或浏览器 Cookie 缓存。"; + "clear_cookie_cache" = "清除 Cookie 缓存"; + "clear_cost_cache" = "清除费用缓存"; + "section_notifications" = "通知"; + "notifications_caption" = "触发 5 小时会话窗口的测试通知(耗尽/恢复)。"; + "post_depleted" = "发布耗尽通知"; + "post_restored" = "发布恢复通知"; + "section_cli_sessions" = "CLI 会话"; + "cli_sessions_caption" = "探测后保持 Codex/Claude CLI 会话存活。默认在捕获数据后退出。"; + "keep_cli_sessions_alive" = "保持 CLI 会话存活"; + "keep_cli_sessions_alive_subtitle" = "探测之间跳过清理(仅限调试)。"; + "reset_cli_sessions" = "重置 CLI 会话"; + "section_error_simulation" = "错误模拟"; + "error_simulation_caption" = "将模拟错误消息注入菜单卡片以进行布局测试。"; + "set_menu_error" = "设置菜单错误"; + "clear_menu_error" = "清除菜单错误"; + "set_cost_error" = "设置费用错误"; + "clear_cost_error" = "清除费用错误"; + "section_cli_paths" = "CLI 路径"; + "cli_paths_caption" = "解析到的 Codex 二进制文件和 PATH 层;启动时捕获登录 PATH(短超时)。"; + "codex_binary" = "Codex 二进制文件"; + "claude_binary" = "Claude 二进制文件"; + "effective_path" = "有效 PATH"; + "unavailable" = "不可用"; + "login_shell_path" = "登录 shell PATH(启动时捕获)"; + "cleared" = "已清除。"; + "no_fetch_attempts" = "尚无获取尝试。"; + "metric_pref_automatic" = "自动"; + "metric_pref_primary" = "主要"; + "metric_pref_secondary" = "次要"; + "metric_pref_tertiary" = "第三"; + "metric_pref_extra_usage" = "额外用量"; + "metric_pref_average" = "平均"; + "display_mode_percent" = "百分比"; + "display_mode_pace" = "进度"; + "display_mode_both" = "两者"; + "display_mode_percent_desc" = "显示剩余/已使用百分比(例如 45%)"; + "display_mode_pace_desc" = "显示进度指示器(例如 +5%)"; + "display_mode_both_desc" = "同时显示百分比和进度(例如 45% · +5%)"; + "status_operational" = "正常运行"; + "status_partial_outage" = "部分中断"; + "status_major_outage" = "重大中断"; + "status_critical_issue" = "严重问题"; + "status_maintenance" = "维护中"; + "status_unknown" = "状态未知"; + "refresh_manual" = "手动"; + "refresh_1min" = "1 分钟"; + "refresh_2min" = "2 分钟"; + "refresh_5min" = "5 分钟"; + "refresh_15min" = "15 分钟"; + "refresh_30min" = "30 分钟"; + "not_found" = "未找到"; + // === Fork-only Mac UI strings (Mobile pane) — added 2026-05-12 === "mobile_section_icloud_sync" = "iCloud 同步"; + "mobile_toggle_sync_title" = "同步用量到 iCloud"; + "mobile_toggle_sync_subtitle" = "将用量数据推送到 iCloud,供 iOS 伴侣 app 展示。"; + "mobile_section_push" = "iOS 推送通知"; + "mobile_toggle_push_title" = "向 iOS 推送通知"; + "mobile_toggle_push_subtitle" = "当 session 配额耗尽或恢复时,通过 iCloud 向 iOS 伴侣 app 发送可见的提醒推送。这与 Mac 本地通知独立 —— 可以让 Mac 保持安静的同时让 iPhone 收到提醒。"; + "mobile_section_mock_data" = "调试 · Mock provider 数据"; + "mobile_toggle_mock_title" = "注入 mock provider 数据"; + "mobile_toggle_mock_subtitle" = "每次同步推送 60 个合成 provider,覆盖 50 个 ID(6 个 rich mock 用于 codex/claude/perplexity 多账号路径,52 个简单 mock 含 7 对 openai/deepseek/antigravity/manus/copilot/venice/stepfun 多账号第二 tab、5 个 v0.27.0 单账号 provider(grok/groq/elevenlabs/deepgram/llmproxy)、3 个 v0.28/v0.29 单账号 provider(azureopenai/alibabatokenplan/t3chat),2 个 unknown-ID mock 测试 fallback 渲染)。所有 mock email 都使用 `.test` TLD,iPhone(1.5.2+)会用 MOCK 徽章渲染。关闭后 CloudKit 会在约 1 个 cycle 内自动清除。默认关闭。"; + "mobile_mock_reference_header" = "参考 — 最常测试的 8 个 mock(35 个简单 mock 省略):"; + "mobile_mock_cost_note" = "Mock 数据启用时会在 30 天费用面板上增加约 $85。关闭后恢复真实数字。"; + "mobile_section_dev_test" = "DEV — iOS 推送测试"; + "mobile_dev_test_intro" = "向 CloudKit 写入真实的 `QuotaTransition` 记录,触发 iOS app 生产环境下相同的提醒推送。需上方开关打开。"; + "mobile_dev_depleted" = "已耗尽"; + "mobile_dev_restored" = "已恢复"; + "mobile_dev_warning" = "警告"; + "mobile_dev_verify_push" = "验证推送配置"; + "mobile_sync_status_syncing" = "同步中…"; + "mobile_sync_status_last_sync_format" = "上次同步:%@"; + "mobile_sync_status_last_attempt_format" = "上次尝试:%@"; + "mobile_sync_status_no_sync" = "尚未同步"; + "mobile_button_sync_now" = "立即同步"; + /* Cost estimation */ "QuotaKit can't show its menu bar icon" = "QuotaKit 无法显示菜单栏图标"; + "Dismiss" = "关闭"; + "Open Menu Bar Settings" = "打开菜单栏设置"; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe 可能会在“系统设置”→“菜单栏”→“允许显示在菜单栏”中阻止菜单栏应用。QuotaKit 正在运行,但 macOS 可能隐藏了它的图标。请打开菜单栏设置并启用 QuotaKit。"; + "cost_header_estimated" = "费用(估算)"; + "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; + "Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; + "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; + "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; + "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.quotakit/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; + "Missing DeepSeek API key." = "缺少 DeepSeek API 密钥。"; + "%@ is unavailable in the current environment." = "%@ 在当前环境不可用。"; + "All Systems Operational" = "系统全部正常"; + "Last 30 days" = "近 30 天"; + "Last 30 days:" = "近 30 天:"; + "This month" = "本月"; + "Store multiple OpenAI API keys." = "存储多个 OpenAI API 密钥。"; + "Admin API key" = "管理员 API 密钥"; + "Open billing" = "打开账单"; + "Google accounts" = "Google 账户"; + "Store multiple Antigravity Google OAuth accounts for quick switching." = "存储多个 Antigravity Google OAuth 账户以便快速切换。"; + "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "保存每个已登录的 Google 账户,便于快速切换 Antigravity。优先使用 Antigravity.app OAuth;也可使用 ANTIGRAVITY_OAUTH_CLIENT_ID 和 ANTIGRAVITY_OAUTH_CLIENT_SECRET 作为覆盖。"; + "Add Google Account" = "添加 Google 账户"; + "Open Token Plan" = "打开 Token 套餐页"; + "Text Generation" = "文本生成"; + "Text to Speech" = "文本转语音"; + "Music Generation" = "音乐生成"; + "Image Generation" = "图像生成"; + "No local data found" = "未找到本地数据"; + "Credits unavailable; keep Codex running to refresh." = "额度暂不可用;请保持 Codex 运行后再刷新。"; + "No available fetch strategy for minimax." = "没有可用的 MiniMax 抓取策略。"; + "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "未找到 Cursor 会话。请在 Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX 或 Edge Canary 中登录 cursor.com。若使用 Safari,请在“系统设置 ▸ 隐私与安全性”中授予 QuotaKit“完全磁盘访问权限”。你也可以在 QuotaKit 菜单中登录 Cursor(添加/切换账户)。"; + "No OpenCode session cookies found in browsers." = "在浏览器中未找到 OpenCode 会话 Cookie。"; + "No available fetch strategy for %@." = "%@ 暂无可用的获取策略。"; + "Today" = "今日"; + "Today tokens" = "今日 token 用量"; + "30d cost" = "近 30 天费用"; + "30d tokens" = "近 30 天 token 用量"; + "Latest tokens" = "最近 token 用量"; + "Top model" = "最常用模型"; + "Storage" = "存储"; + "Add Account..." = "添加账户…"; + "Usage Dashboard" = "用量仪表盘"; + "Status Page" = "状态页"; + "Settings..." = "设置…"; + "About CodexBar" = "关于 QuotaKit"; + "Quit" = "退出"; + "Last %d day" = "近 %d 天"; + "Last %d days" = "近 %d 天"; + "%@ tokens" = "%@ token 用量"; + "Latest billing day" = "最近结算日"; + "Latest billing day (%@)" = "最近结算日(%@)"; + "%@ left" = "%@ 剩余"; + "Resets %@" = "重置于 %@"; + "Resets in %@" = "%@后重置"; + "Resets now" = "立即重置"; + "Lasts until reset" = "持续到重置"; + "Updated %@" = "更新于 %@"; + "Updated %@h ago" = "%@ 小时前更新"; + "Updated %@m ago" = "%@ 分钟前更新"; + "Updated just now" = "刚刚更新"; + "Projected empty in %@" = "预计 %@ 后耗尽"; + "Runs out in %@" = "预计 %@ 后耗尽"; + "Pace: %@" = "节奏:%@"; + "Pace: %@ · %@" = "节奏:%@ · %@"; + "%@ · %@" = "%@ · %@"; + "≈ %d%% run-out risk" = "≈ %d%% 耗尽风险"; + "%d%% in deficit" = "超额 %d%%"; + "%d%% in reserve" = "余量 %d%%"; + "usage_percent_suffix_left" = "剩余"; + "usage_percent_suffix_used" = "已使用"; + "Store multiple DeepSeek API keys." = "存储多个 DeepSeek API 密钥。"; + "This week" = "本周"; + "Week" = "本周"; + "Month" = "本月"; + "Models" = "模型数"; + "24h tokens" = "24 小时 token 用量"; + "Latest hour" = "最近 1 小时"; + "Peak hour" = "峰值小时"; + "Top method" = "主要方法"; + "30d cash" = "近 30 天费用"; + "30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; + "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; + "Rate limit: %d / %@" = "速率限制:%d / %@"; + "Key remaining" = "密钥剩余额度"; + "No limit set for the API key" = "该 API 密钥未设置上限"; + "API key limit unavailable right now" = "当前无法获取 API 密钥上限"; + "This month: %@ tokens" = "本月:%@ token"; + "No utilization data yet." = "暂无使用率数据。"; + "No %@ utilization data yet." = "暂无 %@ 使用率数据。"; + "%@: %@%% used" = "%@:已用 %@%%"; + "%dd" = "%d 天"; + "today" = "今天"; + "just now" = "刚刚"; + "On pace" = "节奏正常"; + "Runs out now" = "即将耗尽"; + "Projected empty now" = "即将耗尽"; + "Switch Account..." = "切换账户…"; + "Update ready, restart now?" = "更新已就绪,是否立即重启?"; + "Daily" = "每日"; + "Hourly Tokens" = "每小时 token 用量"; + "No data" = "暂无数据"; + "No usage breakdown data available." = "暂无可用用量明细数据。"; + "Today: %@ · %@ tokens" = "今日:%@ · %@ token"; + "Today: %@" = "今日:%@"; + "Today: %@ tokens" = "今日:%@ token"; + "Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; + "Last 30 days: %@" = "近 30 天:%@"; + "Est. total (30d): %@" = "近 30 天估算总计:%@"; + "Est. total (%@): %@" = "估算总计(%@):%@"; + "Hover a bar for details" = "悬停在柱形图上查看详情"; + "%@: %@ · %@ tokens" = "%@:%@ · %@ token"; + "No providers selected for Overview." = "概览中尚未选择提供商。"; + "No overview data available." = "概览暂无可用数据。"; + "Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自动模式会优先使用本地 IDE API;当 IDE 关闭时再使用 Google OAuth。"; + "Login with Google" = "使用 Google 登录"; + "Google OAuth" = "Google OAuth"; + "Add accounts via GitHub OAuth Device Flow on the selected host." = "通过所选主机的 GitHub OAuth 设备流程添加账户。"; + "Manual cleanup: past sessions" = "手动清理:历史会话"; + "Clearing removes past resume, continue, and rewind history." = "清理后将移除历史恢复、继续和回退记录。"; + "Manual cleanup: file checkpoints" = "手动清理:文件检查点"; + /* Popup panels */ "No usage configured." = "尚未配置用量。"; + "Quota" = "配额"; + "tokens" = "token"; + "requests" = "请求"; + "Latest" = "最新"; + "Monthly" = "每月"; + "Sonnet" = "Sonnet"; + "Overages" = "超额"; + "Activity" = "活动"; + "Copied" = "已复制"; + "Copy error" = "复制错误"; + "Copy path" = "复制路径"; + "Extra usage spent" = "额外用量支出"; + "Credits remaining" = "剩余额度"; + "Using CLI fallback" = "使用 CLI 回退"; + "Balance updates in near-real time (up to 5 min lag)" = "余额接近实时更新(最多延迟 5 分钟)"; + "Daily billing data finalizes at 07:00 UTC" = "每日账单数据会在 UTC 07:00 完成结算"; + "%@ of %@ credits left" = "剩余 %@ / %@ 点额度"; + "%@ of %@ bonus credits left" = "剩余 %@ / %@ 点奖励额度"; + "%@ / %@ (%@ remaining)" = "%@ / %@(剩余 %@)"; + "%@/%@ left" = "剩余 %@ / %@"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "%@后恢复"; + "used after next regen" = "下次恢复后已使用"; + "after next regen" = "下次恢复后"; + "Near full" = "接近全满"; + "Full in ~1 regen" = "约 1 次恢复后全满"; + "Full in ~%.0f regens" = "约 %.0f 次恢复后全满"; + "Overage usage" = "超额用量"; + "Overage cost" = "超额费用"; + "credits" = "额度"; + "Zen balance" = "Zen 余额"; + "API spend" = "API 支出"; + "Extra usage" = "额外用量"; + "Quota usage" = "配额用量"; + "%.0f%% used" = "已使用 %.0f%%"; + "Usage history (today)" = "用量记录(今天)"; + "Usage history (%d days)" = "用量记录(%d 天)"; + "%d percent remaining" = "剩余 %d%%"; + "Unknown" = "未知"; + "stale data" = "数据过旧"; + "No credits history data available." = "暂无可用额度记录数据。"; + "Credits history chart" = "额度记录图表"; + "%d days of credits data" = "%d 天额度数据"; + "Usage breakdown chart" = "用量明细图表"; + "%d days of usage data across %d services" = "%d 天用量数据,涵盖 %d 个服务"; + "Cost history chart" = "费用记录图表"; + "%d days of cost data" = "%d 天费用数据"; + "Plan utilization chart" = "套餐使用率图表"; + "%d utilization samples" = "%d 条使用率样本"; + "Hourly Usage" = "每小时用量"; + "Usage remaining" = "剩余用量"; + "Usage used" = "已使用用量"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "API 密钥已验证。Ollama 不会通过 API 暴露 Cloud 配额限制。"; + "Last 30 days: %@ tokens" = "近 30 天:%@ token"; + "7d spend" = "7 天支出"; + "30d spend" = "30 天支出"; + "Cache read" = "缓存读取"; + "Claude Admin API 30 day spend trend" = "Claude Admin API 30 天支出趋势"; + "OpenRouter API key spend trend" = "OpenRouter API 密钥支出趋势"; + "z.ai hourly token trend" = "z.ai 每小时 token 趋势"; + "MiniMax 30 day token usage trend" = "MiniMax 30 天 token 用量趋势"; + "Today cash" = "今日现金"; + "DeepSeek 30 day token usage trend" = "DeepSeek 30 天 token 用量趋势"; + "cache-hit input" = "缓存命中输入"; + "cache-miss input" = "缓存未命中输入"; + "output" = "输出"; + "Requests" = "请求"; + "Reported by OpenAI Admin API organization usage." = "由 OpenAI Admin API 组织用量报告。"; + "Reported by Mistral billing usage." = "由 Mistral 账单用量报告。"; + "Clearing removes checkpoint restore data for previous edits." = "清理后将移除以往编辑的检查点恢复数据。"; + "Manual cleanup: saved plans" = "手动清理:已保存计划"; + "Clearing removes old plan-mode files." = "清理后将移除旧的计划模式文件。"; + "Manual cleanup: debug logs" = "手动清理:调试日志"; + "Clearing removes past debug logs." = "清理后会移除历史调试日志。"; + "Manual cleanup: attachment cache" = "手动清理:附件缓存"; + "Clearing removes cached large pastes or attached images." = "清理后会移除缓存的大段粘贴内容或附件图片。"; + "Manual cleanup: session metadata" = "手动清理:会话元数据"; + "Clearing removes per-session environment metadata." = "清理后会移除每个会话的环境元数据。"; + "Manual cleanup: shell snapshots" = "手动清理:Shell 快照"; + "Clearing removes leftover runtime shell snapshot files." = "清理后会移除遗留的运行时 Shell 快照文件。"; + "Manual cleanup: legacy todos" = "手动清理:旧版待办"; + "Clearing removes legacy per-session task lists." = "清理后会移除旧版的每会话任务列表。"; + "Manual cleanup: sessions" = "手动清理:会话"; + "Clearing removes past Codex session history." = "清理后将移除历史 Codex 会话记录。"; + "Manual cleanup: archived sessions" = "手动清理:归档会话"; + "Clearing removes archived Codex session history." = "清理后会移除已归档的 Codex 会话记录。"; + "Manual cleanup: cache" = "手动清理:缓存"; + "Clearing removes provider-owned cached data." = "清理后将移除提供商缓存数据。"; + "Manual cleanup: logs" = "手动清理:日志"; + "Clearing removes local diagnostic logs." = "清理后将移除本地诊断日志。"; + "Manual cleanup: file history" = "手动清理:文件历史"; + "Clearing removes local edit checkpoint history." = "清理后会移除本地编辑检查点历史。"; + "Manual cleanup: temporary data" = "手动清理:临时数据"; + "Clearing removes local temporary provider data." = "清理后会移除本地临时提供商数据。"; + "Total: %@" = "总计:%@"; + "%d more items" = "另有 %d 项"; + "Cleanup ideas" = "清理建议"; + "%d unreadable item(s) skipped" = "已跳过 %d 个不可读项目"; + "weekly_progress_work_days_title" = "工作日刻度线"; + "weekly_progress_work_days_subtitle" = "在每周用量条上显示按天分隔的刻度线。"; + "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; + "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; + "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; + "minimax_usage_amount_format" = "用量:%@ / %@"; + "minimax_used_percent_format" = "已使用 %@"; + "minimax_service_text_generation" = "文本生成"; + "minimax_service_text_to_speech" = "文本转语音"; + "minimax_service_music_generation" = "音乐生成"; + "minimax_service_image_generation" = "图像生成"; + "minimax_service_lyrics_generation" = "歌词生成"; + "minimax_service_coding_plan_vlm" = "视觉编码计划"; + "minimax_service_coding_plan_search" = "搜索编码计划"; + /* Notification strings */ "login_success_notification_title" = "%@ 登录成功"; + "login_success_notification_body" = "可以返回应用了,认证已完成。"; + "session_depleted_notification_title" = "%@ 会话额度已用尽"; + "session_depleted_notification_body" = "剩余 0%。可用时会通知你。"; + "session_restored_notification_title" = "%@ 会话已恢复"; + "session_restored_notification_body" = "会话额度已重新可用。"; + "quota_warning_notification_title" = "%1$@ 的 %2$@ 额度偏低"; + "quota_warning_notification_body" = "剩余 %1$@。已达到 %2$d%% 的 %3$@ 预警阈值。"; + "quota_warning_notification_body_with_account" = "账户 %1$@。剩余 %2$@。已达到 %3$d%% 的 %4$@ 预警阈值。"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ 正在等待权限"; + "%@ requests" = "%@ 个请求"; + "%@: %@ credits" = "%@:%@ 额度"; + "30d requests" = "近 30 天请求"; + "4 days" = "4 天"; + "5 days" = "5 天"; + "7 days" = "7 天"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "API 密钥会验证 Ollama Cloud 访问权限;Cookie 仍会提供配额限制。"; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS 访问密钥 ID。也可以用 AWS_ACCESS_KEY_ID 设置。"; + "AWS region. Can also be set with AWS_REGION." = "AWS 区域。也可以用 AWS_REGION 设置。"; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS 秘密访问密钥。也可以用 AWS_SECRET_ACCESS_KEY 设置。"; + "Access key ID" = "访问密钥 ID"; + "Add Account" = "添加账号"; + "Adding Account…" = "正在添加账号…"; + "Antigravity login failed" = "Antigravity 登录失败"; + "Antigravity login timed out" = "Antigravity 登录超时"; + "Auth source" = "认证来源"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的 Chrome 浏览器 Cookie。"; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "自动从 Chromium 浏览器 localStorage 导入 Windsurf 会话数据。"; + "Automatic imports browser cookies from Bailian." = "自动导入 Bailian 的浏览器 Cookie。"; + "Automatically imports browser cookies." = "自动导入浏览器 Cookie。"; + "Automatically imports browser session cookies." = "自动导入浏览器会话 Cookie。"; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI 部署名称。也支持 AZURE_OPENAI_DEPLOYMENT_NAME。"; + "Azure OpenAI key" = "Azure OpenAI 密钥"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 资源端点。也支持 AZURE_OPENAI_ENDPOINT。"; + "Base URL" = "Base URL"; + "Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 实例的 Base URL。"; + "Browser cookies" = "浏览器 Cookie"; + "Cap end" = "上限终点"; + "Cap start" = "上限起点"; + "Capacity End" = "容量终点"; + "Capacity Start" = "容量起点"; + "Changelog" = "变更记录"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "选择国际或中国大陆账号使用的 Moonshot/Kimi API 主机。"; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit 无法替换仅使用 API 密钥登录设置的系统账号。"; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit 找不到该账号已保存的认证。请重新认证后再试。"; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit 无法读取托管账号存储区。请先修复存储区,再添加其他账号。"; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit 无法读取该账号已保存的认证。请重新认证后再试。"; + "CodexBar could not read the current system account on this Mac." = "QuotaKit 无法读取此 Mac 上当前的系统账号。"; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit 无法替换此 Mac 上当前的 Codex 认证。"; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit 无法在切换前安全保留当前的系统账号。"; + "CodexBar could not save the current system account before switching." = "QuotaKit 无法在切换前保存当前的系统账号。"; + "CodexBar could not update managed account storage." = "QuotaKit 无法更新托管账号存储区。"; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit 发现另一个托管账号已使用当前的系统账号。请先解决重复账号,再进行切换。"; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求“%@”,以解密浏览器 Cookie 并认证你的账号。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求 Claude Code OAuth token,以获取你的 Claude 用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Amp Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Augment Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Claude Cookie 标头,以获取 Claude 网页用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Cursor Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Factory Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 GitHub Copilot token,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Kimi K2 API 密钥,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Kimi 认证 token,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 MiniMax API token,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 MiniMax Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 OpenAI Cookie 标头,以获取 Codex 仪表盘额外数据。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 OpenCode Cookie 标头,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 Synthetic API 密钥,以获取用量。点击“确定”继续。"; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit 将向 macOS 钥匙串请求你的 z.ai API token,以获取用量。点击“确定”继续。"; + "Could not open Cursor login in your browser." = "无法在浏览器中打开 Cursor 登录。"; + "Could not open browser for Antigravity" = "无法为 Antigravity 打开浏览器"; + "Credits used" = "已用额度"; + "Day" = "日期"; + "Deployment" = "部署"; + "Drag to reorder" = "拖动以重新排序"; + "Endpoint" = "端点"; + "Enterprise host" = "Enterprise 主机"; + "Extra usage balance: %@" = "额外使用量余额:%@"; + "Keychain Access Required" = "需要钥匙串访问权限"; + "Kiro menu bar value" = "Kiro 菜单栏数值"; + "Label" = "标签"; + "No organizations loaded. Click Refresh after setting your API key." = "尚未加载组织。设置 API 密钥后点击“刷新”。"; + "No output captured." = "未捕获到输出。"; + "No system account" = "没有系统账号"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "打开 Augment(退出后重新登录)"; + "Open Codebuff Dashboard" = "打开 Codebuff 仪表盘"; + "Open Command Code Settings" = "打开 Command Code 设置"; + "Open Crof dashboard" = "打开 Crof 仪表盘"; + "Open Manus" = "打开 Manus"; + "Open MiMo Balance" = "打开 MiMo 余额"; + "Open Moonshot Console" = "打开 Moonshot 控制台"; + "Open Ollama API Keys" = "打开 Ollama API 密钥"; + "Open StepFun Platform" = "打开 StepFun 平台"; + "Open T3 Chat Settings" = "打开 T3 Chat 设置"; + "Open Volcengine Ark Console" = "打开 Volcengine Ark 控制台"; + "Open legacy provider docs" = "打开旧版提供商文档"; + "Open projects" = "打开项目"; + "Open this URL manually to continue login:\n\n%@" = "手动打开此 URL 以继续登录:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "适用于关联多个 Anthropic 组织的账号,可选填组织 ID。"; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "选填。应用到已设置的 Admin API 密钥;选中的 token 账号不会继承 OPENAI_PROJECT_ID。"; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "选填。输入你的 GitHub Enterprise 主机,例如 octocorp.ghe.com。留空则使用 github.com。"; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "选填。留空会发现并汇总 API 密钥可见的项目。"; + "Org ID (optional)" = "组织 ID(选填)"; + "Organizations" = "组织"; + "Password" = "密码"; + "%@ authentication is disabled." = "%@ 认证已禁用。"; + "%@ cookies are disabled." = "%@ Cookie 已禁用。"; + "%@ web API access is disabled." = "%@ Web API 访问已禁用。"; + "Disable %@ dashboard cookie usage." = "禁用 %@ 仪表盘 Cookie 用法。"; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "高级设置中已禁用钥匙串访问,因此无法导入浏览器 Cookie。"; + "Manually paste an %@ from a browser session." = "从浏览器会话中手动粘贴 %@。"; + "Paste a Cookie header captured from %@." = "粘贴从 %@ 捕获的 Cookie 标头。"; + "Paste a Cookie header from %@." = "粘贴来自 %@ 的 Cookie 标头。"; + "Paste a Cookie header or cURL capture from %@." = "粘贴来自 %@ 的 Cookie 标头或 cURL 捕获内容。"; + "Paste a Cookie header or full cURL capture from %@." = "粘贴来自 %@ 的 Cookie 标头或完整 cURL 捕获内容。"; + "Paste a Cookie or Authorization header from %@." = "粘贴来自 %@ 的 Cookie 或 Authorization 标头。"; + "Paste a full cookie header or the %@ value." = "粘贴完整 Cookie 标头或 %@ 值。"; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "粘贴 T3 Chat 设置中的 Cookie 标头或完整 cURL 捕获内容。"; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "粘贴发往 admin.mistral.ai 请求中的 Cookie 标头。必须包含 ory_session_* Cookie。"; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "粘贴 platform.stepfun.com 已登录浏览器会话中的 Oasis-Token。"; + "Paste the %@ JSON bundle from %@." = "粘贴来自 %2$@ 的 %1$@ JSON 包。"; + "Paste the %@ value or a full Cookie header." = "粘贴 %@ 值或完整 Cookie 标头。"; + "Personal account" = "个人账号"; + "Project ID" = "项目 ID"; + "Re-auth" = "重新认证"; + "Re-authenticating…" = "正在重新认证…"; + "Refresh Session" = "刷新会话"; + "Refresh organizations" = "刷新组织"; + "Region" = "区域"; + "Reload" = "重新加载"; + "Reorder" = "重新排序"; + "Secret access key" = "秘密访问密钥"; + "Series" = "序列"; + "Service" = "服务"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "在菜单栏图标旁显示或隐藏 Kiro 额度、百分比,或两者都显示。"; + "Show usage for organizations you belong to. Personal account is always shown." = "显示你所属组织的用量。个人账号始终显示。"; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "请在浏览器中登录 cursor.com,然后在 QuotaKit 刷新 Cursor。"; + "Simulated error text" = "模拟错误文字"; + "StepFun platform account (phone number or email)." = "StepFun 平台账号(手机号或电子邮件)。"; + "Stored in ~/.codexbar/config.json." = "存储在 ~/.quotakit/config.json 中。"; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "存储在 ~/.quotakit/config.json 中。也支持 AZURE_OPENAI_API_KEY。"; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "存储在 ~/.quotakit/config.json 中。官方 Kimi API 请使用 Moonshot / Kimi API。"; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "存储在 ~/.quotakit/config.json 中。请从 Volcengine Ark 控制台获取 API 密钥。"; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "存储在 ~/.quotakit/config.json 中。请从 Ollama 设置获取密钥。"; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "存储在 ~/.quotakit/config.json 中。请从 console.deepgram.com 获取密钥。"; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "存储在 ~/.quotakit/config.json 中。请从 elevenlabs.io/app/settings/api-keys 获取密钥。"; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "存储在 ~/.quotakit/config.json 中。请从 openrouter.ai/settings/keys 获取密钥,并在那里设置密钥支出上限以启用 API 密钥配额跟踪。"; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "存储在 ~/.quotakit/config.json 中。在 Warp 中打开 Settings > Platform > API Keys,然后创建一个。"; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "存储在 ~/.quotakit/config.json 中。指标需要 Groq Enterprise Prometheus 访问权限。"; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "存储在 ~/.quotakit/config.json 中。优先使用 OPENAI_ADMIN_KEY;OPENAI_API_KEY 仍可使用。"; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "存储在 ~/.quotakit/config.json 中。需要 Anthropic Admin API 密钥。"; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "存储在 ~/.quotakit/config.json 中。用于 /v1/quota-stats。"; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "存储在 ~/.quotakit/config.json 中。你也可以提供 CODEBUFF_API_KEY,或让 QuotaKit 读取由 `codebuff login` 创建的 ~/.config/manicode/credentials.json。"; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "存储在 ~/.quotakit/config.json 中。你也可以提供 CROF_API_KEY。"; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "存储在 ~/.quotakit/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; + "T3 Chat cookie" = "T3 Chat Cookie"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "该账号已无法在 QuotaKit 中使用。请刷新账号列表后再试。"; + "The browser login did not complete in time. Try Antigravity login again." = "浏览器登录未在时限内完成。请再次尝试 Antigravity 登录。"; + "Timed out waiting for Cursor login. %@" = "等待 Cursor 登录超时。%@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "等待 Cursor 登录超时。%@ 最后错误:%@"; + "Today requests" = "今日请求"; + "Total (30d): %@ credits" = "总计(30 天):%@ 额度"; + "Username" = "用户名"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "使用用户名与密码登录,并自动获取 Oasis-Token。"; + "Uses username + password to login and obtain an %@ automatically." = "使用用户名与密码登录,并自动获取 %@。"; + "Utilization End" = "使用率终点"; + "Utilization Start" = "使用率起点"; + "Verbosity" = "详细程度"; + "Windsurf session JSON bundle" = "Windsurf 会话 JSON 包"; + "Workspace ID" = "工作区 ID"; + "Your StepFun platform password. Used to login and obtain a session token." = "你的 StepFun 平台密码。用于登录并获取会话 token。"; + "claude /login exited with status %d." = "claude /login 以状态 %d 结束。"; + "codex login exited with status %d." = "codex login 以状态 %d 结束。"; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\n或粘贴 Abacus AI 仪表盘的 cURL 捕获内容"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或粘贴 __Secure-next-auth.session-token 值"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或粘贴 kimi-auth token 值"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只粘贴 session_id 值"; + "Clear" = "清除"; + "No matching providers" = "没有匹配的提供商"; + "Search providers" = "搜索提供商"; + + +"language_vietnamese" = "越南语"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 3b552a006..4b49493bb 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1,931 +1,1866 @@ /* Chinese (Traditional) localization for CodexBar */ " providers" = " 提供者"; + "(System)" = "(System)"; + "30d" = "30 天"; + "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; + "API key" = "API 金鑰"; + "API key limit" = "API 金鑰限制"; + "API region" = "API 區域"; + "API token" = "API token"; + "API tokens" = "API token"; + "About" = "關於"; + "Account" = "帳號"; + "Accounts" = "帳號"; + "Accounts subtitle" = "帳號副標題"; + "Active" = "作用中"; + "Add" = "新增"; + "Add Workspace" = "新增工作區"; + "Advanced" = "進階"; + "All" = "全部"; + "Always allow prompts" = "一律允許提示"; + "Animation pattern" = "動畫模式"; + "Antigravity login is managed in the app" = "Antigravity 登入由 App 管理"; + "Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework OAuth 鑰匙圈讀取器。"; + "Auth" = "認證"; + "Auto" = "自動"; + "Auto falls back to the next source if the preferred one fails." = "如果偏好的來源失敗,自動改用下一個來源。"; + "Auto uses API first, then falls back to CLI on auth failures." = "自動優先使用 API,認證失敗時改用 CLI。"; + "Auto-detect" = "自動偵測"; + "Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; + "Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 逾時:10 分鐘"; + "Automatic" = "自動"; + "Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookie 和 WorkOS token。"; + "Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookie 和本機儲存 token。"; + "Automatic imports browser cookies for dashboard extras." = "自動匯入用於儀表板附加功能的瀏覽器 Cookie。"; + "Automatic imports browser cookies for the web API." = "自動匯入用於 Web API 的瀏覽器 Cookie。"; + "Automatic imports browser cookies from Model Studio/Bailian." = "自動從 Model Studio/Bailian 匯入瀏覽器 Cookie。"; + "Automatic imports browser cookies from admin.mistral.ai." = "自動從 admin.mistral.ai 匯入瀏覽器 Cookie。"; + "Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookie。"; + "Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已儲存的工作階段。"; + "Automatic imports browser cookies." = "自動匯入瀏覽器 Cookie。"; + "Automatically imports browser session cookie." = "自動匯入瀏覽器工作階段 Cookie。"; + "Automatically opens CodexBar when you start your Mac." = "登入 Mac 時自動開啟 QuotaKit。"; + "Automation" = "自動化"; + "Average (\\(label1) + \\(label2))" = "平均(\\(label1) + \\(label2))"; + "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均(\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; + "Avoid Keychain prompts" = "避免鑰匙圈提示"; + "Balance" = "餘額"; + "Battery Saver" = "省電模式"; + "Bordered" = "有邊框"; + "Build" = "建置"; + "Built \\(buildTimestamp)" = "建置於 \\(buildTimestamp)"; + "Buy Credits..." = "購買額度..."; + "Buy Credits…" = "購買額度…"; + "CLI paths" = "CLI 路徑"; + "CLI sessions" = "CLI 工作階段"; + "Caches" = "快取"; + "Cancel" = "取消"; + "Check for Updates…" = "檢查更新…"; + "Check for updates automatically" = "自動檢查更新"; + "Check if you like your agents having some fun up there." = "讓選單列上的 Agent 多一點變化。"; + "Check provider status" = "檢查提供者狀態"; + "Choose Codex workspace" = "選擇 Codex 工作區"; + "Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; + "Choose up to " = "選擇最多 "; + "Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個提供者"; + "Choose up to \\(count) providers" = "選擇最多 \\(count) 個提供者"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; + "Choose which Codex account CodexBar should follow." = "選擇 QuotaKit 要追蹤的 Codex 帳號。"; + "Choose which window drives the menu bar percent." = "選擇用於驅動選單列百分比的時段。"; + "Chrome" = "Chrome"; + "Claude CLI not found" = "找不到 Claude CLI"; + "Claude binary" = "Claude 二進位檔案"; + "Claude cookies" = "Claude Cookie"; + "Claude login failed" = "Claude 登入失敗"; + "Claude login timed out" = "Claude 登入逾時"; + "Close" = "關閉"; + "Code review" = "程式碼審查"; + "Codex CLI not found" = "找不到 Codex CLI"; + "Codex account login already running" = "Codex 帳號登入已在執行"; + "Codex binary" = "Codex 二進位檔案"; + "Codex login failed" = "Codex 登入失敗"; + "Codex login timed out" = "Codex 登入逾時"; + "CodexBar Lifecycle Keepalive" = "QuotaKit 生命週期維持"; + "CodexBar could not read managed account storage. " = "QuotaKit 無法讀取託管帳號儲存。"; + "Configure…" = "設定…"; + "Connected" = "已連線"; + "Controls how much detail is logged." = "控制記錄詳細程度。"; + "Cookie header" = "Cookie 標頭"; + "Cookie source" = "Cookie 來源"; + "Cookie: ..." = "Cookie:..."; + "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或貼上來自 Abacus AI 儀表板的 cURL 擷取內容"; + "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 __Secure-next-auth.session-token 的值"; + "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 kimi-auth token 值"; + "Cookie: …" = "Cookie:…"; + "CopilotDeviceFlow" = "Copilot 裝置流程"; + "Cost" = "費用"; + "Could not add Codex account" = "無法新增 Codex 帳號"; + "Could not open Terminal for Gemini" = "無法為 Gemini 開啟終端"; + "Could not start claude /login" = "無法啟動 claude /login"; + "Could not start codex login" = "無法啟動 codex login"; + "Could not switch system account" = "無法切換系統帳號"; + "Credits" = "額度"; + "Credits history" = "額度歷史"; + "Cursor login failed" = "Cursor 登入失敗"; + "Custom" = "自訂"; + "Custom Path" = "自訂路徑"; + "Daily Routines" = "每日例行工作"; + "Debug" = "除錯"; + "Default" = "預設"; + "Disable Keychain access" = "停用鑰匙圈存取"; + "Disabled" = "已停用"; + "Disabled — no recent data" = "已停用 — 無近期資料"; + "Disconnected" = "已中斷連線"; + "Display" = "顯示"; + "Display mode" = "顯示模式"; + "Display reset times as absolute clock values instead of countdowns." = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; + "Done" = "完成"; + "Effective PATH" = "有效 PATH"; + "Email" = "電子郵件"; + "Enable Merge Icons to configure Overview tab providers." = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; + "Enable file logging" = "啟用檔案記錄"; + "Enabled" = "已啟用"; + "Error" = "錯誤"; + "Error simulation" = "錯誤模擬"; + "Expose troubleshooting tools in the Debug tab." = "在「除錯」標籤中顯示疑難排解工具。"; + "Failed" = "失敗"; + "False" = "假"; + "Fetch strategy attempts" = "取得策略嘗試"; + "Fetching" = "取得中"; + "Field" = "欄位"; + "Field subtitle" = "欄位副標題"; + "Finish the current managed account change before switching the system account." = "請先完成目前託管帳號變更,再切換系統帳號。"; + "Force animation on next refresh" = "下次重新整理時強制動畫"; + "Gateway region" = "閘道區域"; + "Gemini CLI not found" = "找不到 Gemini CLI"; + "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,並在圖示和選單中顯示服務異常。"; + "General" = "一般"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "GitHub Copilot 登入"; + "GitHub Login" = "GitHub 登入"; + "Hide details" = "隱藏詳細資訊"; + "Hide personal information" = "隱藏個人資訊"; + "Historical tracking" = "歷史追蹤"; + "How often CodexBar polls providers in the background." = "QuotaKit 在背景輪詢提供者的頻率。"; + "Inactive" = "非作用中"; + "Install CLI" = "安裝 CLI"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後重試。"; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後重試。"; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後重試。"; + "JetBrains AI is ready" = "JetBrains AI 已就緒"; + "JetBrains IDE" = "JetBrains IDE"; + "Keep CLI sessions alive" = "保持 CLI 工作階段存活"; + "Keyboard shortcut" = "快速鍵"; + "Keychain access" = "鑰匙圈存取"; + "Keychain prompt policy" = "鑰匙圈提示策略"; + "Last \\(name) fetch failed:" = "上次取得 \\(name) 失敗:"; + "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次取得 \\(self.store.metadata(for: self.provider).displayName) 失敗:"; + "Last attempt" = "上次嘗試"; + "Limits not available" = "無法取得限制資料"; + "Link" = "連結"; + "Loading animations" = "載入動畫"; + "Loading…" = "載入中…"; + "Local" = "本機"; + "Logging" = "記錄"; + "Login failed" = "登入失敗"; + "Login shell PATH (startup capture)" = "登入 shell PATH(啟動時擷取)"; + "Login timed out" = "登入逾時"; + "MCP details" = "MCP 詳細資訊"; + "Managed Codex accounts unavailable" = "無法使用託管 Codex 帳號"; + "Managed account storage is unreadable. Live account access is still available, " = "託管帳號儲存區無法讀取。即時帳號仍可存取,"; + "Manual" = "手動"; + "May your tokens never run out—keep agent limits in view." = "願你的 token 永不用完,隨時掌握 Agent 限制。"; + "Menu bar" = "選單列"; + "Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的提供者。"; + "Menu bar metric" = "選單列指標"; + "Menu bar shows percent" = "選單列顯示百分比"; + "Menu content" = "選單內容"; + "Merge Icons" = "合併圖示"; + "Never prompt" = "永不提示"; + "No" = "否"; + "No Codex accounts detected yet." = "未偵測到 Codex 帳號。"; + "No JetBrains IDE detected" = "未偵測到 JetBrains IDE"; + "No cost history data." = "尚無費用歷史資料。"; + "No usage yet" = "尚無使用量"; + "Not fetched yet" = "尚未取得"; + "No credits history data." = "尚無額度歷史資料。"; + "No data available" = "沒有可用資料"; + "No data yet" = "尚無資料"; + "No enabled providers available for Overview." = "「概覽」中沒有可用的已啟用提供者。"; + "No providers selected" = "未選擇提供者"; + "No token accounts yet." = "尚無 token 帳號。"; + "No usage breakdown data." = "尚無使用量明細資料。"; + "None" = "無"; + "Notifications" = "通知"; + "Notifies when the 5-hour session quota hits 0% and when it becomes " = "當 5 小時工作階段配額降至 0% 或"; + "OK" = "好"; + "Obscure email addresses in the menu bar and menu UI." = "在選單列和選單介面中隱藏電子郵件地址。"; + "Off" = "關閉"; + "Offline" = "離線"; + "On" = "開啟"; + "Online" = "線上"; + "Only on user action" = "僅在使用者操作時"; + "Open" = "開啟"; + "Open API Keys" = "開啟 API 金鑰"; + "Open Amp Settings" = "開啟 Amp 設定"; + "Open Antigravity to sign in, then refresh CodexBar." = "開啟 Antigravity 登入,然後重新整理 QuotaKit。"; + "Open Browser" = "開啟瀏覽器"; + "Open Coding Plan" = "開啟 Coding Plan"; + "Open Console" = "開啟主控台"; + "Open Dashboard" = "開啟儀表板"; + "Open Mistral Admin" = "開啟 Mistral 管理頁面"; + "Open Ollama Settings" = "開啟 Ollama 設定"; + "Open Terminal" = "開啟終端"; + "Open Usage Page" = "開啟使用量頁面"; + "Open Warp API Key Guide" = "開啟 Warp API 金鑰指南"; + "Open menu" = "開啟選單"; + "Open token file" = "開啟 token 檔案"; + "OpenAI cookies" = "OpenAI Cookie"; + "OpenAI web extras" = "OpenAI Web 附加功能"; + "Option A" = "選項 A"; + "Option B" = "選項 B"; + "Optional override if workspace lookup fails." = "找不到工作區時可選的覆寫值。"; + "Options" = "選項"; + "Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基礎路徑覆蓋自動偵測"; + "Overview" = "概覽"; + "Overview rows always follow provider order." = "概覽列一律依提供者順序排列。"; + "Overview tab providers" = "概覽標籤提供者"; + "Paste API key…" = "貼上 API 金鑰…"; + "Paste API token…" = "貼上 API token…"; + "Paste key…" = "貼上金鑰…"; + "Paste sessionKey or OAuth token…" = "貼上 sessionKey 或 OAuth token…"; + "Paste the Cookie header from a request to admin.mistral.ai. " = "貼上發往 admin.mistral.ai 請求中的 Cookie 標頭。"; + "Paste token…" = "貼上 token…"; + "Personal" = "個人"; + "Picker" = "選擇器"; + "Picker subtitle" = "選擇器副標題"; + "Placeholder" = "預留位置"; + "Plan" = "方案"; + "Play full-screen confetti when weekly usage resets." = "每週使用量重置時播放全螢幕慶祝動畫。"; + "Polls OpenAI/Claude status pages and Google Workspace for " = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace,以檢查"; + "Prevents any Keychain access while enabled." = "啟用時封鎖任何鑰匙圈存取。"; + "Primary (API key limit)" = "主要(API 金鑰限制)"; + "Primary (\\(label))" = "主要(\\(label))"; + "Primary (\\(metadata.sessionLabel))" = "主要(\\(metadata.sessionLabel))"; + "Probe logs" = "探測記錄"; + "Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; + "Provider" = "提供者"; + "Providers" = "提供者"; + "Quit CodexBar" = "結束 QuotaKit"; + "Random (default)" = "隨機(預設)"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; + "Refresh" = "重新整理"; + "Refreshing" = "正在重新整理"; + "Refresh cadence" = "重新整理頻率"; + "Remote" = "遠端"; + "Remove" = "移除"; + "Remove Codex account?" = "移除 Codex 帳號?"; + "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "要從 QuotaKit 中移除 \\(account.email) 嗎?其託管的 Codex 主目錄將被刪除。"; + "Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "要從 QuotaKit 中移除 \\(email) 嗎?其託管的 Codex 主目錄將被刪除。"; + "Remove selected account" = "移除所選帳號"; + "Replace critter bars with provider branding icons and a percentage." = "將小動物進度條替換為提供者品牌圖示和百分比。"; + "Replay selected animation" = "重播選取的動畫"; + "Requires authentication via GitHub Device Flow." = "需要透過 GitHub 裝置流程進行認證。"; + "Resets: \\(reset)" = "重置:\\(reset)"; + "Rolling five-hour limit" = "滾動式 5 小時限制"; + "Search hourly" = "每小時搜尋"; + "Secondary (\\(label))" = "次要(\\(label))"; + "Secondary (\\(metadata.weeklyLabel))" = "次要(\\(metadata.weeklyLabel))"; + "Select a provider" = "選擇提供者"; + "Select the IDE to monitor" = "選擇要監控的 IDE"; + "Session" = "工作階段"; + "Session quota notifications" = "工作階段配額通知"; + "Session tokens" = "工作階段 token"; + "Settings" = "設定"; + "Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; + "Show Debug Settings" = "顯示除錯設定"; + "Show all token accounts" = "顯示所有 token 帳號"; + "Show cost summary" = "顯示費用摘要"; + "Show credits + extra usage" = "顯示額度 + 額外使用量"; + "Show details" = "顯示詳細資訊"; + "Show most-used provider" = "顯示使用量最高的提供者"; + "Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; + "Show reset time as clock" = "以時鐘時間顯示重置時間"; + "Show usage as used" = "以已用量顯示"; + "Sign in via button below" = "透過下方按鈕登入"; + "Skip teardown between probes (debug-only)." = "探測之間跳過清理(僅限除錯)。"; + "Source" = "來源"; + "Stack token accounts in the menu (otherwise show an account switcher bar)." = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; + "Start at Login" = "登入時啟動"; + "State" = "狀態"; + "Status" = "狀態"; + "Store Claude sessionKey cookies or OAuth access tokens." = "儲存 Claude sessionKey Cookie 或 OAuth 存取 token。"; + "Store multiple Abacus AI Cookie headers." = "儲存多個 Abacus AI Cookie 標頭。"; + "Store multiple Augment Cookie headers." = "儲存多個 Augment Cookie 標頭。"; + "Store multiple Cursor Cookie headers." = "儲存多個 Cursor Cookie 標頭。"; + "Store multiple Factory Cookie headers." = "儲存多個 Factory Cookie 標頭。"; + "Store multiple MiniMax Cookie headers." = "儲存多個 MiniMax Cookie 標頭。"; + "Store multiple Mistral Cookie headers." = "儲存多個 Mistral Cookie 標頭。"; + "Store multiple Ollama Cookie headers." = "儲存多個 Ollama Cookie 標頭。"; + "Store multiple OpenCode Cookie headers." = "儲存多個 OpenCode Cookie 標頭。"; + "Store multiple OpenCode Go Cookie headers." = "儲存多個 OpenCode Go Cookie 標頭。"; + "Stored in the CodexBar config file." = "儲存在 QuotaKit 設定檔中。"; + "Stored in ~/.codexbar/config.json. " = "儲存在 ~/.quotakit/config.json 中。"; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存在 ~/.quotakit/config.json 中。可在 kimi-k2.ai 產生。"; + "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "儲存在 ~/.quotakit/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; + "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "儲存在 ~/.quotakit/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存在 ~/.quotakit/config.json 中。請貼上你的 MiniMax API 金鑰。"; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "儲存在 ~/.quotakit/config.json 中。你也可以提供 KILO_API_KEY 或"; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 使用量歷史(8 週),用於個人化進度預測。"; + "Subscription Utilization" = "訂閱使用率"; + "Surprise me" = "給我驚喜"; + "Switcher shows icons" = "切換器顯示圖示"; + "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit." = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "System" = "系統"; + "Temporarily shows the loading animation after the next refresh." = "下次重新整理後暫時顯示載入動畫。"; + "Tertiary (\\(label))" = "第三(\\(label))"; + "Tertiary (\\(tertiaryTitle))" = "第三(\\(tertiaryTitle))"; + "The default Codex account on this Mac." = "此 Mac 上的預設 Codex 帳號。"; + "Toggle" = "切換"; + "Toggle subtitle" = "切換副標題"; + "Token" = "token"; + "Trigger the menu bar menu from anywhere." = "可從任何位置開啟選單列選單。"; + "True" = "真"; + "Twitter" = "Twitter"; + "Unsupported" = "不支援"; + "Unavailable" = "無法使用"; + "Update Channel" = "更新頻道"; + "Updated" = "已更新"; + "Updates unavailable in this build." = "此建置無法使用更新功能。"; + "Usage" = "使用量"; + "Usage breakdown" = "使用量明細"; + "Usage history (30 days)" = "使用量歷史"; + "Usage source" = "使用量來源"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中國大陸端點使用 BigModel(open.bigmodel.cn)。"; + "Use a single menu bar icon with a provider switcher." = "使用單一選單列圖示並帶提供者切換器。"; + "Use international or China mainland console gateways for quota fetches." = "使用國際或中國大陸主控台閘道取得配額資料。"; + "Version" = "版本"; + "Version \\(self.versionString)" = "版本 \\(self.versionString)"; + "Version \\(version)" = "版本 \\(version)"; + "Version \\(versionString)" = "版本 \\(versionString)"; + "Vertex AI Login" = "Vertex AI 登入"; + "Wait for the current managed Codex login to finish before adding another account." = "請等待目前託管 Codex 登入完成後再新增其他帳號。"; + "Waiting for Authentication..." = "等待認證…"; + "Website" = "網站"; + "Weekly" = "每週"; + "Weekly limit confetti" = "每週重置慶祝動畫"; + "Weekly token limit" = "每週 token 限制"; + "Weekly usage" = "每週使用量"; + "Weekly usage unavailable for this account." = "此帳號無法取得每週使用量。"; + "Window: \\(window)" = "時段:\\(window)"; + "Write logs to \\(self.fileLogPath) for debugging." = "將記錄寫入 \\(self.fileLogPath) 以進行除錯。"; + "Yes" = "是"; + "not detected" = "未偵測到"; + "\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode):\\(usage)"; + "\\(name): \\(truncated)" = "\\(name):\\(truncated)"; + "\\(name): \\(updated) · 30d \\(cost)" = "\\(name):\\(updated) · 30 天 \\(cost)"; + "\\(name): fetching…\\(elapsed)" = "\\(name):取得中…\\(elapsed)"; + "\\(name): last attempt \\(when)" = "\\(name):上次嘗試 \\(when)"; + "\\(name): no data yet" = "\\(name):尚無資料"; + "\\(name): unsupported" = "\\(name):不支援"; + "all browsers" = "所有瀏覽器"; + "available again." = "恢復可用時傳送通知。"; + "built_format" = "建置於 %@"; + "copilot_complete_in_browser" = "請在瀏覽器中完成登入。"; + "copilot_device_code_copied" = "裝置代碼已複製。"; + "copilot_verify_at" = "請在 %@ 驗證"; + "copilot_window_closes_auto" = "登入完成後,此視窗會自動關閉。"; + "cost_status_error" = "%1$@:%2$@"; + "cost_status_fetching" = "%1$@:取得中… %2$@"; + "cost_status_last_attempt" = "%1$@:上次嘗試 %2$@"; + "cost_status_no_data" = "%@:尚無資料"; + "cost_status_snapshot" = "%1$@:%2$@ · %3$@ %4$@"; + "cost_status_unsupported" = "%@:不支援"; + "credits_remaining" = "額度:%@"; + "cursor_on_demand" = "隨用隨付:%@"; + "cursor_on_demand_with_limit" = "隨用隨付:%1$@ / %2$@"; + "extra_usage_format" = "額外使用量:%1$@ / %2$@"; + "jetbrains_detected_generate" = "偵測到:%@。使用一次 AI 助手以產生配額資料,然後重新整理 QuotaKit。"; + "jetbrains_detected_select" = "偵測到:%@。在設定中選擇你偏好的 IDE,然後重新整理 QuotaKit。"; + "last_fetch_failed_with_provider" = "上次取得 %@ 失敗:"; + "last_spend" = "上次支出:%@"; + "mcp_model_usage" = "%1$@:%2$@"; + "mcp_resets" = "重置:%@"; + "mcp_window" = "時段:%@"; + "metric_average" = "平均(%1$@ + %2$@)"; + "metric_primary" = "主要(%@)"; + "metric_secondary" = "次要(%@)"; + "metric_tertiary" = "第三(%@)"; + "multiple_workspaces_found" = "QuotaKit 發現 %@ 有多個工作區。請選擇要新增的工作區。"; + "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; + "overview_choose_providers" = "最多選擇 %@ 個提供者"; + "remove_account_message" = "要從 QuotaKit 中移除 %@ 嗎?其託管的 Codex 主目錄將被刪除。"; + "version_format" = "版本 %@"; + "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已設定 workspaceID,但只有 opencode、opencodego 和 deepgram 支援 workspaceID。"; + "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 許可證。"; + "section_system" = "系統"; + "section_usage" = "使用量"; + "section_automation" = "自動化"; + "language_title" = "語言"; + "language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; + "language_system" = "依照系統"; + "language_english" = "English"; + "language_spanish" = "Español"; + "language_catalan" = "Català"; + "language_chinese_simplified" = "简体中文"; + "language_chinese_traditional" = "繁體中文"; + "language_portuguese_brazilian" = "Português (Brasil)"; + +"language_swedish" = "瑞典語"; + +"language_dutch" = "Nederlands"; + +"language_french" = "法語"; + +"language_ukrainian" = "烏克蘭語"; + "start_at_login_title" = "登入時啟動"; + "start_at_login_subtitle" = "登入 Mac 時自動開啟 QuotaKit。"; + "show_cost_summary" = "顯示費用摘要"; + "show_cost_summary_subtitle" = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; + "cost_history_days_title" = "歷史時段:%d 天"; + "cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; + "refresh_cadence_title" = "重新整理頻率"; + "refresh_cadence_subtitle" = "QuotaKit 在背景輪詢提供者的頻率。"; + "manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; + "check_provider_status_title" = "檢查提供者狀態"; + "check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,並在圖示和選單中顯示服務異常資訊。"; + "session_quota_notifications_title" = "工作階段配額通知"; + "session_quota_notifications_subtitle" = "當 5 小時工作階段配額用完或恢復可用時傳送通知。"; + "quota_warning_notifications_title" = "配額提醒通知"; + "quota_warning_notifications_subtitle" = "當工作階段或每週剩餘配額達到設定門檻時提醒。"; + "quota_warnings_title" = "配額提醒"; + "quota_warning_session" = "工作階段"; + "quota_warning_session_capitalized" = "工作階段"; + "quota_warning_weekly" = "每週"; + "quota_warning_weekly_capitalized" = "每週"; + "quota_warning_notification_title" = "%1$@ %2$@配額偏低"; + "quota_warning_notification_body" = "剩餘 %1$@。已達到 %2$d%% %3$@提醒門檻。"; + "quota_warning_notification_body_with_account" = "帳號 %1$@。剩餘 %2$@。已達到 %3$d%% %4$@提醒門檻。"; + "session_depleted_notification_title" = "%@ 工作階段已用完"; + "session_depleted_notification_body" = "剩餘 0%。恢復可用時會再通知。"; + "session_restored_notification_title" = "%@ 工作階段已恢復"; + "session_restored_notification_body" = "工作階段配額已恢復可用。"; + "quota_warning_warn_at" = "提醒門檻"; + "quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; + "quota_warning_sound" = "播放通知音效"; + "quota_warning_provider_inherits" = "預設使用全域配額提醒設定,除非在此自訂時段。"; + "quota_warning_customize_thresholds" = "自訂 %@ 門檻"; + "quota_warning_enable_warnings" = "啟用 %@ 提醒"; + "quota_warning_window_warn_at" = "%@ 提醒門檻"; + "quota_warning_off" = "關閉"; + "quota_warning_inherited" = "繼承:%@"; + "quota_warning_depleted_only" = "僅用完時"; + "quota_warning_upper" = "上限"; + "quota_warning_lower" = "下限"; + "apply" = "套用"; + "quit_app" = "結束 QuotaKit"; + "tab_general" = "一般"; + "tab_providers" = "提供者"; + "tab_display" = "顯示"; + "tab_advanced" = "進階"; + "tab_about" = "關於"; + "tab_debug" = "除錯"; + "select_a_provider" = "選擇提供者"; + "cancel" = "取消"; + "last_fetch_failed" = "上次取得失敗"; + "usage_not_fetched_yet" = "尚未取得使用量"; + "managed_account_storage_unreadable" = "託管帳號儲存區無法讀取。即時帳號仍可存取,但託管新增、重新認證和移除操作已被停用,直到儲存區恢復。"; + "remove_codex_account_title" = "移除 Codex 帳號?"; + "remove" = "移除"; + "managed_login_already_running" = "託管 Codex 登入已在執行。請等待完成後再新增或重新認證其他帳號。"; + "managed_login_failed" = "託管 Codex 登入未完成。請先在終端確認 `codex --version` 可以執行。如果 macOS 封鎖了 `codex` 或將它移到垃圾桶,請移除舊的重複安裝,執行 `npm install -g --include=optional @openai/codex@latest`,然後重試。"; + "codex_login_output" = "codex login 輸出:"; + "managed_login_missing_email" = "Codex 登入已完成,但無法取得帳號電子郵件。請在確認帳號已完全登入後重試。"; + "login_success_notification_title" = "%@ 登入成功"; + "login_success_notification_body" = "你可以回到 App;認證已完成。"; + "workspace_selection_cancelled" = "QuotaKit 發現多個工作區,但未選擇任何工作區。"; + "unsafe_managed_home" = "QuotaKit 拒絕修改意外的託管主目錄路徑:%@"; + "menu_bar_metric_title" = "選單列指標"; + "menu_bar_metric_subtitle" = "選擇哪個時段驅動選單列百分比。"; + "menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; + "menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; + "menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; + "menu_bar_metric_subtitle_kimik2" = "在選單列顯示 Kimi K2 API 金鑰額度。"; + "automatic" = "自動"; + "primary_api_key_limit" = "主要(API 金鑰限制)"; + "section_menu_bar" = "選單列"; + "merge_icons_title" = "合併圖示"; + "merge_icons_subtitle" = "使用單一選單列圖示並帶提供者切換器。"; + "switcher_shows_icons_title" = "切換器顯示圖示"; + "switcher_shows_icons_subtitle" = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; + "show_most_used_provider_title" = "顯示使用量最高的提供者"; + "show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的提供者。"; + "menu_bar_shows_percent_title" = "選單列顯示百分比"; + "menu_bar_shows_percent_subtitle" = "將小動物進度條替換為提供者品牌圖示和百分比。"; + "display_mode_title" = "顯示模式"; + "display_mode_subtitle" = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; + "section_menu_content" = "選單內容"; + "show_usage_as_used_title" = "以已用量顯示"; + "show_usage_as_used_subtitle" = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; + "show_quota_warning_markers_title" = "顯示配額提醒標記"; + "show_quota_warning_markers_subtitle" = "設定配額提醒後,在使用量條上繪製門檻刻度標記。"; + "weekly_progress_work_days_title" = "每週進度工作日標記"; + "weekly_progress_work_days_subtitle" = "在每週使用量條上繪製日期邊界刻度標記。"; + "show_reset_time_as_clock_title" = "以時鐘時間顯示重置時間"; + "show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; + "show_provider_changelog_links_title" = "顯示提供者版本資訊連結"; + "show_provider_changelog_links_subtitle" = "在選單中為支援的 CLI 提供者新增發行說明連結。"; + "show_credits_extra_usage_title" = "顯示額度 + 額外使用量"; + "show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; + "show_all_token_accounts_title" = "顯示所有 token 帳號"; + "show_all_token_accounts_subtitle" = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; + "multi_account_layout_title" = "多帳號版面配置"; + "multi_account_layout_subtitle" = "選擇分段帳號切換或堆疊帳號卡片。"; + "multi_account_layout_segmented" = "分段"; + "multi_account_layout_stacked" = "堆疊"; + "overview_tab_providers_title" = "概覽標籤提供者"; + "configure" = "設定…"; + "overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; + "overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; + "overview_rows_follow_order" = "概覽列一律依提供者順序排列。"; + "overview_no_providers_selected" = "未選擇提供者"; + "section_keyboard_shortcut" = "快速鍵"; + "open_menu_shortcut_title" = "開啟選單"; + "open_menu_shortcut_subtitle" = "從任意位置觸發選單列選單。"; + "install_cli" = "安裝 CLI"; + "install_cli_subtitle" = "Symlink QuotaKitCLI to /usr/local/bin and /opt/homebrew/bin as quotakit."; + "cli_not_found" = "QuotaKitCLI not found in app bundle."; + "no_writable_bin_dirs" = "找不到可寫的 bin 目錄。"; + "show_debug_settings_title" = "顯示除錯設定"; + "show_debug_settings_subtitle" = "在「除錯」標籤中顯示疑難排解工具。"; + "surprise_me_title" = "給我驚喜"; + "surprise_me_subtitle" = "讓選單列上的 Agent 多一點變化。"; + "weekly_limit_confetti_title" = "每週重置慶祝動畫"; + "weekly_limit_confetti_subtitle" = "每週使用量重置時播放全螢幕慶祝動畫。"; + "hide_personal_info_title" = "隱藏個人資訊"; + "hide_personal_info_subtitle" = "在選單列和選單介面中隱藏電子郵件地址。"; + "show_provider_storage_usage_title" = "顯示提供者儲存使用量"; + "show_provider_storage_usage_subtitle" = "在選單中顯示本機磁碟使用量。會在背景掃描已知的提供者自有路徑。"; + "section_keychain_access" = "鑰匙圈存取"; + "keychain_access_caption" = "停用所有鑰匙圈讀寫。如果 macOS 在你按下一律允許後仍持續要求存取「Chrome/Brave/Edge Safe Storage」,可使用此選項。啟用時無法匯入瀏覽器 Cookie;請在「提供者」中手動貼上 Cookie 標頭。透過 CLI 的 Claude/Codex OAuth 仍可使用。"; + "disable_keychain_access_title" = "停用鑰匙圈存取"; + "disable_keychain_access_subtitle" = "啟用時封鎖任何鑰匙圈存取。"; + "about_tagline" = "願你的 token 永不用完,隨時掌握 Agent 限制。"; + "link_github" = "GitHub"; + "link_website" = "網站"; + "link_twitter" = "Twitter"; + "link_email" = "電子郵件"; + "check_updates_auto" = "自動檢查更新"; + "update_channel" = "更新頻道"; + "check_for_updates" = "檢查更新…"; + "updates_unavailable" = "此建置無法使用更新功能。"; + "copyright" = "© 2026 Peter Steinberger。MIT 許可證。"; + "section_logging" = "記錄"; + "enable_file_logging" = "啟用檔案記錄"; + "enable_file_logging_subtitle" = "將記錄寫入 %@ 以進行除錯。"; + "verbosity_title" = "詳細程度"; + "verbosity_subtitle" = "控制記錄詳細程度。"; + "open_log_file" = "開啟記錄檔"; + "force_animation_next_refresh" = "下次重新整理時強制動畫"; + "force_animation_next_refresh_subtitle" = "下次重新整理後暫時顯示載入動畫。"; + "section_loading_animations" = "載入動畫"; + "loading_animations_caption" = "選擇一個模式並在選單列中重播。「隨機」保持現有行為。"; + "animation_random_default" = "隨機(預設)"; + "replay_selected_animation" = "重播選取的動畫"; + "blink_now" = "立即閃爍"; + "section_probe_logs" = "探測記錄"; + "probe_logs_caption" = "取得最新的探測輸出以進行除錯;複製會保留完整文字。"; + "fetch_log" = "取得記錄"; + "copy" = "複製"; + "save_to_file" = "儲存到檔案"; + "load_parse_dump" = "載入解析 dump"; + "rerun_provider_autodetect" = "重新執行提供者自動偵測"; + "loading" = "載入中…"; + "no_log_yet_fetch" = "尚無記錄。取得後載入。"; + "section_fetch_strategy" = "取得策略嘗試"; + "fetch_strategy_caption" = "提供者上次取得流程中的決策和錯誤。"; + "section_openai_cookies" = "OpenAI Cookie"; + "openai_cookies_caption" = "上次 OpenAI Cookie 嘗試中的 Cookie 匯入和 WebKit 抓取記錄。"; + "no_log_yet" = "尚無記錄。請在「提供者」→「Codex」中更新 OpenAI Cookie 以執行匯入。"; + "section_caches" = "快取"; + "caches_caption" = "清除快取的費用掃描結果或瀏覽器 Cookie 快取。"; + "clear_cookie_cache" = "清除 Cookie 快取"; + "clear_cost_cache" = "清除費用快取"; + "section_notifications" = "通知"; + "notifications_caption" = "觸發 5 小時工作階段時段的測試通知(用完/恢復)。"; + "post_depleted" = "傳送用完通知"; + "post_restored" = "傳送恢復通知"; + "section_cli_sessions" = "CLI 工作階段"; + "cli_sessions_caption" = "探測後保持 Codex/Claude CLI 工作階段存活。預設在擷取資料後結束。"; + "keep_cli_sessions_alive" = "保持 CLI 工作階段存活"; + "keep_cli_sessions_alive_subtitle" = "探測之間跳過關閉流程(僅限除錯)。"; + "reset_cli_sessions" = "重置 CLI 工作階段"; + "section_error_simulation" = "錯誤模擬"; + "error_simulation_caption" = "將模擬錯誤訊息注入選單卡片以進行版面配置測試。"; + "set_menu_error" = "設定選單錯誤"; + "clear_menu_error" = "清除選單錯誤"; + "set_cost_error" = "設定費用錯誤"; + "clear_cost_error" = "清除費用錯誤"; + "section_cli_paths" = "CLI 路徑"; + "cli_paths_caption" = "解析到的 Codex 二進位檔案和 PATH 層;啟動時擷取登入 PATH(短逾時)。"; + "codex_binary" = "Codex 二進位檔案"; + "claude_binary" = "Claude 二進位檔案"; + "effective_path" = "有效 PATH"; + "unavailable" = "無法使用"; + "login_shell_path" = "登入 shell PATH(啟動時擷取)"; + "cleared" = "已清除。"; + "no_fetch_attempts" = "尚無取得嘗試。"; + "metric_pref_automatic" = "自動"; + "metric_pref_primary" = "主要"; + "metric_pref_secondary" = "次要"; + "metric_pref_tertiary" = "第三"; + "metric_pref_extra_usage" = "額外使用量"; + "metric_pref_average" = "平均"; + "display_mode_percent" = "百分比"; + "display_mode_pace" = "進度"; + "display_mode_both" = "兩者"; + "display_mode_percent_desc" = "顯示剩餘/已使用百分比(例如 45%)"; + "display_mode_pace_desc" = "顯示進度指示器(例如 +5%)"; + "display_mode_both_desc" = "同時顯示百分比和進度(例如 45% · +5%)"; + "status_operational" = "運作正常"; + "status_partial_outage" = "部分服務中斷"; + "status_major_outage" = "重大服務中斷"; + "status_critical_issue" = "嚴重問題"; + "status_maintenance" = "維護中"; + "status_unknown" = "狀態未知"; + "refresh_manual" = "手動"; + "refresh_1min" = "1 分鐘"; + "refresh_2min" = "2 分鐘"; + "refresh_5min" = "5 分鐘"; + "refresh_15min" = "15 分鐘"; + "refresh_30min" = "30 分鐘"; + "not_found" = "找不到"; + "QuotaKit can't show its menu bar icon" = "QuotaKit 無法顯示選單列圖示"; + "Dismiss" = "關閉"; + "Open Menu Bar Settings" = "開啟選單列設定"; + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. QuotaKit is running, but macOS may be hiding its icon. Open Menu Bar settings and turn QuotaKit on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。QuotaKit 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 QuotaKit。"; + "cost_header_estimated" = "費用(估算)"; + "cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; + "copilot_device_code" = "裝置代碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; + "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; + "vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; + /* Popup panels */ "No usage configured." = "尚未設定使用量。"; + "Quota" = "配額"; + "tokens" = "token"; + "requests" = "請求"; + "Latest" = "最新"; + "Monthly" = "每月"; + "Sonnet" = "Sonnet"; + "Overages" = "超額"; + "Activity" = "活動"; + "Copied" = "已複製"; + "Copy error" = "複製錯誤"; + "Copy path" = "複製路徑"; + "Extra usage spent" = "額外使用量支出"; + "Credits remaining" = "剩餘額度"; + "Using CLI fallback" = "使用 CLI 備援"; + "Balance updates in near-real time (up to 5 min lag)" = "餘額接近即時更新(最多延遲 5 分鐘)"; + "Daily billing data finalizes at 07:00 UTC" = "每日帳單資料會在 UTC 07:00 完成結算"; + "%@ of %@ credits left" = "剩餘 %@ / %@ 點額度"; + "%@ of %@ bonus credits left" = "剩餘 %@ / %@ 點獎勵額度"; + "%@ / %@ (%@ remaining)" = "%@ / %@(剩餘 %@)"; + "%@/%@ left" = "剩餘 %@ / %@"; + "Gemini Flash" = "Gemini Flash"; + "Regenerates %@" = "%@後恢復"; + "used after next regen" = "下次恢復後已使用"; + "after next regen" = "下次恢復後"; + "Near full" = "接近全滿"; + "Full in ~1 regen" = "約 1 次恢復後全滿"; + "Full in ~%.0f regens" = "約 %.0f 次恢復後全滿"; + "Overage usage" = "超額使用量"; + "Overage cost" = "超額費用"; + "credits" = "額度"; + "Zen balance" = "Zen 餘額"; + "API spend" = "API 支出"; + "Extra usage" = "額外使用量"; + "Quota usage" = "配額使用量"; + "%.0f%% used" = "已使用 %.0f%%"; + "Usage history (today)" = "使用量記錄(今天)"; + "Usage history (%d days)" = "使用量記錄(%d 天)"; + "%d percent remaining" = "剩餘 %d%%"; + "Unknown" = "未知"; + "stale data" = "資料過舊"; + "No credits history data available." = "尚無可用的額度記錄資料。"; + "Credits history chart" = "額度記錄圖表"; + "%d days of credits data" = "%d 天額度資料"; + "Usage breakdown chart" = "使用量明細圖表"; + "%d days of usage data across %d services" = "%d 天使用量資料,涵蓋 %d 個服務"; + "Cost history chart" = "費用記錄圖表"; + "%d days of cost data" = "%d 天費用資料"; + "Plan utilization chart" = "方案使用率圖表"; + "%d utilization samples" = "%d 筆使用率樣本"; + "Hourly Usage" = "每小時使用量"; + "Usage remaining" = "剩餘使用量"; + "Usage used" = "已使用使用量"; + "API key verified. Ollama does not expose Cloud quota limits through the API." = "API 金鑰已驗證。Ollama 不會透過 API 暴露 Cloud 配額限制。"; + "Last 30 days: %@ tokens" = "近 30 天:%@ token"; + "7d spend" = "7 天支出"; + "30d spend" = "30 天支出"; + "Cache read" = "快取讀取"; + "Claude Admin API 30 day spend trend" = "Claude Admin API 30 天支出趨勢"; + "OpenRouter API key spend trend" = "OpenRouter API 金鑰支出趨勢"; + "z.ai hourly token trend" = "z.ai 每小時 token 趨勢"; + "MiniMax 30 day token usage trend" = "MiniMax 30 天 token 使用量趨勢"; + "Today cash" = "今日現金"; + "DeepSeek 30 day token usage trend" = "DeepSeek 30 天 token 使用量趨勢"; + "cache-hit input" = "快取命中輸入"; + "cache-miss input" = "快取未命中輸入"; + "output" = "輸出"; + "Requests" = "請求"; + "Reported by OpenAI Admin API organization usage." = "由 OpenAI Admin API 組織使用量回報。"; + "Reported by Mistral billing usage." = "由 Mistral 帳單使用量回報。"; + "Today" = "今天"; + "Today tokens" = "今日 token"; + "30d cost" = "近 30 天費用"; + "30d tokens" = "近 30 天 token"; + "Latest tokens" = "最新 token"; + "Top model" = "主要模型"; + "Storage" = "儲存空間"; + "Add Account..." = "新增帳號…"; + "Usage Dashboard" = "使用量儀表板"; + "Status Page" = "狀態頁"; + "Settings..." = "設定…"; + "About CodexBar" = "關於 QuotaKit"; + "Quit" = "結束"; + "Last %d day" = "近 %d 天"; + "Last %d days" = "近 %d 天"; + "%@ tokens" = "%@ token"; + "Latest billing day" = "最新帳單日"; + "Latest billing day (%@)" = "最新帳單日(%@)"; + "This week" = "本週"; + "Week" = "週"; + "Month" = "月"; + "Models" = "模型數"; + "24h tokens" = "24 小時 token"; + "Latest hour" = "最新小時"; + "Peak hour" = "尖峰小時"; + "Top method" = "主要方法"; + "30d cash" = "30 天現金"; + "30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; + "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; + "Rate limit: %d / %@" = "速率限制: %d / %@"; + "Key remaining" = "金鑰剩餘額度"; + "No limit set for the API key" = "此 API 金鑰未設定限制"; + "API key limit unavailable right now" = "目前無法取得 API 金鑰限制"; + "This month: %@ tokens" = "本月:%@ token"; + "Switch Account..." = "切換帳號…"; + "Update ready, restart now?" = "更新已就緒,要立即重新啟動嗎?"; + "Daily" = "每日"; + "Hourly Tokens" = "每小時 token"; + "No data" = "無資料"; + "No usage breakdown data available." = "尚無可用的使用量明細資料。"; + "Today: %@ · %@ tokens" = "今天:%@ · %@ token"; + "Today: %@" = "今天:%@"; + "Today: %@ tokens" = "今天:%@ token"; + "Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; + "Last 30 days: %@" = "近 30 天:%@"; + "Est. total (30d): %@" = "估計總計(30 天):%@"; + "Est. total (%@): %@" = "估計總計(%@):%@"; + "Hover a bar for details" = "停留在長條上查看詳細資料"; + "%@: %@ · %@ tokens" = "%@:%@ · %@ token"; + "No providers selected for Overview." = "概覽尚未選擇提供者。"; + "No overview data available." = "概覽尚無可用資料。"; + /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ 正在等待權限"; + "%@ requests" = "%@ 個請求"; + "%@: %@ credits" = "%@:%@ 額度"; + "30d requests" = "近 30 天請求"; + "4 days" = "4 天"; + "5 days" = "5 天"; + "7 days" = "7 天"; + "API key verifies Ollama Cloud access; cookies still expose quota limits." = "API 金鑰會驗證 Ollama Cloud 存取;Cookie 仍會提供配額限制。"; + "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS 存取金鑰 ID。也可以用 AWS_ACCESS_KEY_ID 設定。"; + "AWS region. Can also be set with AWS_REGION." = "AWS 區域。也可以用 AWS_REGION 設定。"; + "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS 秘密存取金鑰。也可以用 AWS_SECRET_ACCESS_KEY 設定。"; + "Access key ID" = "存取金鑰 ID"; + "Add Account" = "新增帳號"; + "Adding Account…" = "正在新增帳號…"; + "Antigravity login failed" = "Antigravity 登入失敗"; + "Antigravity login timed out" = "Antigravity 登入逾時"; + "Auth source" = "認證來源"; + "Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的 Chrome 瀏覽器 Cookie。"; + "Automatic imports Windsurf session data from Chromium browser localStorage." = "自動從 Chromium 瀏覽器 localStorage 匯入 Windsurf 工作階段資料。"; + "Automatic imports browser cookies from Bailian." = "自動匯入 Bailian 的瀏覽器 Cookie。"; + "Automatically imports browser cookies." = "自動匯入瀏覽器 Cookie。"; + "Automatically imports browser session cookies." = "自動匯入瀏覽器工作階段 Cookie。"; + "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI 部署名稱。也支援 AZURE_OPENAI_DEPLOYMENT_NAME。"; + "Azure OpenAI key" = "Azure OpenAI 金鑰"; + "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 資源端點。也支援 AZURE_OPENAI_ENDPOINT。"; + "Base URL" = "Base URL"; + "Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 實例的 Base URL。"; + "Browser cookies" = "瀏覽器 Cookie"; + "Cap end" = "上限終點"; + "Cap start" = "上限起點"; + "Capacity End" = "容量終點"; + "Capacity Start" = "容量起點"; + "Changelog" = "變更記錄"; + "Choose the Moonshot/Kimi API host for international or China mainland accounts." = "選擇國際或中國大陸帳號使用的 Moonshot/Kimi API 主機。"; + "CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit 無法取代僅使用 API 金鑰登入設定的系統帳號。"; + "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit 找不到該帳號已儲存的認證。請重新認證後再試。"; + "CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit 無法讀取受管理帳號儲存區。請先修復儲存區,再新增其他帳號。"; + "CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit 無法讀取該帳號已儲存的認證。請重新認證後再試。"; + "CodexBar could not read the current system account on this Mac." = "QuotaKit 無法讀取此 Mac 上目前的系統帳號。"; + "CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit 無法取代此 Mac 上的目前 Codex 認證。"; + "CodexBar could not safely preserve the current system account before switching." = "QuotaKit 無法在切換前安全保留目前的系統帳號。"; + "CodexBar could not save the current system account before switching." = "QuotaKit 無法在切換前儲存目前的系統帳號。"; + "CodexBar could not update managed account storage." = "QuotaKit 無法更新受管理帳號儲存區。"; + "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit 發現另一個受管理帳號已使用目前的系統帳號。請先解決重複帳號,再進行切換。"; + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求「%@」,以解密瀏覽器 Cookie 並認證你的帳號。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求 Claude Code OAuth token,以取得你的 Claude 使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Amp Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Augment Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Claude Cookie 標頭,以取得 Claude 網頁使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Cursor Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Factory Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 GitHub Copilot token,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Kimi K2 API 金鑰,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Kimi 認證 token,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 MiniMax API token,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 MiniMax Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 OpenAI Cookie 標頭,以取得 Codex 儀表板額外資料。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 OpenCode Cookie 標頭,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 Synthetic API 金鑰,以取得使用量。按一下「確定」繼續。"; + "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit 將向 macOS 鑰匙圈要求你的 z.ai API token,以取得使用量。按一下「確定」繼續。"; + "Could not open Cursor login in your browser." = "無法在瀏覽器中開啟 Cursor 登入。"; + "Could not open browser for Antigravity" = "無法為 Antigravity 開啟瀏覽器"; + "Credits used" = "已用額度"; + "Day" = "日期"; + "Deployment" = "部署"; + "Drag to reorder" = "拖曳以重新排序"; + "Endpoint" = "端點"; + "Enterprise host" = "Enterprise 主機"; + "Extra usage balance: %@" = "額外使用量餘額:%@"; + "Keychain Access Required" = "需要鑰匙圈存取權"; + "Kiro menu bar value" = "Kiro 選單列數值"; + "Label" = "標籤"; + "No organizations loaded. Click Refresh after setting your API key." = "尚未載入組織。設定 API 金鑰後按一下「重新整理」。"; + "No output captured." = "未擷取到輸出。"; + "No system account" = "沒有系統帳號"; + "Oasis-Token" = "Oasis-Token"; + "Open Augment (Log Out & Back In)" = "開啟 Augment(登出後重新登入)"; + "Open Codebuff Dashboard" = "開啟 Codebuff 儀表板"; + "Open Command Code Settings" = "開啟 Command Code 設定"; + "Open Crof dashboard" = "開啟 Crof 儀表板"; + "Open Manus" = "開啟 Manus"; + "Open MiMo Balance" = "開啟 MiMo 餘額"; + "Open Moonshot Console" = "開啟 Moonshot 主控台"; + "Open Ollama API Keys" = "開啟 Ollama API 金鑰"; + "Open StepFun Platform" = "開啟 StepFun 平台"; + "Open T3 Chat Settings" = "開啟 T3 Chat 設定"; + "Open Volcengine Ark Console" = "開啟 Volcengine Ark 主控台"; + "Open legacy provider docs" = "開啟舊版提供者文件"; + "Open projects" = "開啟專案"; + "Open this URL manually to continue login:\n\n%@" = "手動開啟此 URL 以繼續登入:\n\n%@"; + "Optional organization ID for accounts linked to multiple Anthropic organizations." = "適用於連結多個 Anthropic 組織的帳號,可選填組織 ID。"; + "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "選填。套用到已設定的 Admin API 金鑰;選取的 token 帳號不會繼承 OPENAI_PROJECT_ID。"; + "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "選填。輸入你的 GitHub Enterprise 主機,例如 octocorp.ghe.com。留空則使用 github.com。"; + "Optional. Leave blank to discover and aggregate projects visible to the API key." = "選填。留空會探索並彙總 API 金鑰可見的專案。"; + "Org ID (optional)" = "組織 ID(選填)"; + "Organizations" = "組織"; + "Password" = "密碼"; + "%@ authentication is disabled." = "%@ 認證已停用。"; + "%@ cookies are disabled." = "%@ Cookie 已停用。"; + "%@ web API access is disabled." = "%@ Web API 存取已停用。"; + "Disable %@ dashboard cookie usage." = "停用 %@ 儀表板 Cookie 用法。"; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "進階設定中已停用鑰匙圈存取,因此無法匯入瀏覽器 Cookie。"; + "Manually paste an %@ from a browser session." = "從瀏覽器工作階段中手動貼上 %@。"; + "Paste a Cookie header captured from %@." = "貼上從 %@ 擷取的 Cookie 標頭。"; + "Paste a Cookie header from %@." = "貼上來自 %@ 的 Cookie 標頭。"; + "Paste a Cookie header or cURL capture from %@." = "貼上來自 %@ 的 Cookie 標頭或 cURL 擷取內容。"; + "Paste a Cookie header or full cURL capture from %@." = "貼上來自 %@ 的 Cookie 標頭或完整 cURL 擷取內容。"; + "Paste a Cookie or Authorization header from %@." = "貼上來自 %@ 的 Cookie 或 Authorization 標頭。"; + "Paste a full cookie header or the %@ value." = "貼上完整 Cookie 標頭或 %@ 值。"; + "Paste a Cookie header or full cURL capture from T3 Chat settings." = "貼上 T3 Chat 設定中的 Cookie 標頭或完整 cURL 擷取內容。"; + "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "貼上發往 admin.mistral.ai 請求中的 Cookie 標頭。必須包含 ory_session_* Cookie。"; + "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "貼上 platform.stepfun.com 已登入瀏覽器工作階段中的 Oasis-Token。"; + "Paste the %@ JSON bundle from %@." = "貼上來自 %2$@ 的 %1$@ JSON 組合。"; + "Paste the %@ value or a full Cookie header." = "貼上 %@ 值或完整 Cookie 標頭。"; + "Personal account" = "個人帳號"; + "Project ID" = "專案 ID"; + "Re-auth" = "重新認證"; + "Re-authenticating…" = "正在重新認證…"; + "Refresh Session" = "重新整理工作階段"; + "Refresh organizations" = "重新整理組織"; + "Region" = "區域"; + "Reload" = "重新載入"; + "Reorder" = "重新排序"; + "Secret access key" = "秘密存取金鑰"; + "Series" = "序列"; + "Service" = "服務"; + "Show or hide Kiro credits, percent, or both next to the menu bar icon." = "在選單列圖示旁顯示或隱藏 Kiro 額度、百分比,或兩者都顯示。"; + "Show usage for organizations you belong to. Personal account is always shown." = "顯示你所屬組織的使用量。個人帳號一律顯示。"; + "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "請在瀏覽器中登入 cursor.com,然後在 QuotaKit 重新整理 Cursor。"; + "Simulated error text" = "模擬錯誤文字"; + "StepFun platform account (phone number or email)." = "StepFun 平台帳號(電話號碼或電子郵件)。"; + "Stored in ~/.codexbar/config.json." = "儲存在 ~/.quotakit/config.json 中。"; + "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "儲存在 ~/.quotakit/config.json 中。也支援 AZURE_OPENAI_API_KEY。"; + "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "儲存在 ~/.quotakit/config.json 中。官方 Kimi API 請使用 Moonshot / Kimi API。"; + "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "儲存在 ~/.quotakit/config.json 中。請從 Volcengine Ark 主控台取得 API 金鑰。"; + "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "儲存在 ~/.quotakit/config.json 中。請從 Ollama 設定取得金鑰。"; + "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "儲存在 ~/.quotakit/config.json 中。請從 console.deepgram.com 取得金鑰。"; + "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "儲存在 ~/.quotakit/config.json 中。請從 elevenlabs.io/app/settings/api-keys 取得金鑰。"; + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "儲存在 ~/.quotakit/config.json 中。請從 openrouter.ai/settings/keys 取得金鑰,並在該處設定金鑰支出上限以啟用 API 金鑰配額追蹤。"; + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "儲存在 ~/.quotakit/config.json 中。在 Warp 中開啟 Settings > Platform > API Keys,然後建立金鑰。"; + "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "儲存在 ~/.quotakit/config.json 中。指標需要 Groq Enterprise Prometheus 存取權。"; + "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "儲存在 ~/.quotakit/config.json 中。優先使用 OPENAI_ADMIN_KEY;OPENAI_API_KEY 仍可使用。"; + "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "儲存在 ~/.quotakit/config.json 中。需要 Anthropic Admin API 金鑰。"; + "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "儲存在 ~/.quotakit/config.json 中。用於 /v1/quota-stats。"; + "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "儲存在 ~/.quotakit/config.json 中。你也可以提供 CODEBUFF_API_KEY,或讓 QuotaKit 讀取 `codebuff login` 建立的 ~/.config/manicode/credentials.json。"; + "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "儲存在 ~/.quotakit/config.json 中。你也可以提供 CROF_API_KEY。"; + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "儲存在 ~/.quotakit/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; + "T3 Chat cookie" = "T3 Chat Cookie"; + "That account is no longer available in CodexBar. Refresh the account list and try again." = "該帳號已無法在 QuotaKit 中使用。請重新整理帳號列表後再試。"; + "The browser login did not complete in time. Try Antigravity login again." = "瀏覽器登入未在時限內完成。請再次嘗試 Antigravity 登入。"; + "Timed out waiting for Cursor login. %@" = "等待 Cursor 登入逾時。%@"; + "Timed out waiting for Cursor login. %@ Last error: %@" = "等待 Cursor 登入逾時。%@ 最後錯誤:%@"; + "Today requests" = "今日請求"; + "Total (30d): %@ credits" = "總計(30 天):%@ 額度"; + "Username" = "使用者名稱"; + "Uses username + password to login and obtain an Oasis-Token automatically." = "使用使用者名稱與密碼登入,並自動取得 Oasis-Token。"; + "Uses username + password to login and obtain an %@ automatically." = "使用使用者名稱與密碼登入,並自動取得 %@。"; + "Utilization End" = "使用率終點"; + "Utilization Start" = "使用率起點"; + "Verbosity" = "詳細程度"; + "Windsurf session JSON bundle" = "Windsurf 工作階段 JSON 組合"; + "Workspace ID" = "工作區 ID"; + "Your StepFun platform password. Used to login and obtain a session token." = "你的 StepFun 平台密碼。用於登入並取得工作階段 token。"; + "claude /login exited with status %d." = "claude /login 以狀態 %d 結束。"; + "codex login exited with status %d." = "codex login 以狀態 %d 結束。"; + "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\n或貼上 Abacus AI 儀表板的 cURL 擷取內容"; + "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或貼上 __Secure-next-auth.session-token 值"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或貼上 kimi-auth token 值"; + "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只貼上 session_id 值"; + "Clear" = "清除"; + "No matching providers" = "沒有相符的提供者"; + "Search providers" = "搜尋提供者"; + + +"language_vietnamese" = "越南語"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index c9f38e5cb..9dca338d8 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -473,9 +473,9 @@ extension SettingsStore { } var mergedMenuLastSelectedWasOverview: Bool { - get { self.defaultsState.mergedMenuLastSelectedWasOverview } + get { self.mergedMenuLastSelectedWasOverviewStorage } set { - self.defaultsState.mergedMenuLastSelectedWasOverview = newValue + self.mergedMenuLastSelectedWasOverviewStorage = newValue self.userDefaults.set(newValue, forKey: "mergedMenuLastSelectedWasOverview") } } @@ -489,9 +489,9 @@ extension SettingsStore { } private var selectedMenuProviderRaw: String? { - get { self.defaultsState.selectedMenuProviderRaw } + get { self.selectedMenuProviderRawStorage } set { - self.defaultsState.selectedMenuProviderRaw = newValue + self.selectedMenuProviderRawStorage = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "selectedMenuProvider") } else { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 3776c1bba..f81c06fb4 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -63,7 +63,6 @@ extension SettingsStore { _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons - _ = self.mergedMenuLastSelectedWasOverview _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken _ = self.syntheticAPIToken @@ -88,7 +87,6 @@ extension SettingsStore { _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern - _ = self.selectedMenuProvider _ = self.configRevision return 0 } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index e04b084ae..6d4c87ceb 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -140,6 +140,8 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false + @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] @@ -237,7 +239,10 @@ final class SettingsStore { self.configStore = configStore self.config = config self.configLoading = true - self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + let defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + self.defaultsState = defaultsState + self.mergedMenuLastSelectedWasOverviewStorage = defaultsState.mergedMenuLastSelectedWasOverview + self.selectedMenuProviderRawStorage = defaultsState.selectedMenuProviderRaw self.updateProviderState(config: config) self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 6a3c7b419..cd93de3d8 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -328,7 +328,16 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } @objc func quit() { - NSApp.terminate(nil) + let openMenus = Array(self.openMenus.values) + for menu in openMenus { + menu.cancelTrackingWithoutAnimation() + } + + self.scheduleQuitTermination { [weak self] in + guard let self else { return } + self.prepareForAppShutdown() + self.terminateApplicationForQuit() + } } @objc func copyError(_ sender: NSMenuItem) { diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index eda85c7a8..2ced7ae42 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -2,6 +2,30 @@ import AppKit import CodexBarCore import QuartzCore +private struct MergedIconRenderContext { + let button: NSStatusBarButton + let provider: UsageProvider + let snapshot: UsageSnapshot? + let style: IconStyle + let statusIndicator: ProviderStatusIndicator + let warningFlash: Bool + let needsAnimation: Bool +} + +private struct MergedIconRenderMetrics { + var primary: Double? + var weekly: Double? + var credits: Double? + var stale: Bool + var morphProgress: Double? +} + +private struct MergedIconMotion { + let blink: CGFloat + let wiggle: CGFloat + let tilt: CGFloat +} + extension StatusItemController { private static let loadingPercentEpsilon = 0.0001 private static let appStatusIconSize = NSSize(width: 18, height: 18) @@ -233,7 +257,7 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { // swiftlint:disable:this function_body_length + func applyIcon(phase: Double?) -> Bool { guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle @@ -251,38 +275,31 @@ extension StatusItemController { style: style, showUsed: showUsed) } - var primary = resolved?.primary - var weekly = resolved?.secondary + var metrics = MergedIconRenderMetrics( + primary: resolved?.primary, + weekly: resolved?.secondary, + credits: self.menuBarCreditsRemainingForIcon( + provider: primaryProvider, + snapshot: snapshot), + stale: self.store.isStale(provider: primaryProvider), + morphProgress: nil) if showUsed, primaryProvider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. - weekly = 0 + metrics.weekly = 0 } if showUsed, primaryProvider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, - weekly == 0 + metrics.weekly == 0 { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. - weekly = Self.loadingPercentEpsilon + metrics.weekly = Self.loadingPercentEpsilon } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: primaryProvider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil - var stale = self.store.isStale(provider: primaryProvider) - var morphProgress: Double? let needsAnimation = self.needsMenuBarIconAnimation() if let phase, needsAnimation { @@ -291,145 +308,193 @@ extension StatusItemController { pattern = .cylon } if pattern == .unbraid { - morphProgress = pattern.value(phase: phase) / 100 - primary = nil - weekly = nil - credits = nil - stale = false + metrics.morphProgress = pattern.value(phase: phase) / 100 + metrics.primary = nil + metrics.weekly = nil + metrics.credits = nil + metrics.stale = false } else { // Keep loading animation layout stable: IconRenderer uses `weeklyRemaining > 0` to switch layouts, // so hitting an exact 0 would flip between "normal" and "weekly exhausted" rendering. - primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) - weekly = max( + metrics.primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) + metrics.weekly = max( pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) - credits = nil - stale = false + metrics.credits = nil + metrics.stale = false } } - let blink: CGFloat = style == .combined ? 0 : self.blinkAmount(for: primaryProvider) - let wiggle: CGFloat = style == .combined ? 0 : self.wiggleAmount(for: primaryProvider) - let tilt: CGFloat = - style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 - - let statusIndicator: ProviderStatusIndicator = { - for provider in self.store.enabledProvidersForDisplay() { - let indicator = self.store.statusIndicator(for: provider) - if indicator.hasIssue { return indicator } - } - return .none - }() + let motion = MergedIconMotion( + blink: style == .combined ? 0 : self.blinkAmount(for: primaryProvider), + wiggle: style == .combined ? 0 : self.wiggleAmount(for: primaryProvider), + tilt: style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28) + let context = MergedIconRenderContext( + button: button, + provider: primaryProvider, + snapshot: snapshot, + style: style, + statusIndicator: self.mergedStatusIndicator(), + warningFlash: warningFlash, + needsAnimation: needsAnimation) let shouldUseAppIconFallback = - primary == nil - && weekly == nil - && credits == nil - && morphProgress == nil + metrics.primary == nil + && metrics.weekly == nil + && metrics.credits == nil + && metrics.morphProgress == nil && (!needsAnimation || phase == nil) - && !statusIndicator.hasIssue - if showBrandPercent, - let brand = ProviderBrandIcon.image(for: primaryProvider) + && !context.statusIndicator.hasIssue + if showBrandPercent, let rendered = self.applyBrandPercentIcon( + context: context, + metrics: metrics) { - let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) - let signature = [ - "mode=brandPercent", - "provider=\(primaryProvider.rawValue)", - "style=\(String(describing: style))", - "primary=\(Self.iconSignatureValue(primary))", - "weekly=\(Self.iconSignatureValue(weekly))", - "credits=\(Self.iconSignatureValue(credits))", - "stale=\(stale ? "1" : "0")", - "status=\(statusIndicator.rawValue)", - "text=\(displayText ?? "nil")", - "warningFlash=\(warningFlash ? "1" : "0")", - "anim=\(needsAnimation ? "1" : "0")", - ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { - // AppKit can lose button title/image-position state independently of the cached render signature. - // Keep the cheap title path self-healing even when the icon image itself can be skipped. - self.setButtonTitle(displayText, for: button) - self.noteIconPerfRender(skipped: true) - return true - } - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) - self.setButtonTitle(displayText, for: button) - self.noteIconPerfRender(skipped: false) - return false + return rendered } self.setButtonTitle(nil, for: button) - if shouldUseAppIconFallback, - let appIcon = Self.appStatusIconForMenuBar() + if shouldUseAppIconFallback, let rendered = self.applyAppFallbackIcon( + context: context, + metrics: metrics) { - let signature = [ - "mode=appIcon", - "provider=\(primaryProvider.rawValue)", - "style=\(String(describing: style))", - "stale=\(stale ? "1" : "0")", - "warningFlash=\(warningFlash ? "1" : "0")", - "anim=\(needsAnimation ? "1" : "0")", - ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { - self.noteIconPerfRender(skipped: true) - return true - } - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: appIcon) : appIcon, for: button) - self.noteIconPerfRender(skipped: false) - return false + return rendered } - if let morphProgress { + if self.applyMergedQuotaIcon(context: context, metrics: metrics, motion: motion) { + return true + } + self.noteIconPerfRender(skipped: false) + return false + } + + private func mergedStatusIndicator() -> ProviderStatusIndicator { + for provider in self.store.enabledProvidersForDisplay() { + let indicator = self.store.statusIndicator(for: provider) + if indicator.hasIssue { return indicator } + } + return .none + } + + private func applyBrandPercentIcon( + context: MergedIconRenderContext, + metrics: MergedIconRenderMetrics) + -> Bool? + { + guard let brand = ProviderBrandIcon.image(for: context.provider) else { return nil } + let displayText = self.menuBarDisplayText( + for: context.provider, + snapshot: context.snapshot) + let signature = [ + "mode=brandPercent", + "provider=\(context.provider.rawValue)", + "style=\(String(describing: context.style))", + "primary=\(Self.iconSignatureValue(metrics.primary))", + "weekly=\(Self.iconSignatureValue(metrics.weekly))", + "credits=\(Self.iconSignatureValue(metrics.credits))", + "stale=\(metrics.stale ? "1" : "0")", + "status=\(context.statusIndicator.rawValue)", + "text=\(displayText ?? "nil")", + "warningFlash=\(context.warningFlash ? "1" : "0")", + "anim=\(context.needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + // AppKit can lose button title/image-position state independently of the cached render signature. + self.setButtonTitle(displayText, for: context.button) + self.noteIconPerfRender(skipped: true) + return true + } + self.setButtonImage( + context.warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, + for: context.button) + self.setButtonTitle(displayText, for: context.button) + self.noteIconPerfRender(skipped: false) + return false + } + + private func applyAppFallbackIcon( + context: MergedIconRenderContext, + metrics: MergedIconRenderMetrics) + -> Bool? + { + guard let appIcon = Self.appStatusIconForMenuBar() else { return nil } + let signature = [ + "mode=appIcon", + "provider=\(context.provider.rawValue)", + "style=\(String(describing: context.style))", + "stale=\(metrics.stale ? "1" : "0")", + "warningFlash=\(context.warningFlash ? "1" : "0")", + "anim=\(context.needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + self.noteIconPerfRender(skipped: true) + return true + } + self.setButtonImage( + context.warningFlash ? Self.quotaWarningFlashImage(base: appIcon) : appIcon, + for: context.button) + self.noteIconPerfRender(skipped: false) + return false + } + + private func applyMergedQuotaIcon( + context: MergedIconRenderContext, + metrics: MergedIconRenderMetrics, + motion: MergedIconMotion) + -> Bool + { + if let morphProgress = metrics.morphProgress { let signature = [ "mode=morph", - "provider=\(primaryProvider.rawValue)", - "style=\(String(describing: style))", + "provider=\(context.provider.rawValue)", + "style=\(String(describing: context.style))", "morph=\(Self.iconSignatureValue(morphProgress))", - "status=\(statusIndicator.rawValue)", - "warningFlash=\(warningFlash ? "1" : "0")", - "anim=\(needsAnimation ? "1" : "0")", - ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { - self.noteIconPerfRender(skipped: true) - return true - } - let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) - } else { - let signature = [ - "mode=icon", - "provider=\(primaryProvider.rawValue)", - "style=\(String(describing: style))", - "primary=\(Self.iconSignatureValue(primary))", - "weekly=\(Self.iconSignatureValue(weekly))", - "credits=\(Self.iconSignatureValue(credits))", - "stale=\(stale ? "1" : "0")", - "status=\(statusIndicator.rawValue)", - "blink=\(Self.iconSignatureValue(Double(blink)))", - "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", - "tilt=\(Self.iconSignatureValue(Double(tilt)))", - "warningFlash=\(warningFlash ? "1" : "0")", - "anim=\(needsAnimation ? "1" : "0")", + "status=\(context.statusIndicator.rawValue)", + "warningFlash=\(context.warningFlash ? "1" : "0")", + "anim=\(context.needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { self.noteIconPerfRender(skipped: true) return true } - let image = IconRenderer.makeIcon( - primaryRemaining: primary, - weeklyRemaining: weekly, - creditsRemaining: credits, - stale: stale, - style: style, - blink: blink, - wiggle: wiggle, - tilt: tilt, - statusIndicator: statusIndicator) + let image = IconRenderer.makeMorphIcon( + progress: morphProgress, + style: context.style) self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + context.warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + for: context.button) + return false } - self.noteIconPerfRender(skipped: false) + + let signature = [ + "mode=icon", + "provider=\(context.provider.rawValue)", + "style=\(String(describing: context.style))", + "primary=\(Self.iconSignatureValue(metrics.primary))", + "weekly=\(Self.iconSignatureValue(metrics.weekly))", + "credits=\(Self.iconSignatureValue(metrics.credits))", + "stale=\(metrics.stale ? "1" : "0")", + "status=\(context.statusIndicator.rawValue)", + "blink=\(Self.iconSignatureValue(Double(motion.blink)))", + "wiggle=\(Self.iconSignatureValue(Double(motion.wiggle)))", + "tilt=\(Self.iconSignatureValue(Double(motion.tilt)))", + "warningFlash=\(context.warningFlash ? "1" : "0")", + "anim=\(context.needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + self.noteIconPerfRender(skipped: true) + return true + } + let image = IconRenderer.makeIcon( + primaryRemaining: metrics.primary, + weeklyRemaining: metrics.weekly, + creditsRemaining: metrics.credits, + stale: metrics.stale, + style: context.style, + blink: motion.blink, + wiggle: motion.wiggle, + tilt: motion.tilt, + statusIndicator: context.statusIndicator) + self.setButtonImage( + context.warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + for: context.button) return false } @@ -514,17 +579,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: provider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -615,11 +670,25 @@ extension StatusItemController { return false } - private static func iconSignatureValue(_ value: Double?) -> String { + static func iconSignatureValue(_ value: Double?) -> String { guard let value else { return "nil" } return String(format: "%.3f", value) } + func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? { + // Derive the menu-bar credits fallback from the same Codex projection path the rendered + // icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a + // hand-rolled rate-window predicate. The projection is pure value composition over + // already-loaded snapshot/credits state (no IO), so this stays cheap while keeping the + // icon render, this signature input, and the menu-bar fallback semantics on a single + // source of truth — a hand-rolled approximation can silently drift from the projection + // as its fallback logic evolves. + guard provider == .codex else { return nil } + return self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + } + func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { guard let until = self.quotaWarningFlashUntil[provider] else { return false } if until > now { return true } @@ -750,12 +819,15 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = - codexProjection?.rateWindow(for: .weekly) - ?? snapshot?.secondary + let paceWindow: RateWindow? = if let codexProjection { + codexProjection.rateWindow(for: .weekly) + } else if provider == .abacus { // Abacus has no secondary window; pace is computed on primary monthly credits - ?? (provider == .abacus ? snapshot?.primary : nil) - pace = weeklyWindow.flatMap { window in + snapshot?.primary + } else { + percentWindow + } + pace = paceWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } } @@ -956,7 +1028,7 @@ extension StatusItemController { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } - private func primaryProviderForUnifiedIcon() -> UsageProvider { + func primaryProviderForUnifiedIcon() -> UsageProvider { // When "show highest usage" is enabled, auto-select the provider closest to rate limit. if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons, @@ -1028,7 +1100,7 @@ extension StatusItemController { self.tickBlink(now: now) } - private func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { + func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { if self.store.debugForceAnimation { return true } let isMerged = mergeIcons ?? self.shouldMergeIcons diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index c26f9aa85..0b3a64c29 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -32,7 +32,9 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(cardIndex)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: account.id, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) @@ -48,7 +50,9 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) } menu.addItem(.separator()) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1f080abe6..b4c7bfad2 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -186,9 +186,9 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -213,9 +213,9 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -252,9 +252,9 @@ extension StatusItemController { windowLabel: tokenSnapshot.historyLabel, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -288,9 +288,9 @@ extension StatusItemController { let maxHeight = self.storageBreakdownMenuMaxHeight() let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) let hosting = MenuHostingView(rootView: view) - let controller = NSHostingController(rootView: view) - let size = controller.sizeThatFits(in: CGSize(width: width, height: maxHeight)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let item = NSMenuItem() item.view = hosting @@ -328,9 +328,9 @@ extension StatusItemController { let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift new file mode 100644 index 000000000..a205bbc84 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + let providerSignatures: String + let primaryProvider: UsageProvider? + if mergeIcons { + let primary = self.primaryProviderForUnifiedIcon() + primaryProvider = primary + providerSignatures = [ + self.providerStoreIconObservationSignature(for: primary, showBrandPercent: showBrandPercent), + "mergedStatus=\(self.mergedIconStatusIndicator().rawValue)", + ].joined(separator: "||") + } else { + primaryProvider = nil + providerSignatures = UsageProvider.allCases + .filter { self.isVisible($0) } + .map { self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) } + .joined(separator: "||") + } + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "primary=\(primaryProvider?.rawValue ?? "nil")", + "iconStyle=\(self.store.iconStyle.rawValue)", + "showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "needsAnimation=\(self.needsMenuBarIconAnimation() ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let style = self.store.style(for: provider) + let resolved = snapshot.map { + IconRemainingResolver.resolvedPercents( + snapshot: $0, + style: style, + showUsed: self.settings.usageBarsShowUsed) + } + let creditsRemaining = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + + return [ + provider.rawValue, + "style=\(style.rawValue)", + "primary=\(Self.iconSignatureValue(resolved?.primary))", + "weekly=\(Self.iconSignatureValue(resolved?.secondary))", + "credits=\(Self.iconSignatureValue(creditsRemaining))", + "stale=\(self.store.isStale(provider: provider) ? "1" : "0")", + "status=\(self.store.statusIndicator(for: provider).rawValue)", + "anim=\(self.shouldAnimate(provider: provider) ? "1" : "0")", + "refreshing=\(self.store.refreshingProviders.contains(provider) ? "1" : "0")", + "text=\(displayText ?? "nil")", + ].joined(separator: "|") + } + + private func mergedIconStatusIndicator() -> ProviderStatusIndicator { + for provider in self.store.enabledProvidersForDisplay() { + let indicator = self.store.statusIndicator(for: provider) + if indicator.hasIssue { return indicator } + } + return .none + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 225331c5b..db4b49abc 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -9,7 +9,7 @@ import SwiftUI extension StatusItemController { static let menuCardBaseWidth: CGFloat = 310 private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - private static let overviewRowIdentifierPrefix = "overviewRow-" + static let overviewRowIdentifierPrefix = "overviewRow-" private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) #if DEBUG private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) @@ -58,13 +58,6 @@ extension StatusItemController { return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) } - private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { - let measuringMenu = NSMenu() - measuringMenu.autoenablesItems = false - self.addActionableSections(sections, to: measuringMenu, width: baseWidth) - return ceil(measuringMenu.size.width) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -85,6 +78,11 @@ extension StatusItemController { self.cancelDeferredMenuInteractionRefreshTask() self.cancelClosedMenuRebuild(menu) + // Track whether this is the root menu opening (no menus were open). Only the root open rebuilds + // all content from current data, so the readiness baseline is re-anchored only here — re-anchoring + // on a nested submenu open could mask a pending refresh for the already-open parent menu. + let menuTrackingWasIdle = self.openMenus.isEmpty + if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) @@ -95,6 +93,9 @@ extension StatusItemController { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu + if menuTrackingWasIdle { + self.resyncMenuAdjunctReadinessBaseline() + } } // Removed redundant async refresh - single pass is sufficient after initial layout return @@ -122,12 +123,25 @@ extension StatusItemController { if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } + if self.settings.providerStorageFootprintsEnabled { + self.store.refreshStorageFootprintsForOverview() + } + let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu + // Only re-anchor when the opened menu actually shows current data. During an in-flight provider + // refresh `refreshMenuForOpenIfNeeded` can preserve stale content; resyncing the baseline to + // live store data in that case would mask the refresh-completion update (#1351). + if menuTrackingWasIdle, !self.menuNeedsRefresh(menu) { + self.resyncMenuAdjunctReadinessBaselineForRootOpen( + menu, + provider: provider, + menuWasFreshBeforeOpen: menuWasFreshBeforeOpen) + } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) @@ -150,6 +164,7 @@ extension StatusItemController { } self.cancelClosedMenuRebuild(menu) + self.clearMergedSwitcherContentCache(for: menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() @@ -163,10 +178,9 @@ extension StatusItemController { menu === self.fallbackMenu || self.providerMenus.values.contains { $0 === menu } if !isPersistentMenu { - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + self.clearTransientMenuTrackingState(key) } else if self.menuNeedsRefresh(menu) { - self.rebuildClosedMenuIfNeeded(menu) + self.handleClosedPersistentMenuNeedingRefresh(menu) } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() @@ -292,7 +306,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -324,7 +339,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -379,6 +395,7 @@ extension StatusItemController { let codexAccountDisplay: CodexAccountMenuDisplay? let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor } /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. @@ -392,12 +409,30 @@ extension StatusItemController { switcherView.updateSelection(context.switcherSelection) switcherView.updateQuotaIndicators() } + if let outgoingSelection = self.lastMergedMenuContentSelection, + outgoingSelection != context.switcherSelection + { + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: outgoingSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth) + } while menu.items.count > contentStartIndex { menu.removeItem(at: contentStartIndex) } let enabledProviders = self.store.enabledProvidersForDisplay() self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + if self.addCachedMergedSwitcherContent( + for: context.switcherSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -409,16 +444,6 @@ extension StatusItemController { width: context.menuWidth) self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - let descriptor = MenuDescriptor.build( - provider: context.provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: context.switcherSelection != .overview) - let menuContext = MenuCardContext( currentProvider: context.currentProvider, selectedProvider: context.provider, @@ -427,7 +452,13 @@ extension StatusItemController { tokenAccountDisplay: context.tokenAccountDisplay, openAIContext: context.openAIContext) self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) + self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) } } @@ -436,7 +467,9 @@ extension StatusItemController { context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + self.lastMergedMenuContentSelection = nil menu.removeAllItems() + let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: context.enabledProviders, @@ -450,6 +483,17 @@ extension StatusItemController { context.switcherSelection, context.includesOverview) } + if self.shouldMergeIcons, + context.enabledProviders.count > 1, + self.addCachedMergedSwitcherContent( + for: contentSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -470,8 +514,14 @@ extension StatusItemController { self.addPrimaryMenuContent( to: menu, context: menuContext, - switcherSelection: context.switcherSelection ?? .provider(context.currentProvider)) + switcherSelection: contentSelection) self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: contentSelection, + contentStartIndex: self.providerSwitcherContentStartIndex(in: menu), + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) } } @@ -557,14 +607,20 @@ extension StatusItemController { OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), id: identifier, width: menuWidth, + heightCacheScope: row.provider.rawValue, + heightCacheFingerprint: row.model.heightFingerprint( + section: "overview", + additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } self.selectOverviewProvider(row.provider, menu: menu) }) - // Keep menu item action wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) + if submenu == nil { + // Keep plain rows wired for keyboard activation and accessibility action paths. + item.target = self + item.action = #selector(self.selectOverviewProvider(_:)) + } menu.addItem(item) if index < rows.count - 1 { menu.addItem(.separator()) @@ -639,7 +695,9 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { menu.addItem(.separator()) } @@ -659,14 +717,18 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(index)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", + heightCacheFingerprint: model.heightFingerprint(section: "card"))) if index < cards.count - 1 { menu.addItem(.separator()) } @@ -707,7 +769,6 @@ extension StatusItemController { context: MenuCardContext, switcherSelection: ProviderSwitcherSelection) { - self.store.refreshStorageFootprintsForOverview() if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( @@ -744,7 +805,7 @@ extension StatusItemController { } } - private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { + func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } @@ -971,24 +1032,27 @@ extension StatusItemController { }, onSelect: { [weak self, weak menu] selection in guard let self, let menu else { return } - let provider: UsageProvider? - switch selection { - case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - provider = self.resolvedMenuProvider() - case let .provider(selectedProvider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = selectedProvider - provider = selectedProvider - } - switch selection { - case .overview: - self.lastMenuProvider = provider ?? .codex - case let .provider(provider): - self.lastMenuProvider = provider + var provider: UsageProvider? + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + provider = self.resolvedMenuProvider() + case let .provider(selectedProvider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = selectedProvider + provider = selectedProvider + } + switch selection { + case .overview: + self.lastMenuProvider = provider ?? .codex + case let .provider(provider): + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(deferRendering: true) } - self.lastMergedSwitcherSelection = selection - self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) }) let item = NSMenuItem() item.view = view @@ -1169,86 +1233,6 @@ extension StatusItemController { return enabledProviders } - private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.renderedMenuWidth(for: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) - } - } - - func makeMenuCardItem( - _ view: some View, - id: String, - width: CGFloat, - submenu: NSMenu? = nil, - submenuIndicatorAlignment: Alignment = .topTrailing, - submenuIndicatorTopPadding: CGFloat = 8, - onClick: (() -> Void)? = nil) -> NSMenuItem - { - if !Self.menuCardRenderingEnabled { - let item = NSMenuItem() - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) - { - view - } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) - // Set frame with target width immediately - let height = self.menuCardHeight(for: hosting, width: width) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - let item = NSMenuItem() - item.view = hosting - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { - let basePadding: CGFloat = 6 - let descenderSafety: CGFloat = 1 - - // Fast path: use protocol-based measurement when available (avoids layout passes) - if let measured = view as? MenuCardMeasuring { - return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) - } - - // Set frame with target width before measuring. - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - - // Use fittingSize directly - SwiftUI hosting views respect the frame width for wrapping - let fitted = view.fittingSize - - return max(1, ceil(fitted.height + basePadding + descenderSafety)) - } - private func addMenuCardSections( to menu: NSMenu, model: UsageMenuCardView.Model, @@ -1280,13 +1264,20 @@ extension StatusItemController { usageView, id: "menuCardUsage", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "usage"), submenu: usageSubmenu)) } else { let headerView = UsageMenuCardHeaderSectionView( model: model, showDivider: false, width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) + menu.addItem(self.makeMenuCardItem( + headerView, + id: "menuCardHeader", + width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "header"))) } if hasStorage || hasCredits || hasExtraUsage || hasCost { @@ -1314,6 +1305,8 @@ extension StatusItemController { creditsView, id: "menuCardCredits", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "credits"), submenu: creditsSubmenu)) if webItems.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) @@ -1333,6 +1326,8 @@ extension StatusItemController { extraUsageView, id: "menuCardExtraUsage", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "extraUsage"), submenu: extraUsageSubmenu)) } if hasCost { @@ -1358,6 +1353,8 @@ extension StatusItemController { storageView, id: "menuCardStorage", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: UsageMenuCardView.Model.heightFingerprintField("storage", storageText), submenu: storageSubmenu)) return true } @@ -1604,41 +1601,23 @@ extension StatusItemController { for item in menu.items { guard let view = item.view else { continue } - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - let height = view.fittingSize.height + let height = self.hostedSubviewFittingHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } - @objc private func menuCardNoOp(_ sender: NSMenuItem) { - _ = sender - } - - @objc private func selectOverviewProvider(_ sender: NSMenuItem) { - guard let represented = sender.representedObject as? String, - represented.hasPrefix(Self.overviewRowIdentifierPrefix) - else { - return - } - let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) - guard let provider = UsageProvider(rawValue: rawProvider), - let menu = sender.menu - else { - return - } - - self.selectOverviewProvider(provider, menu: menu) + /// Measures the natural height of a hosted submenu view at the given width using the live + /// view that will actually be displayed. Hosted chart items used to spin up a second, + /// throwaway `NSHostingController` purely to size the chart even though every build path + /// immediately re-measures the live view via `fittingSize`; that extra SwiftUI hierarchy was + /// pure overhead on a popup-menu hot path, so callers now size the displayed view directly. + func hostedSubviewFittingHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return view.fittingSize.height } - private func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { - if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } - self.settings.mergedMenuLastSelectedWasOverview = false - self.lastMergedSwitcherSelection = nil - self.selectedMenuProvider = provider - self.lastMenuProvider = provider - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) + @objc func menuCardNoOp(_ sender: NSMenuItem) { + _ = sender } } diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift new file mode 100644 index 000000000..3cf08ad87 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -0,0 +1,54 @@ +import AppKit + +extension StatusItemController { + struct MenuCardHeightCacheKey: Hashable { + let id: String + let scope: String + let width: Int + let textScale: Int + let fingerprint: String + } + + /// Measured card height also depends on the resolved font sizes, which the menu cards + /// derive from semantic text styles (`.body`, `.footnote`, …). Those scale with the + /// macOS system text-size / Dynamic Type setting, which is neither part of the content + /// fingerprint nor invalidated on rebuild. Fold the current resolved scale into the key + /// so a runtime text-size change forces a fresh measurement instead of returning a + /// height measured at the old scale (clipped / over-tall cards). + static func menuCardHeightTextScaleToken() -> Int { + Int((NSFont.preferredFont(forTextStyle: .body).pointSize * 100).rounded()) + } + + func cachedMenuCardHeight( + for id: String, + scope: String, + width: CGFloat, + fingerprint: String? = nil, + measure: () -> CGFloat) -> CGFloat + { + let key = MenuCardHeightCacheKey( + id: id, + scope: scope, + width: Int((width * 100).rounded()), + textScale: Self.menuCardHeightTextScaleToken(), + fingerprint: fingerprint ?? "version:\(self.menuContentVersion)") + if let cached = self.menuCardHeightCache[key] { + return cached + } + let height = measure() + if self.menuCardHeightCache.count > 256 { + self.menuCardHeightCache.removeAll(keepingCapacity: true) + } + self.menuCardHeightCache[key] = height + return height + } + + func pruneVersionScopedMenuCardHeightCache() { + let currentVersionFingerprint = "version:\(self.menuContentVersion)" + for key in self.menuCardHeightCache.keys + where key.fingerprint.hasPrefix("version:") && key.fingerprint != currentVersionFingerprint + { + self.menuCardHeightCache.removeValue(forKey: key) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift new file mode 100644 index 000000000..768b0fcf8 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -0,0 +1,90 @@ +import AppKit +import SwiftUI + +extension StatusItemController { + func refreshMenuCardHeights(in menu: NSMenu) { + let cardItems = menu.items.filter { item in + (item.representedObject as? String)?.hasPrefix("menuCard") == true + } + for item in cardItems { + guard let view = item.view else { continue } + let width = self.renderedMenuWidth(for: menu) + let id = item.representedObject as? String ?? "menuCard" + let scope = self.menuProvider(for: menu)?.rawValue ?? id + let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { + self.menuCardHeight(for: view, width: width) + } + view.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: height)) + } + } + + func makeMenuCardItem( + _ view: some View, + id: String, + width: CGFloat, + heightCacheScope: String? = nil, + heightCacheFingerprint: String? = nil, + submenu: NSMenu? = nil, + submenuIndicatorAlignment: Alignment = .topTrailing, + submenuIndicatorTopPadding: CGFloat = 8, + onClick: (() -> Void)? = nil) -> NSMenuItem + { + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) + let height = self.cachedMenuCardHeight( + for: id, + scope: heightCacheScope ?? id, + width: width, + fingerprint: heightCacheFingerprint) + { + self.menuCardHeight(for: hosting, width: width) + } + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + + let item = NSMenuItem() + item.view = hosting + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + if let measured = view as? MenuCardMeasuring { + return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) + } + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 1518d1223..7c17a6559 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -85,6 +85,10 @@ extension StatusItemController { self.store.weeklyPace(provider: target, window: window, now: now) } } + let fallbackAccount = accountOverride + ?? (metadata.usesAccountFallback + ? self.store.accountInfo(for: target) + : AccountInfo(email: nil, plan: nil)) let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, @@ -96,7 +100,7 @@ extension StatusItemController { dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, - account: accountOverride ?? self.store.accountInfo(for: target), + account: fallbackAccount, isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), lastError: errorOverride ?? codexProjection?.userFacingErrors.usage diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index e90f03de7..abad4320d 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -25,6 +25,7 @@ extension StatusItemController { self.lastSwitcherProviders = providers self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed self.lastMergedSwitcherSelection = selection + self.lastMergedMenuContentSelection = selection self.lastSwitcherIncludesOverview = includesOverview self.lastMenuLocalizationSignature = self.menuLocalizationSignature() } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index f1097a50b..527e35439 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -127,9 +127,9 @@ final class MenuCardItemHostingView: NSHostingView, Menu } func measuredHeight(width: CGFloat) -> CGFloat { - let controller = NSHostingController(rootView: self.rootView) - let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - return measured.height + self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) + self.layoutSubtreeIfNeeded() + return self.fittingSize.height } func setHighlighted(_ highlighted: Bool) { diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4742e30ec..f9aab228d 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,12 +3,100 @@ import CodexBarCore import QuartzCore extension StatusItemController { + private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 0 + func didMenuAdjunctReadinessChange() -> Bool { let signature = self.menuAdjunctReadinessSignature() - defer { self.lastMenuAdjunctReadinessSignature = signature } + defer { self.recordMenuAdjunctReadinessBaseline(signature) } return signature != self.lastMenuAdjunctReadinessSignature } + /// Resyncs the readiness baseline to the data the menu was just built from. + /// + /// Because the baseline is no longer recomputed on every store change while all menus are closed, + /// it can drift from the live store state. When a root menu opens and is actually rebuilt (or is + /// already fresh for the current `menuContentVersion`), the baseline must be re-anchored here; + /// otherwise a later open-menu store change that happens to revert to the stale baseline value would + /// be treated as "unchanged" and skip a needed rebuild, leaving the visible menu showing the older + /// content. Callers must **not** invoke this when `refreshMenuForOpenIfNeeded` preserved stale + /// content during an in-flight refresh — that would record live store data while the visible menu + /// still shows older content and mask the refresh-completion update. + func resyncMenuAdjunctReadinessBaseline() { + self.recordMenuAdjunctReadinessBaseline(self.menuAdjunctReadinessSignature()) + } + + /// Resyncs a root-menu baseline after open and handles the narrow race where a store change + /// has updated live data but its deferred observation task has not invalidated menus yet. + /// + /// If a previously fresh menu sees new live data before the observer version tick, invalidate all + /// menus first and rebuild only the opened menu. The matching observer can then skip the expensive + /// readiness comparison while still invalidating menu-observed state that is not in the signature. + func resyncMenuAdjunctReadinessBaselineForRootOpen( + _ menu: NSMenu, + provider: UsageProvider?, + menuWasFreshBeforeOpen: Bool) + { + let signature = self.menuAdjunctReadinessSignature() + let menuKey = ObjectIdentifier(menu) + let menuRenderedCurrentSignature = self.menuVersions[menuKey] == self.menuContentVersion && + self.menuReadinessSignatures[menuKey] == signature + guard signature != self.lastMenuAdjunctReadinessSignature else { + guard menuWasFreshBeforeOpen, !menuRenderedCurrentSignature else { + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion + return + } + guard !self.isMenuDataRefreshInFlight else { return } + self.invalidateMenus() + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + self.recordMenuAdjunctReadinessBaseline(signature) + return + } + + if menuWasFreshBeforeOpen { + if self.isMenuDataRefreshInFlight, !menuRenderedCurrentSignature { + return + } + if menuRenderedCurrentSignature { + self.recordMenuAdjunctReadinessBaseline(signature) + return + } + self.invalidateMenus() + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + } + self.recordMenuAdjunctReadinessBaseline(signature) + } + + private func recordMenuAdjunctReadinessBaseline(_ signature: String) { + self.lastMenuAdjunctReadinessSignature = signature + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion + } + + private func rememberRootOpenHandledMenuObservation(signature: String) { + self.rootOpenHandledMenuObservationSignature = signature + Task { @MainActor [weak self] in + await Task.yield() + if self?.rootOpenHandledMenuObservationSignature == signature { + self?.rootOpenHandledMenuObservationSignature = nil + } + } + } + + func consumeRootOpenHandledMenuObservationIfNeeded() -> Bool { + guard let handledSignature = self.rootOpenHandledMenuObservationSignature else { return false } + let signature = self.menuAdjunctReadinessSignature() + guard signature == handledSignature else { + self.rootOpenHandledMenuObservationSignature = nil + return false + } + self.rootOpenHandledMenuObservationSignature = nil + self.recordMenuAdjunctReadinessBaseline(signature) + return true + } + func menuAdjunctReadinessSignature() -> String { let dashboard = self.store.openAIDashboard let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( @@ -87,8 +175,12 @@ extension StatusItemController { return self.formatDoubleForSignature(value) } + /// The signature is only ever compared for equality against the previous signature, so it does + /// not need a human-readable decimal form. `String(format: "%.8f", …)` is a surprisingly hot + /// cost here because it runs for every daily/service value across every enabled provider on each + /// store mutation. The raw bit pattern is both exact (no rounding collisions) and far cheaper. private static func formatDoubleForSignature(_ value: Double) -> String { - String(format: "%.8f", value) + String(value.bitPattern, radix: 16) } func performMenuMutationWithoutAnimation(_ updates: () -> Void) { @@ -101,10 +193,17 @@ extension StatusItemController { func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { self.providerSwitcherUpdateToken &+= 1 let updateToken = self.providerSwitcherUpdateToken + #if DEBUG + let debounceNanoseconds = self._test_providerSwitcherMenuRebuildDebounceNanoseconds ?? ( + self._test_openMenuRebuildObserver == nil ? Self.providerSwitcherMenuRebuildDebounceNanoseconds : 0) + #else + let debounceNanoseconds = Self.providerSwitcherMenuRebuildDebounceNanoseconds + #endif self.scheduleOpenMenuRebuildIfStillVisible( menu, provider: provider, - closeHostedSubviewMenusBeforeRebuild: true) + closeHostedSubviewMenusBeforeRebuild: true, + debounceNanoseconds: debounceNanoseconds) { [weak self] in guard let self else { return false } return self.providerSwitcherUpdateToken == updateToken @@ -115,6 +214,7 @@ extension StatusItemController { _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { let key = ObjectIdentifier(menu) @@ -137,6 +237,9 @@ extension StatusItemController { #else await Task.yield() #endif + if debounceNanoseconds > 0 { + try? await Task.sleep(nanoseconds: debounceNanoseconds) + } guard !Task.isCancelled else { return } guard self.openMenuRebuildTokens[key] == rebuildToken else { return } defer { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 4922f9cc6..2df363724 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,7 +32,12 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 - if !allowStaleContentDuringDataRefresh { + let preservesMergedSwitcherContentCaches = self.preservesMergedSwitcherContentCachesDuringInvalidation + if !preservesMergedSwitcherContentCaches { + self.clearMergedSwitcherContentCaches() + } + self.pruneVersionScopedMenuCardHeightCache() + if !allowStaleContentDuringDataRefresh, !preservesMergedSwitcherContentCaches { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } guard self.isMenuRefreshEnabled else { return } @@ -51,7 +56,33 @@ extension StatusItemController { guard self.isMenuRefreshEnabled else { return } guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } - for menu in self.attachedMenusForClosedPreparation() { + let menus = self.attachedMenusForClosedPreparation() + let requiredClosedPreparationVersion: Int? + if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { + guard self.latestRequiredMenuRebuildVersion > 0 else { return } + let hasRequiredClosedMenu = menus.contains { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + } + guard hasRequiredClosedMenu else { return } + requiredClosedPreparationVersion = self.latestRequiredMenuRebuildVersion + } else { + requiredClosedPreparationVersion = nil + } + for menu in menus { + let key = ObjectIdentifier(menu) + guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } + if let requiredClosedPreparationVersion { + guard (self.menuVersions[key] ?? -1) < requiredClosedPreparationVersion else { continue } + } + // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu + // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display + // anyway. In Merge Icons mode it is the only attached menu, so this just relocates that + // work into a background freeze on every store tick (#1274). Defer it until next open. + if menu === self.mergedMenu { + self.closedMenusDeferredUntilNextOpen.insert(key) + continue + } self.rebuildClosedMenuIfNeeded(menu) } } @@ -61,7 +92,25 @@ extension StatusItemController { UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } } + func clearTransientMenuTrackingState(_ key: ObjectIdentifier) { + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + self.menuReadinessSignatures.removeValue(forKey: key) + self.closedMenusDeferredUntilNextOpen.remove(key) + } + + func handleClosedPersistentMenuNeedingRefresh(_ menu: NSMenu) { + if menu === self.mergedMenu { + // Closing the merged menu is on the user's dismiss path. Leave stale content attached and let + // menuWillOpen rebuild it, while other closed-menu invalidations can still prepare in the background. + self.closedMenusDeferredUntilNextOpen.insert(ObjectIdentifier(menu)) + } else { + self.rebuildClosedMenuIfNeeded(menu) + } + } + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) guard self.menuNeedsRefresh(menu) else { return } if self.canPreserveStaleMenuContentDuringRefresh(menu) { #if DEBUG @@ -183,6 +232,7 @@ extension StatusItemController { func markMenuFresh(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion + self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature() } func hasOpenHostedSubviewMenu() -> Bool { diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift new file mode 100644 index 000000000..a376958e1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -0,0 +1,98 @@ +import AppKit + +extension StatusItemController { + private static let measuredStandardMenuWidthCacheLimit = 96 + + func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { + let cacheKey = self.measuredStandardMenuWidthCacheKey(for: sections, baseWidth: baseWidth) + if let cached = self.measuredStandardMenuWidthCache[cacheKey] { + return cached + } + + let measuringMenu = NSMenu() + measuringMenu.autoenablesItems = false + self.addActionableSections(sections, to: measuringMenu, width: baseWidth) + let measured = ceil(measuringMenu.size.width) + if self.measuredStandardMenuWidthCache.count >= Self.measuredStandardMenuWidthCacheLimit { + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: true) + } + self.measuredStandardMenuWidthCache[cacheKey] = measured + return measured + } + + private func measuredStandardMenuWidthCacheKey( + for sections: [MenuDescriptor.Section], + baseWidth: CGFloat) -> String + { + var parts = [ + "base=\(Int((baseWidth * 100).rounded()))", + "font=\(Self.menuCardHeightTextScaleToken())", + self.menuLocalizationSignature(), + ] + for section in sections { + parts.append("[") + for entry in section.entries { + parts.append(self.measuredStandardMenuWidthCacheToken(for: entry)) + } + parts.append("]") + } + return parts.joined(separator: "\u{1f}") + } + + private func measuredStandardMenuWidthCacheToken(for entry: MenuDescriptor.Entry) -> String { + switch entry { + case let .text(text, style): + "text:\(style):\(text)" + case let .action(title, action): + "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" + case let .submenu(title, systemImageName, submenuItems): + "submenu:\(title):\(systemImageName ?? ""):" + submenuItems.map { item in + [ + item.title, + item.isEnabled ? "1" : "0", + item.isChecked ? "1" : "0", + item.action.map(self.measuredStandardMenuWidthCacheToken(for:)) ?? "", + ].joined(separator: ":") + }.joined(separator: ",") + case .divider: + "divider" + } + } + + private func measuredStandardMenuWidthCacheToken(for action: MenuDescriptor.MenuAction) -> String { + switch action { + case .installUpdate: + "installUpdate" + case .refresh: + "refresh" + case .refreshAugmentSession: + "refreshAugmentSession" + case .dashboard: + "dashboard" + case .statusPage: + "statusPage" + case .changelog: + "changelog" + case .addCodexAccount: + "addCodexAccount:\(self.codexAddAccountSubtitle() ?? "")" + case let .requestCodexSystemPromotion(id): + "requestCodexSystemPromotion:\(id)" + case let .addProviderAccount(provider): + "addProviderAccount:\(provider.rawValue)" + case let .switchAccount(provider): + "switchAccount:\(provider.rawValue):\(self.switchAccountSubtitle(for: provider) ?? "")" + case let .openTerminal(command): + "openTerminal:\(command)" + case let .loginToProvider(url): + "loginToProvider:\(url)" + case .settings: + "settings" + case .about: + "about" + case .quit: + "quit" + case let .copyError(message): + "copyError:\(message)" + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift new file mode 100644 index 000000000..b31c447c9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -0,0 +1,96 @@ +import AppKit + +struct CachedMergedSwitcherMenuContent { + let requiredMenuContentVersion: Int + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let localizationSignature: String + let items: [NSMenuItem] + + func matches( + requiredMenuContentVersion: Int, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?, + localizationSignature: String) + -> Bool + { + self.requiredMenuContentVersion >= requiredMenuContentVersion && + abs(self.menuWidth - menuWidth) <= 0.5 && + self.codexAccountDisplay == codexAccountDisplay && + self.tokenAccountDisplay == tokenAccountDisplay && + self.localizationSignature == localizationSignature + } +} + +extension StatusItemController { + func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) { + let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation + self.preservesMergedSwitcherContentCachesDuringInvalidation = true + defer { self.preservesMergedSwitcherContentCachesDuringInvalidation = previous } + body() + } + + func clearMergedSwitcherContentCaches() { + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: true) + } + + func clearMergedSwitcherContentCache(for menu: NSMenu) { + self.mergedSwitcherContentCaches.removeValue(forKey: ObjectIdentifier(menu)) + } + + func cacheVisibleMergedSwitcherContent( + in menu: NSMenu, + selection: ProviderSwitcherSelection, + contentStartIndex: Int, + menuWidth: CGFloat, + contentVersion: Int? = nil) + { + guard self.shouldMergeIcons else { return } + guard menu.items.first?.view is ProviderSwitcherView else { return } + guard contentStartIndex < menu.items.count else { return } + let items = Array(menu.items[contentStartIndex...]) + guard !items.isEmpty else { return } + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: contentVersion ?? + self.menuVersions[ObjectIdentifier(menu)] ?? + self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: self.lastCodexAccountMenuDisplay, + tokenAccountDisplay: self.lastTokenAccountMenuDisplay, + localizationSignature: self.lastMenuLocalizationSignature, + items: items) + self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry + } + + func addCachedMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + to menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false } + guard entry.matches( + requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return false + } + + self.lastCodexAccountMenuDisplay = codexAccountDisplay + self.lastTokenAccountMenuDisplay = tokenAccountDisplay + for item in entry.items { + menu.addItem(item) + } + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index bfefcf78d..4d3444edd 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -27,4 +27,33 @@ extension StatusItemController { } return self.makeStorageBreakdownSubmenu(provider: provider, width: width) } + + @objc func selectOverviewProvider(_ sender: NSMenuItem) { + guard let represented = sender.representedObject as? String, + represented.hasPrefix(Self.overviewRowIdentifierPrefix) + else { + return + } + let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawProvider), + let menu = sender.menu + else { + return + } + + self.selectOverviewProvider(provider, menu: menu) + } + + func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { + if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } + self.preservingMergedSwitcherContentCachesDuringInvalidation { + self.settings.mergedMenuLastSelectedWasOverview = false + self.lastMergedSwitcherSelection = .provider(provider) + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } } diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 8a1091938..6611fdd52 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,6 +1,39 @@ import CodexBarCore extension StatusItemController { + func refreshProviderSelectionDependentUI( + refreshOpenMenus: Bool = false, + deferRendering: Bool = false) + { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.invalidateMenus(refreshOpenMenus: refreshOpenMenus) + if deferRendering { + self.scheduleProviderSelectionUIRefresh() + return + } + self.refreshProviderSelectionRendering() + } + + private func scheduleProviderSelectionUIRefresh() { + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = Task { @MainActor [weak self] in + await Task.yield() + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled, let self else { return } + self.refreshProviderSelectionRendering() + self.providerSelectionUIRefreshTask = nil + } + } + + private func refreshProviderSelectionRendering() { + self.updateAnimationState() + self.updateBlinkingState() + let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil + self.applyIcon(phase: phase) + } + func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() @@ -26,18 +59,19 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] - switch selection { - case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex - case let .provider(provider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = provider - self.lastMenuProvider = provider + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true) } - self.lastMergedSwitcherSelection = selection - self.invalidateMenus(refreshOpenMenus: true) - self.applyIcon(phase: nil) } private func navigationResolvedProvider(enabledProviders: [UsageProvider]) -> UsageProvider? { diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index d227a9ae6..034d1699e 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -1,14 +1,18 @@ import AppKit import CodexBarCore +struct PendingProviderSwitcherRebuild { + let menu: NSMenu + let provider: UsageProvider? +} + +@MainActor final class ProviderSwitcherShortcutEventMonitor { - private let events: NSEvent.EventTypeMask private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { - self.events = events self.callback = callback self.observer = CFRunLoopObserverCreateWithHandler( @@ -36,7 +40,9 @@ final class ProviderSwitcherShortcutEventMonitor { } deinit { - self.stop() + MainActor.assumeIsolated { + self.stop() + } } func start() { @@ -68,7 +74,9 @@ extension StatusItemController { } self.removeProviderSwitcherShortcutMonitor() - let monitor = ProviderSwitcherShortcutEventMonitor(events: [.keyDown]) { [weak self, weak menu] event in + let monitor = ProviderSwitcherShortcutEventMonitor( + events: [.keyDown, .leftMouseDown, .leftMouseUp]) + { [weak self, weak menu] event in guard let self, let menu, self.openMenus[ObjectIdentifier(menu)] != nil, @@ -77,7 +85,7 @@ extension StatusItemController { return false } - return self.handleProviderSwitcherShortcut(event, menu: menu) + return self.handleProviderSwitcherTrackingEvent(event, menu: menu) } monitor.start() self.providerSwitcherShortcutEventMonitor = monitor @@ -88,6 +96,7 @@ extension StatusItemController { self.providerSwitcherShortcutEventMonitor?.stop() self.providerSwitcherShortcutEventMonitor = nil self.providerSwitcherShortcutMenuID = nil + self.clearProviderSwitcherPointerInteraction() } func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { @@ -106,6 +115,77 @@ extension StatusItemController { return false } + @discardableResult + func handleProviderSwitcherTrackingEvent(_ event: NSEvent, menu: NSMenu) -> Bool { + switch event.type { + case .keyDown: + return self.handleProviderSwitcherShortcut(event, menu: menu) + case .leftMouseDown: + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { return false } + self.beginProviderSwitcherPointerInteraction(in: menu) + let handled = switcher.handleMenuTrackingMouseDown(event) + if !handled { + self.clearProviderSwitcherPointerInteraction(in: menu) + } + return handled + case .leftMouseUp: + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + return false + } + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { + self.clearProviderSwitcherPointerInteraction(in: menu) + return true + } + _ = switcher.handleMenuTrackingMouseUp(event) + self.finishProviderSwitcherPointerInteraction(in: menu) + return true + default: + return false + } + } + + func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.pendingProviderSwitcherPointerRebuild = PendingProviderSwitcherRebuild( + menu: menu, + provider: provider) + } + + private func beginProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + if self.providerSwitcherPointerInteractionMenuID != menuID { + self.pendingProviderSwitcherPointerRebuild = nil + } + self.providerSwitcherPointerInteractionMenuID = menuID + } + + private func finishProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + guard self.providerSwitcherPointerInteractionMenuID == menuID else { return } + self.providerSwitcherPointerInteractionMenuID = nil + guard let pending = self.pendingProviderSwitcherPointerRebuild, + pending.menu === menu + else { + self.pendingProviderSwitcherPointerRebuild = nil + return + } + self.pendingProviderSwitcherPointerRebuild = nil + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: pending.provider) + } + + private func clearProviderSwitcherPointerInteraction(in menu: NSMenu? = nil) { + if let menu, + self.providerSwitcherPointerInteractionMenuID != ObjectIdentifier(menu) + { + return + } + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil + } + @discardableResult private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, @@ -113,7 +193,6 @@ extension StatusItemController { else { return false } - self.applyIcon(phase: nil) return true } } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 0277aca62..6e44db4e0 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -52,6 +52,10 @@ extension StatusItemController { } self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = nil + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = nil + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil } private func clearShutdownMenuState() { @@ -59,12 +63,16 @@ extension StatusItemController { self.menuRefreshTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) + self.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.menuCardHeightCache.removeAll(keepingCapacity: false) + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) self.menuVersions.removeAll(keepingCapacity: false) self.providerMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a7fe4c684..a5404b54e 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -2,7 +2,7 @@ import AppKit import CodexBarCore import QuartzCore -enum ProviderSwitcherSelection: Equatable { +enum ProviderSwitcherSelection: Hashable { case overview case provider(UsageProvider) } @@ -40,6 +40,7 @@ final class ProviderSwitcherView: NSView { private var preferredWidth: CGFloat = 0 private var hoveredButtonTag: Int? private var pressedButtonTag: Int? + private var selectedSegmentIndex: Int? private let lightModeOverlayLayer = CALayer() private static let quotaIndicatorHeight: CGFloat = 3 private static let quotaIndicatorBottomInset: CGFloat = 2 @@ -190,6 +191,9 @@ final class ProviderSwitcherView: NSView { let button = makeButton(index: index, segment: segment) self.addSubview(button) } + self.selectedSegmentIndex = selected.flatMap { selected in + self.segments.firstIndex { $0.selection == selected } + } let uniformWidth: CGFloat if self.rowCount > 1 || !self.stackedIcons { @@ -294,21 +298,52 @@ final class ProviderSwitcherView: NSView { } override func mouseDown(with event: NSEvent) { - let location = self.convert(event.locationInWindow, from: nil) - self.pressedButtonTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag + _ = self.handleMenuTrackingMouseDown(event) } override func mouseUp(with event: NSEvent) { + _ = self.handleMenuTrackingMouseUp(event) + } + + @discardableResult + func handleMenuTrackingMouseDown(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown else { return false } + let location = self.locationInView(for: event) + guard let pressedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, + self.segments.indices.contains(pressedTag) + else { + return false + } + self.pressedButtonTag = pressedTag + return true + } + + @discardableResult + func handleMenuTrackingMouseUp(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseUp else { return false } defer { self.pressedButtonTag = nil } - guard let pressedTag = self.pressedButtonTag else { return } - let location = self.convert(event.locationInWindow, from: nil) + guard let pressedTag = self.pressedButtonTag else { return false } + let location = self.locationInView(for: event) guard let releasedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, - releasedTag == pressedTag, - self.segments.indices.contains(pressedTag) + releasedTag == pressedTag else { - return + return true } + // Commit only after the matching release. The controller schedules structural menu + // replacement after this callback returns so AppKit can finish the tracking transaction. self.applySelection(at: pressedTag) + return true + } + + private func locationInView(for event: NSEvent) -> NSPoint { + guard let eventWindow = event.window, + let viewWindow = self.window, + eventWindow !== viewWindow + else { + return self.convert(event.locationInWindow, from: nil) + } + let screenLocation = eventWindow.convertPoint(toScreen: event.locationInWindow) + return self.convert(viewWindow.convertPoint(fromScreen: screenLocation), from: nil) } func handleKeyboardSelection(at index: Int) -> Bool { @@ -319,22 +354,14 @@ final class ProviderSwitcherView: NSView { private func applySelection(at index: Int) { let selection = self.segments[index].selection + guard self.selectedSegmentIndex != index else { + self.updateSelection(selection) + return + } self.updateSelection(selection) self.onSelect(selection) } - #if DEBUG - /// Simulates the runtime click path (mouseDown → mouseUp on this view) that the menu uses - /// in production, bypassing `NSButton.performClick`. Tests use this to cover the path that - /// regressed in issue #867. - @discardableResult - func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { - guard self.segments.indices.contains(buttonTag) else { return false } - self.applySelection(at: buttonTag) - return true - } - #endif - private func applyLayout( outerPadding: CGFloat, minimumGap: CGFloat, @@ -588,10 +615,15 @@ final class ProviderSwitcherView: NSView { } func updateSelection(_ selection: ProviderSwitcherSelection) { + var selectedIndex: Int? for (index, button) in self.buttons.enumerated() { let isSelected = self.segments.indices.contains(index) && self.segments[index].selection == selection + if isSelected { + selectedIndex = index + } button.state = isSelected ? .on : .off } + self.selectedSegmentIndex = selectedIndex self.updateButtonStyles() } @@ -613,11 +645,14 @@ final class ProviderSwitcherView: NSView { let key = ObjectIdentifier(button) if let remaining { if var indicator = self.quotaIndicators[key] { - Self.updateQuotaIndicatorFill( - indicator: &indicator, - remainingPercent: remaining, - selection: segment.selection) - self.quotaIndicators[key] = indicator + let newRatio = Self.quotaIndicatorRatio(remainingPercent: remaining) + if newRatio != indicator.fillRatio { + Self.updateQuotaIndicatorFill( + indicator: &indicator, + remainingPercent: remaining, + selection: segment.selection) + self.quotaIndicators[key] = indicator + } } else { self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining) } @@ -658,41 +693,6 @@ final class ProviderSwitcherView: NSView { } } - #if DEBUG - func _test_buttonFrames() -> [NSRect] { - self.buttons.map(\.frame) - } - - func _test_buttonFittingSizes() -> [NSSize] { - self.buttons.map(\.fittingSize) - } - - func _test_rowCount() -> Int { - self.rowCount - } - - func _test_rowHeight() -> CGFloat { - self.rowHeight - } - - func _test_setHoveredButtonTag(_ tag: Int?) { - self.hoveredButtonTag = tag - self.updateButtonStyles() - } - - func _test_quotaIndicatorFillRatios() -> [CGFloat] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio - } - } - - func _test_quotaIndicatorFillFrames() -> [NSRect] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame - } - } - #endif - private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } @@ -896,6 +896,97 @@ final class ProviderSwitcherView: NSView { } } +#if DEBUG +extension ProviderSwitcherView { + func _test_mouseDownEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseDown) + } + + func _test_mouseUpEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseUp) + } + + private func _test_mouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: type == .leftMouseDown ? 1 : 2, + clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0) + } + + @discardableResult + func _test_simulateMouseDown(buttonTag: Int) -> Bool { + guard let event = self._test_mouseDownEvent(buttonTag: buttonTag) else { return false } + return self.handleMenuTrackingMouseDown(event) + } + + /// Simulates the parent-view event path used while NSMenu owns mouse tracking. + @discardableResult + func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { + guard self._test_simulateMouseDown(buttonTag: buttonTag) else { return false } + guard let event = self._test_mouseUpEvent(buttonTag: buttonTag) else { return false } + guard self.handleMenuTrackingMouseUp(event) else { return false } + return self.selectedSegmentIndex == buttonTag + } + + @discardableResult + func _test_simulateNativeAction(buttonTag: Int, state: NSControl.StateValue) -> Bool { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return false } + button.state = state + self.handleSelection(button) + return true + } + + func _test_buttonFrames() -> [NSRect] { + self.buttons.map(\.frame) + } + + func _test_buttonFittingSizes() -> [NSSize] { + self.buttons.map(\.fittingSize) + } + + func _test_rowCount() -> Int { + self.rowCount + } + + func _test_rowHeight() -> CGFloat { + self.rowHeight + } + + func _test_setHoveredButtonTag(_ tag: Int?) { + self.hoveredButtonTag = tag + self.updateButtonStyles() + } + + func _test_quotaIndicatorFillRatios() -> [CGFloat] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio + } + } + + func _test_quotaIndicatorFillFrames() -> [NSRect] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame + } + } + + func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) } + } + } +} +#endif + extension ProviderSwitcherView { private func addQuotaIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { guard let remainingPercent else { return } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 2e6639216..5bc0bbed0 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -24,6 +24,8 @@ extension StatusItemController { }, id: "usageHistorySubmenu", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: "usageHistorySubmenu:\(provider.rawValue)", submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) @@ -66,9 +68,9 @@ extension StatusItemController { snapshot: snapshot, width: width) let hosting = UsageHistoryMenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 3751e1c61..712fb3137 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -24,6 +24,8 @@ extension StatusItemController { }, id: "zaiHourlyUsageSubmenu", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: "zaiHourlyUsageSubmenu:\(provider.rawValue)", submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 47908496a..5e8222001 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -117,7 +117,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var menuReadinessSignatures: [ObjectIdentifier: String] = [:] + var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] + var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" + var lastMenuAdjunctReadinessBaselineVersion = 0 + var rootOpenHandledMenuObservationSignature: String? var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? @@ -126,6 +131,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var closedMenuRebuildTokenCounter = 0 + var closedMenusDeferredUntilNextOpen: Set = [] var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 @@ -137,7 +143,21 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? + var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? + var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild? var hasPreparedForAppShutdown = false + var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in + DispatchQueue.main.async { + Task { @MainActor in + operation() + } + } + } + + var terminateApplicationForQuit: @MainActor () -> Void = { + NSApp.terminate(nil) + } + var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? @@ -147,6 +167,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? + var _test_providerSwitcherMenuRebuildDebounceNanoseconds: UInt64? var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? #endif @@ -203,12 +224,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? + /// Tracks which provider/overview content is currently attached below the merged-menu switcher. + var lastMergedMenuContentSelection: ProviderSwitcherSelection? /// Tracks the visible Codex account switcher contents for merged-menu smart updates. var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? /// Tracks the visible token account switcher contents for merged-menu smart updates. var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Keeps detached merged-menu tab content reusable while the same menu remains open. + var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] + = [:] + var preservesMergedSwitcherContentCachesDuringInvalidation = false /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 + var providerSelectionUIRefreshTask: Task? var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] var lastObservedStoreIconWorkSignature: String? @@ -370,6 +398,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) } self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion self.wireBindings() self.updateVisibility() self.updateIcons() @@ -440,15 +469,27 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } - self.observeStoreChanges() - self.invalidateMenus( - refreshOpenMenus: self.didMenuAdjunctReadinessChange(), - deferOpenParentMenuRebuild: true, - allowStaleContentDuringDataRefresh: true) + self.handleObservedStoreMenuChange() } } } + func handleObservedStoreMenuChange() { + self.observeStoreChanges() + let rootOpenHandledReadiness = self.consumeRootOpenHandledMenuObservationIfNeeded() + // `refreshOpenMenus` is only consulted when a menu is currently open. + // Computing the readiness signature serializes every enabled provider's + // token snapshot and 30-day daily breakdown, which is wasted main-thread + // work on the common path where no menu is open (background refresh ticks). + let refreshOpenMenus = self.openMenus.isEmpty + ? false + : rootOpenHandledReadiness || self.didMenuAdjunctReadinessChange() + self.invalidateMenus( + refreshOpenMenus: refreshOpenMenus, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) + } + private func observeStoreIconChanges() { withObservationTracking { _ = self.store.iconObservationToken @@ -464,51 +505,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - func storeIconObservationSignature() -> String { - let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent - let mergeIcons = self.shouldMergeIcons - let needsAnimation = self.needsMenuBarIconAnimation() - let providerSignatures = UsageProvider.allCases.map { - self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) - }.joined(separator: "||") - let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") - return [ - "merge=\(mergeIcons ? "1" : "0")", - "visible=\(visibleProviders)", - "iconStyle=\(String(describing: self.store.iconStyle))", - "brandPercent=\(showBrandPercent ? "1" : "0")", - "needsAnimation=\(needsAnimation ? "1" : "0")", - providerSignatures, - ].joined(separator: "|") - } - - private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { - let snapshot = self.store.snapshot(for: provider) - let stale = self.store.isStale(provider: provider) - let status = self.store.statusIndicator(for: provider).rawValue - let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) - let isAnimating = isVisibleForAnimation && !stale && snapshot == nil - let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) - let creditsRemaining = provider == .codex - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil - let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil - - return [ - provider.rawValue, - "style=\(String(describing: self.store.style(for: provider)))", - "snapshot=\(String(describing: snapshot))", - "stale=\(stale ? "1" : "0")", - "status=\(status)", - "anim=\(isAnimating ? "1" : "0")", - "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", - "credits=\(String(describing: creditsRemaining))", - "text=\(displayText ?? "nil")", - ].joined(separator: "|") - } - private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 4224e8d72..ccdec060b 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -83,15 +83,30 @@ extension UsageStore { } func accountInfo(for provider: UsageProvider) -> AccountInfo { - guard provider == .codex else { - return self.codexFetcher.loadAccountInfo() + let now = Date() + let configRevision = self.settings.configRevision + if let cached = self.accountInfoCache[provider], + cached.isValid(now: now, configRevision: configRevision) + { + return cached.account } - let env = ProviderRegistry.makeEnvironment( - base: self.environmentBase, - provider: .codex, - settings: self.settings, - tokenOverride: nil) - let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) - return fetcher.loadAccountInfo() + + let account: AccountInfo + if provider == .codex { + let env = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .codex, + settings: self.settings, + tokenOverride: nil) + let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) + account = fetcher.loadAccountInfo() + } else { + account = self.codexFetcher.loadAccountInfo() + } + self.accountInfoCache[provider] = AccountInfoCacheEntry( + account: account, + configRevision: configRevision, + expiresAt: now.addingTimeInterval(self.accountInfoCacheTTL)) + return account } } diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 9a2d730e3..3f8f986c2 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -60,6 +60,17 @@ extension UsageStore { guard !percents.isEmpty else { return true } return percents.allSatisfy { $0 >= 100 } } + if provider == .antigravity, + effectivePreference == .automatic + { + let percents = [ + snapshot.primary?.usedPercent, + snapshot.secondary?.usedPercent, + snapshot.tertiary?.usedPercent, + ].compactMap(\.self) + guard !percents.isEmpty else { return true } + return percents.allSatisfy { $0 >= 100 } + } return true } } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 496502fbd..b8e910c2c 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -108,7 +108,7 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -120,18 +120,25 @@ extension UsageStore { allowCodexUsageBackfill: Bool = true) async { guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return } - if let expectedGuard, - !self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: targetEmail) - { - return - } - + self.settings.invalidateCodexAccountReconciliationSnapshotCache() let authority = self.evaluateCodexDashboardAuthority( dashboard: dash, sourceKind: .liveWeb, routingTargetEmail: targetEmail) + if let expectedGuard { + let shouldApply = switch authority.decision.disposition { + case .attach: + self.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + case .displayOnly, .failClosed: + self.shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + } + guard shouldApply else { return } + } + let attachedAccountEmail = self.codexDashboardAttachmentEmail(from: authority.input) await self.applyOpenAIDashboardAuthorityDecision( @@ -832,7 +839,7 @@ extension UsageStore { allowCurrentSnapshotFallback: true, allowLastKnownLiveFallback: false) _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, @@ -878,7 +885,8 @@ extension UsageStore { let source = String(describing: expectedGuard?.source ?? self.settings.codexResolvedActiveSource) let identityKey = Self.codexIdentityGuardKey(expectedGuard?.identity ?? .unresolved) ?? "unresolved" let accountKey = Self.normalizeCodexAccountScopedKey(targetEmail) ?? "unknown" - return "\(source)|\(identityKey)|\(accountKey)" + let authFingerprint = CodexAuthFingerprint.normalize(expectedGuard?.authFingerprint) ?? "nil" + return "\(source)|\(identityKey)|\(accountKey)|auth:\(authFingerprint)" } private func actionableOpenAIDashboardImportFailure(targetEmail: String?) -> String? { diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 2f87fbc5a..ad01e7460 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -53,6 +53,34 @@ extension UsageStore { return providerBuckets.histories(for: accountKey) } + func codexPlanUtilizationHistories(forVisibleAccount account: CodexVisibleAccount) + -> [PlanUtilizationSeriesHistory] + { + var providerBuckets = self.planUtilizationHistory[.codex] ?? PlanUtilizationHistoryBuckets() + let originalProviderBuckets = providerBuckets + let ownership = self.codexOwnershipContext(forVisibleAccount: account) + guard let canonicalKey = ownership.canonicalKey else { return [] } + + if ownership.hasAdjacentEmailScopeAmbiguity { + guard canonicalKey != ownership.canonicalEmailHashKey else { return [] } + return providerBuckets.histories(for: canonicalKey) + } + + let accountKey = self.materializeCodexPlanUtilizationHistoryIfNeeded( + into: canonicalKey, + ownership: ownership, + shouldAdoptUnscopedHistory: true, + providerBuckets: &providerBuckets) + self.planUtilizationHistory[.codex] = providerBuckets + if providerBuckets != originalProviderBuckets { + let snapshotToPersist = self.planUtilizationHistory + Task { + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) + } + } + return providerBuckets.histories(for: accountKey) + } + func shouldShowRefreshingMenuCard(for provider: UsageProvider) -> Bool { let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) return isRefreshing @@ -733,6 +761,7 @@ extension UsageStore { targetCanonicalKey: canonicalKey, canonicalEmailHashKey: ownership.canonicalEmailHashKey) if matchesTargetContinuity, + !Self.codexPlanHistoryOwnerIsAmbiguousEmailScope(owner, ownership: ownership), let accountHistories = providerBuckets.accounts[rawKey], !accountHistories.isEmpty { @@ -776,6 +805,21 @@ extension UsageStore { return canonicalKey } + private static func codexPlanHistoryOwnerIsAmbiguousEmailScope( + _ owner: CodexHistoryPersistedOwner, + ownership: CodexOwnershipContext) -> Bool + { + guard ownership.hasAdjacentEmailScopeAmbiguity else { return false } + return switch owner { + case let .canonical(key): + key == ownership.canonicalEmailHashKey + case .legacyEmailHash: + true + case .legacyOpaqueScoped, .legacyUnscoped: + false + } + } + private func materializeLegacyClaudePlanUtilizationHistoryIfNeeded( into accountKey: String, provider: UsageProvider, diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift index 3e5197eb5..a31f01740 100644 --- a/Sources/CodexBar/UsageStore+ProviderStorage.swift +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -45,7 +45,16 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let environment = self.environmentBase + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let request = await Task.detached(priority: .utility) { + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + return Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + }.value + guard let request else { self.clearStorageFootprints() return } @@ -67,6 +76,7 @@ extension UsageStore { updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } func scheduleStorageFootprintRefresh(for providers: [UsageProvider], force: Bool = false) { @@ -74,19 +84,23 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let requestKey = Self.storageRefreshRequestKey( + for: providers, + managedAccountsOverride: managedAccountsOverride) + guard !requestKey.isEmpty else { self.clearStorageFootprints() return } let now = Date() if self.storageRefreshTask != nil, - self.storageRefreshInFlightSignature == request.signature + self.storageRefreshInFlightRequestKey == nil || self.storageRefreshInFlightRequestKey == requestKey { return } if !force { - if self.lastStorageRefreshSignature == request.signature, + if self.lastStorageRefreshRequestKey == requestKey, let lastStorageRefreshAt, now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval { @@ -97,9 +111,32 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshGeneration &+= 1 let generation = self.storageRefreshGeneration - self.storageRefreshInFlightSignature = request.signature + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = requestKey + let environment = self.environmentBase self.storageRefreshTask = Task.detached(priority: .utility) { [weak self] in + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + guard let request = Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + else { + await MainActor.run { [weak self] in + guard let self, + !Task.isCancelled, + generation == self.storageRefreshGeneration + else { return } + self.providerStorageFootprints.removeAll() + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil + self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = requestKey + self.lastStorageRefreshAt = Date() + } + return + } let footprints = Self.scanStorageFootprints(candidatePathsByProvider: request.candidatePathsByProvider) await MainActor.run { [weak self] in @@ -112,9 +149,11 @@ extension UsageStore { footprints, providers: request.providers, signature: request.signature, + requestKey: requestKey, updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } } } @@ -123,7 +162,9 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = nil self.lastStorageRefreshAt = nil self.providerStorageFootprints.removeAll() } @@ -132,6 +173,7 @@ extension UsageStore { _ footprints: [UsageProvider: ProviderStorageFootprint], providers: [UsageProvider], signature: String, + requestKey: String? = nil, updatedAt: Date) { let providerSet = Set(providers) @@ -140,15 +182,19 @@ extension UsageStore { self.providerStorageFootprints[provider] = footprints[provider] } self.lastStorageRefreshSignature = signature + self.lastStorageRefreshRequestKey = requestKey ?? signature self.lastStorageRefreshAt = updatedAt } - private func makeStorageRefreshRequest(for providers: [UsageProvider]) -> StorageRefreshRequest? { + private nonisolated static func makeStorageRefreshRequest( + for providers: [UsageProvider], + environment: [String: String], + managedAccounts: [ManagedCodexAccount]) + -> StorageRefreshRequest? + { let uniqueProviders = Array(Set(providers)).sorted { $0.rawValue < $1.rawValue } guard !uniqueProviders.isEmpty else { return nil } - let environment = self.environmentBase - let managedAccounts = self.loadManagedCodexAccountsForStorage() var candidatePathsByProvider: [UsageProvider: [String]] = [:] for provider in uniqueProviders { @@ -175,9 +221,39 @@ extension UsageStore { signature: signature) } - private func loadManagedCodexAccountsForStorage() -> [ManagedCodexAccount] { - if let managedCodexAccountsForStorageOverride { - return managedCodexAccountsForStorageOverride + private nonisolated static func storageRefreshRequestKey( + for providers: [UsageProvider], + managedAccountsOverride: [ManagedCodexAccount]?) + -> String + { + let uniqueProviders = Array(Set(providers)) + .sorted { $0.rawValue < $1.rawValue } + let providerKey = uniqueProviders.map(\.rawValue).joined(separator: ",") + guard uniqueProviders.contains(.codex) else { return providerKey } + + let managedAccountsRevision: String + if let managedAccountsOverride { + managedAccountsRevision = Array(Set(managedAccountsOverride.map(\.managedHomePath))) + .sorted() + .joined(separator: "\u{1f}") + } else { + let fileURL = FileManagedCodexAccountStore.defaultURL() + let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + let modificationDate = (attributes?[.modificationDate] as? Date)? + .timeIntervalSinceReferenceDate.bitPattern ?? 0 + let fileNumber = (attributes?[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0 + let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + managedAccountsRevision = "\(fileNumber):\(modificationDate):\(fileSize)" + } + return "\(providerKey)\u{1e}\(managedAccountsRevision)" + } + + private nonisolated static func loadManagedCodexAccountsForStorage( + override: [ManagedCodexAccount]?) + -> [ManagedCodexAccount] + { + if let override { + return override } return (try? FileManagedCodexAccountStore().loadAccounts().accounts) ?? [] } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 22c5b19f4..eabe08c78 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -23,7 +23,7 @@ extension UsageStore { func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { self.prepareRefreshState(for: provider) guard let spec = await self.providerRefreshSpec(provider) else { return } - let codexExpectedGuard = provider == .codex ? self.currentCodexAccountScopedRefreshGuard() : nil + let codexExpectedGuard = provider == .codex ? self.freshCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { await self.clearDisabledProviderRefreshState(provider) @@ -105,7 +105,10 @@ extension UsageStore { if claudeCredentialsChanged { self.clearClaudeCredentialDerivedStateForCredentialSwapNow() } - let backfilled = scoped.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + let resetBackfillSource = provider == .codex + ? self.codexLastKnownResetSnapshot(matching: codexExpectedGuard) + : self.lastKnownResetSnapshots[provider] + let backfilled = scoped.backfillingResetTimes(from: resetBackfillSource) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 39bf13444..fb39db3cf 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -45,8 +45,19 @@ private struct CodexAccountFetchResult { let outcome: ProviderFetchOutcome } +private struct CodexManagedVisibleAccountRuntimeState { + let authFingerprint: String? + let workspaceAccountID: String? +} + extension UsageStore { static let tokenAccountMenuSnapshotLimit = 6 + private static let codexSessionWindowMinutes = 5 * 60 + private static let codexWeeklyWindowMinutes = 7 * 24 * 60 + + func freshCodexVisibleAccountsForSnapshotHydration() -> [CodexVisibleAccount] { + self.freshCodexVisibleAccountProjectionForAccountRefresh().visibleAccounts + } func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } @@ -80,12 +91,13 @@ extension UsageStore { } func shouldFetchAllCodexVisibleAccounts() -> Bool { - self.settings.multiAccountMenuLayout == .stacked && - self.settings.codexVisibleAccountProjection.visibleAccounts.count > 1 + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() + return self.settings.multiAccountMenuLayout == .stacked && + projection.visibleAccounts.count > 1 } func refreshCodexVisibleAccountsForMenu() async { - let projection = self.settings.codexVisibleAccountProjection + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let accounts = self.limitedCodexVisibleAccounts( projection.visibleAccounts, snapshots: self.codexAccountSnapshots, @@ -94,14 +106,19 @@ extension UsageStore { self.codexAccountSnapshots = [] return } + let managedAccountIDsWithReadableAuthAtStart = self.codexManagedAccountIDsWithReadableAuth() let originalVisibleAccountID = projection.activeVisibleAccountID let originalSelectionSource = originalVisibleAccountID.flatMap { projection.source(forVisibleAccountID: $0) } + let originalVisibleAccount = originalVisibleAccountID.flatMap { id in + accounts.first { $0.id == id } + } let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) var snapshots: [CodexAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? + var selectedAccount: CodexVisibleAccount? var selectedSnapshot: UsageSnapshot? var selectedSourceLabel: String? var sawAnyNonCancellationOutcome = false @@ -117,45 +134,277 @@ extension UsageStore { let resolved = self.resolveCodexAccountOutcome( outcome, account: account, - priorSnapshot: priorByAccountID[account.id]) + priorSnapshot: priorByAccountID[account.id], + resetBackfillSnapshots: self.codexResetBackfillSnapshots( + for: account, + priorSnapshot: priorByAccountID[account.id], + activeVisibleAccountID: originalVisibleAccountID)) if let snapshot = resolved.snapshot { snapshots.append(snapshot) } if account.id == originalVisibleAccountID { selectedOutcome = outcome + selectedAccount = account selectedSnapshot = resolved.usage selectedSourceLabel = resolved.sourceLabel } } + let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) + let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in + guard let currentAccount = Self.currentCodexVisibleAccount( + matching: snapshot.account, + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: snapshot.error == nil) + else { + return nil + } + guard currentAccount != snapshot.account else { return snapshot } + return CodexAccountUsageSnapshot( + account: currentAccount, + snapshot: Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + snapshot.snapshot, + account: currentAccount), + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } let shouldPreservePriorState = !sawAnyNonCancellationOutcome && - snapshots.allSatisfy { $0.snapshot == nil } + currentSnapshots.allSatisfy { $0.snapshot == nil } if !shouldPreservePriorState { - self.codexAccountSnapshots = snapshots - self.codexAccountUsageSnapshotStore?.store(snapshots) + self.codexAccountSnapshots = currentSnapshots + self.codexAccountUsageSnapshotStore?.store(currentSnapshots) } let selectionStillMatches = self.codexVisibleSelectionStillMatches( originalVisibleAccountID: originalVisibleAccountID, - originalSelectionSource: originalSelectionSource) - if let selectedOutcome, selectionStillMatches { - await self.applySelectedCodexVisibleAccountOutcome( + originalSelectionSource: originalSelectionSource, + originalAccount: originalVisibleAccount, + currentProjection: currentProjection) + guard let selectedOutcome, let selectedAccount else { return } + guard selectionStillMatches else { + _ = self.prepareCodexAccountScopedRefreshIfNeeded() + return + } + + let allowSelectedAuthFingerprintMismatch = switch selectedOutcome.result { + case .success: + true + case .failure: + false + } + let currentSelectedAccount = Self.currentCodexVisibleAccount( + matching: selectedAccount, + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: allowSelectedAuthFingerprintMismatch) + if let currentSelectedAccount { + let currentSelectedSnapshot = Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + selectedSnapshot, + account: currentSelectedAccount) + if self.shouldApplySelectedCodexVisibleAccountOutcome( selectedOutcome, - snapshot: selectedSnapshot, - sourceLabel: selectedSourceLabel) + snapshot: currentSelectedSnapshot) + { + await self.applySelectedCodexVisibleAccountOutcome( + selectedOutcome, + account: currentSelectedAccount, + snapshot: currentSelectedSnapshot, + sourceLabel: selectedSourceLabel) + } + } else { + _ = self.prepareCodexAccountScopedRefreshIfNeeded() } } func codexVisibleSelectionStillMatches( originalVisibleAccountID: String?, - originalSelectionSource: CodexActiveSource?) -> Bool + originalSelectionSource: CodexActiveSource?, + originalAccount: CodexVisibleAccount? = nil, + currentProjection: CodexVisibleAccountProjection? = nil) -> Bool + { + let currentProjection = currentProjection ?? self.settings.codexVisibleAccountProjection + let currentActiveAccount = currentProjection.activeVisibleAccountID.flatMap { id in + currentProjection.visibleAccounts.first { $0.id == id } + } + let currentSelectionSource = currentActiveAccount?.selectionSource + if currentProjection.activeVisibleAccountID == originalVisibleAccountID, + currentSelectionSource == originalSelectionSource + { + return true + } + guard let originalAccount, let currentActiveAccount, currentSelectionSource == originalSelectionSource else { + return false + } + return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) + } + + private func freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection + { + // Auth files can change while account fetches are in flight, so account refreshes bypass the + // short-lived reconciliation cache used for normal menu rendering and stale-result guards. + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + let snapshot = self.settings.codexAccountReconciliationSnapshot + return Self.codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + CodexVisibleAccountProjection.make(from: snapshot), + snapshot: snapshot, + requireLiveManagedAuthFor: accountIDs) + } + + private func codexManagedAccountIDsWithReadableAuth() -> Set { + Set(self.settings.codexAccountReconciliationSnapshot.storedAccounts.compactMap { account in + CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) == nil ? nil : account.id + }) + } + + private nonisolated static func codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + _ projection: CodexVisibleAccountProjection, + snapshot: CodexAccountReconciliationSnapshot, + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection + { + let managedRuntimeStates = Dictionary( + uniqueKeysWithValues: snapshot.storedAccounts.map { account in + let workspaceAccountID: String? = switch snapshot.runtimeIdentity(for: account) { + case let .providerAccount(id): + id + case .emailOnly, .unresolved: + nil + } + let authFingerprint = CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + let requiresLiveAuth = accountIDs.contains(account.id) + return (account.id, CodexManagedVisibleAccountRuntimeState( + authFingerprint: authFingerprint ?? (requiresLiveAuth ? nil : account.authFingerprint), + workspaceAccountID: authFingerprint == nil && requiresLiveAuth + ? nil + : (workspaceAccountID ?? account.workspaceAccountID))) + }) + let visibleAccounts = projection.visibleAccounts.map { account in + guard case let .managedAccount(id) = account.selectionSource else { return account } + let accountWorkspaceAccountID = account.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let runtimeWorkspaceAccountID = managedRuntimeStates[id]?.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + guard let runtimeState = managedRuntimeStates[id], + runtimeState.authFingerprint != account.authFingerprint || + runtimeWorkspaceAccountID != accountWorkspaceAccountID + else { + return account + } + return CodexVisibleAccount( + id: account.id, + email: account.email, + workspaceLabel: account.workspaceLabel, + workspaceAccountID: runtimeState.workspaceAccountID, + authFingerprint: runtimeState.authFingerprint, + storedAccountID: account.storedAccountID, + selectionSource: account.selectionSource, + isActive: account.isActive, + isLive: account.isLive, + canReauthenticate: account.canReauthenticate, + canRemove: account.canRemove) + } + return CodexVisibleAccountProjection( + visibleAccounts: visibleAccounts, + activeVisibleAccountID: projection.activeVisibleAccountID, + liveVisibleAccountID: projection.liveVisibleAccountID, + hasUnreadableAddedAccountStore: projection.hasUnreadableAddedAccountStore) + } + + private static func currentCodexVisibleAccount( + matching account: CodexVisibleAccount, + projection: CodexVisibleAccountProjection, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> CodexVisibleAccount? + { + if let currentAccount = projection.visibleAccounts.first(where: { $0.id == account.id }), + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: currentAccount, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) + { + return currentAccount + } + return projection.visibleAccounts.first { + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: $0, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) + } + } + + private static func codexVisibleAccountSnapshotRelabeledForCurrentProjection( + _ snapshot: UsageSnapshot?, + account: CodexVisibleAccount) -> UsageSnapshot? + { + guard let snapshot else { return nil } + let existing = snapshot.identity(for: .codex) + return snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: existing?.accountOrganization, + loginMethod: existing?.loginMethod ?? account.workspaceLabel)) + } + + private static func codexVisibleAccountMatchesCurrentProjection( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> Bool { - let currentProjection = self.settings.codexVisibleAccountProjection - let currentSelectionSource = originalVisibleAccountID.flatMap { - currentProjection.source(forVisibleAccountID: $0) + guard prior.selectionSource == account.selectionSource else { return false } + + let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email) + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email) + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + guard priorWorkspaceID == accountWorkspaceID else { return false } + if !allowProviderAccountAuthFingerprintMismatch { + guard self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) else { return false } + } + switch account.selectionSource { + case .managedAccount: + if !self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) { + return priorEmail != nil && priorEmail == accountEmail + } + return true + case .liveSystem: + return priorEmail != nil && priorEmail == accountEmail + } + } + + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + + return priorEmail != nil && priorEmail == accountEmail + } + + private static func codexVisibleAccountAuthFingerprintMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + return priorAuthFingerprint == accountAuthFingerprint + } + return true + } + + func shouldApplySelectedCodexVisibleAccountOutcome( + _ outcome: ProviderFetchOutcome, + snapshot: UsageSnapshot?) -> Bool + { + switch outcome.result { + case .success: + snapshot != nil + case .failure: + true } - return currentProjection.activeVisibleAccountID == originalVisibleAccountID && - currentSelectionSource == originalSelectionSource } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { @@ -453,6 +702,11 @@ extension UsageStore { let sourceLabel: String? } + private struct CodexResetBackfillWindowCandidate { + let window: RateWindow + let capturedAt: Date + } + func tokenAccountErrorMessage(_ error: any Error) -> String? { guard !Self.errorIsCancellation(error) else { return nil } let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) @@ -466,6 +720,319 @@ extension UsageStore { return message.isEmpty ? "Refresh failed" : message } + private func codexResetBackfillSnapshots( + for account: CodexVisibleAccount, + priorSnapshot: CodexAccountUsageSnapshot?, + activeVisibleAccountID: String?) -> [UsageSnapshot] + { + var snapshots: [UsageSnapshot] = [] + if let priorSnapshot, + Self.codexPriorSnapshotAccountMatches(priorSnapshot.account, account: account), + let prior = priorSnapshot.snapshot + { + snapshots.append(prior) + } + if account.id == activeVisibleAccountID, + let lastKnown = self.codexLastKnownResetSnapshot(for: account) + { + snapshots.append(lastKnown) + } + if account.id != activeVisibleAccountID || self.codexCanUseHistoricalResetBackfill(for: account), + let history = self.codexPlanHistoryResetBackfillSnapshot(for: account) + { + snapshots.append(history) + } + return snapshots + } + + private func codexCanUseHistoricalResetBackfill(for account: CodexVisibleAccount) -> Bool { + let authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + let workspaceAccountID = Self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + guard authFingerprint != nil, workspaceAccountID == nil else { return true } + return Self.codexScopedGuard(self.lastCodexAccountScopedRefreshGuard, matches: account) + } + + private func codexPlanHistoryResetBackfillSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { + let histories = self.codexPlanUtilizationHistories(forVisibleAccount: account) + guard !histories.isEmpty + else { + return nil + } + + let now = Date() + let primaryCandidate = Self.codexResetBackfillWindowCandidate( + from: histories, + name: .session, + windowMinutes: Self.codexSessionWindowMinutes, + now: now) + let secondaryCandidate = Self.codexResetBackfillWindowCandidate( + from: histories, + name: .weekly, + windowMinutes: Self.codexWeeklyWindowMinutes, + now: now) + let primary = primaryCandidate?.window + let secondary = secondaryCandidate?.window + guard primary != nil || secondary != nil else { return nil } + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: [primaryCandidate?.capturedAt, secondaryCandidate?.capturedAt].compactMap(\.self).max() ?? now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: nil, + loginMethod: account.workspaceLabel)) + } + + private func codexLastKnownResetSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { + guard let snapshot = self.lastKnownResetSnapshots[.codex], + Self.codexVisibleAccountEmailMatches(snapshot: snapshot, account: account), + Self.codexScopedGuard(self.lastCodexAccountScopedRefreshGuard, matches: account) + else { + return nil + } + return snapshot + } + + func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) -> UsageSnapshot? { + guard let guardValue, + let lastGuard = self.lastCodexAccountScopedRefreshGuard, + Self.codexScopedRefreshGuardAllowsResetBackfill(lastGuard, matching: guardValue) + else { + return nil + } + return self.lastKnownResetSnapshots[.codex] + } + + private nonisolated static func codexVisibleAccountEmailMatches( + snapshot: UsageSnapshot, + account: CodexVisibleAccount) -> Bool + { + guard let identity = snapshot.identity(for: .codex), + let identityEmail = CodexIdentityResolver.normalizeEmail(identity.accountEmail), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + identityEmail == accountEmail + else { + return false + } + return true + } + + private nonisolated static func codexPriorSnapshotAccountMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + guard let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + priorEmail == accountEmail + else { + return false + } + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + return priorWorkspaceID == accountWorkspaceID + } + + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + + if prior.selectionSource == account.selectionSource { + switch account.selectionSource { + case .managedAccount: + return true + case .liveSystem: + return prior.id == account.id + } + } + + guard prior.id != prior.email, account.id != account.email else { return false } + return prior.id == account.id + } + + private nonisolated static func codexScopedGuard( + _ guardValue: CodexAccountScopedRefreshGuard?, + matches account: CodexVisibleAccount) -> Bool + { + guard let guardValue, guardValue.source == account.selectionSource else { return false } + let guardAuthFingerprint = CodexAuthFingerprint.normalize(guardValue.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if guardAuthFingerprint != nil || accountAuthFingerprint != nil { + guard guardAuthFingerprint == accountAuthFingerprint else { return false } + } + let identity = self.codexVisibleAccountIdentity(for: account) + if identity != .unresolved { + return guardValue.identity == identity + } + guard let accountKey = CodexIdentityResolver.normalizeEmail(account.email) else { return false } + return guardValue.accountKey == accountKey + } + + private nonisolated static func codexScopedRefreshGuardAllowsResetBackfill( + _ lastGuard: CodexAccountScopedRefreshGuard, + matching expectedGuard: CodexAccountScopedRefreshGuard) -> Bool + { + self.codexScopedRefreshGuardsMatchAccount(lastGuard, expectedGuard) + } + + private nonisolated static func codexScopedRefreshGuard(for account: CodexVisibleAccount) + -> CodexAccountScopedRefreshGuard + { + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email) + return CodexAccountScopedRefreshGuard( + source: account.selectionSource, + identity: self.codexVisibleAccountIdentity(for: account), + accountKey: accountEmail, + authFingerprint: account.authFingerprint) + } + + private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) -> CodexIdentity { + if let workspaceAccountID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) { + return .providerAccount(id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) + } + return CodexIdentityResolver.resolve(accountId: nil, email: account.email) + } + + private nonisolated static func normalizedCodexVisibleAccountText(_ text: String?) -> String? { + guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private nonisolated static func codexResetBackfillWindowCandidate( + from histories: [PlanUtilizationSeriesHistory], + name: PlanUtilizationSeriesName, + windowMinutes: Int, + now: Date) -> CodexResetBackfillWindowCandidate? + { + let candidate = histories.lazy + .filter { $0.name == name && name.canonicalWindowMinutes($0.windowMinutes) == windowMinutes } + .flatMap { history in + history.entries.map { entry in + (capturedAt: entry.capturedAt, usedPercent: entry.usedPercent, resetsAt: entry.resetsAt) + } + } + .filter { $0.resetsAt.map { $0 > now } ?? false } + .max { lhs, rhs in + if lhs.capturedAt != rhs.capturedAt { + return lhs.capturedAt < rhs.capturedAt + } + return (lhs.resetsAt ?? .distantPast) < (rhs.resetsAt ?? .distantPast) + } + + guard let candidate, let resetsAt = candidate.resetsAt else { return nil } + return CodexResetBackfillWindowCandidate( + window: RateWindow( + usedPercent: candidate.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: nil), + capturedAt: candidate.capturedAt) + } + + private nonisolated static func codexBackfillingResetWindows( + _ snapshot: UsageSnapshot, + from cached: UsageSnapshot) -> UsageSnapshot + { + let primary = self.codexBackfillingResetWindow(snapshot.primary, from: cached.primary) + let secondary = self.codexBackfillingResetWindow(snapshot.secondary, from: cached.secondary) + guard primary != snapshot.primary || secondary != snapshot.secondary else { return snapshot } + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: snapshot.tertiary, + extraRateWindows: snapshot.extraRateWindows, + kiroUsage: snapshot.kiroUsage, + providerCost: snapshot.providerCost, + zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + deepseekUsage: snapshot.deepseekUsage, + openRouterUsage: snapshot.openRouterUsage, + openAIAPIUsage: snapshot.openAIAPIUsage, + claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, + mistralUsage: snapshot.mistralUsage, + deepgramUsage: snapshot.deepgramUsage, + cursorRequests: snapshot.cursorRequests, + subscriptionExpiresAt: snapshot.subscriptionExpiresAt, + subscriptionRenewsAt: snapshot.subscriptionRenewsAt, + updatedAt: snapshot.updatedAt, + identity: snapshot.identity) + } + + private nonisolated static func codexMergedResetBackfillSnapshot( + _ snapshots: [UsageSnapshot], + now: Date = Date()) -> UsageSnapshot? + { + let primary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + snapshot.primary.map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + let secondary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + snapshot.secondary.map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + guard primary != nil || secondary != nil else { return nil } + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: snapshots.map(\.updatedAt).max() ?? now) + } + + private nonisolated static func codexPreferredResetBackfillWindow( + _ windows: [(window: RateWindow, updatedAt: Date, priority: Int)], + now: Date) -> RateWindow? + { + windows + .filter { ($0.window.resetsAt ?? .distantPast) > now } + .max { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt < rhs.updatedAt + } + if lhs.priority != rhs.priority { + return lhs.priority < rhs.priority + } + let lhsReset = lhs.window.resetsAt ?? .distantPast + let rhsReset = rhs.window.resetsAt ?? .distantPast + if lhsReset != rhsReset { + return lhsReset < rhsReset + } + return (lhs.window.windowMinutes ?? 0) < (rhs.window.windowMinutes ?? 0) + } + .map(\.window) + } + + private nonisolated static func codexBackfillingResetWindow( + _ window: RateWindow?, + from cached: RateWindow?) -> RateWindow? + { + guard let cached, + let resetsAt = cached.resetsAt, + resetsAt > Date() + else { + return window + } + if let window { + return window.backfillingResetTime(from: cached) + } + guard let windowMinutes = cached.windowMinutes, windowMinutes > 0 else { return nil } + return RateWindow( + usedPercent: cached.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: cached.resetDescription) + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -523,20 +1090,23 @@ extension UsageStore { private func resolveCodexAccountOutcome( _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, - priorSnapshot: CodexAccountUsageSnapshot? = nil) -> ResolvedCodexAccountOutcome + priorSnapshot: CodexAccountUsageSnapshot? = nil, + resetBackfillSnapshots: [UsageSnapshot] = []) -> ResolvedCodexAccountOutcome { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: .codex) let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let backfilled = Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let snapshot = CodexAccountUsageSnapshot( account: account, - snapshot: labeled, + snapshot: backfilled, error: nil, sourceLabel: result.sourceLabel) return ResolvedCodexAccountOutcome( snapshot: snapshot, - usage: labeled, + usage: backfilled, sourceLabel: result.sourceLabel) case let .failure(error): if Self.errorIsCancellation(error) { @@ -590,6 +1160,7 @@ extension UsageStore { func applySelectedCodexVisibleAccountOutcome( _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount, snapshot: UsageSnapshot?, sourceLabel: String?) async { @@ -597,19 +1168,19 @@ extension UsageStore { switch outcome.result { case .success: guard let snapshot else { return } - let backfilled = snapshot.backfillingResetTimes(from: self.lastKnownResetSnapshots[.codex]) - self.handleSessionQuotaTransition(provider: .codex, snapshot: backfilled) - self.lastKnownResetSnapshots[.codex] = backfilled - self.snapshots[.codex] = backfilled + self.handleSessionQuotaTransition(provider: .codex, snapshot: snapshot) + self.lastKnownResetSnapshots[.codex] = snapshot + self.lastCodexAccountScopedRefreshGuard = Self.codexScopedRefreshGuard(for: account) + self.snapshots[.codex] = snapshot if let sourceLabel { self.lastSourceLabels[.codex] = sourceLabel } self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() - self.rememberLiveSystemCodexEmailIfNeeded(backfilled.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: backfilled.accountEmail(for: .codex)) - await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) - self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: account.email) + await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: snapshot) + self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): guard let message = self.tokenAccountErrorMessage(error) else { self.errors[.codex] = nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index ef178a8bd..481278397 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -54,30 +54,7 @@ extension UsageStore { func observeSettingsChanges() { withObservationTracking { - _ = self.settings.refreshFrequency - _ = self.settings.statusChecksEnabled - _ = self.settings.sessionQuotaNotificationsEnabled - _ = self.settings.quotaWarningNotificationsEnabled - _ = self.settings.quotaWarningThresholds - _ = self.settings.quotaWarningThresholds(.session) - _ = self.settings.quotaWarningThresholds(.weekly) - _ = self.settings.quotaWarningSoundEnabled - _ = self.settings.usageBarsShowUsed - _ = self.settings.costUsageEnabled - _ = self.settings.costUsageHistoryDays - _ = self.settings.randomBlinkEnabled - _ = self.settings.configRevision - for implementation in ProviderCatalog.all { - implementation.observeSettings(self.settings) - } - _ = self.settings.multiAccountMenuLayout - _ = self.settings.tokenAccountsByProvider - _ = self.settings.mergeIcons - _ = self.settings.selectedMenuProvider - _ = self.settings.debugLoadingPattern - _ = self.settings.debugKeepCLISessionsAlive - _ = self.settings.historicalTrackingEnabled - _ = self.settings.providerStorageFootprintsEnabled + _ = self.backgroundWorkSettingsObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } @@ -93,6 +70,33 @@ extension UsageStore { } } + var backgroundWorkSettingsObservationToken: Int { + _ = self.settings.refreshFrequency + _ = self.settings.statusChecksEnabled + _ = self.settings.sessionQuotaNotificationsEnabled + _ = self.settings.quotaWarningNotificationsEnabled + _ = self.settings.quotaWarningThresholds + _ = self.settings.quotaWarningThresholds(.session) + _ = self.settings.quotaWarningThresholds(.weekly) + _ = self.settings.quotaWarningSoundEnabled + _ = self.settings.usageBarsShowUsed + _ = self.settings.costUsageEnabled + _ = self.settings.costUsageHistoryDays + _ = self.settings.randomBlinkEnabled + _ = self.settings.configRevision + for implementation in ProviderCatalog.all { + implementation.observeSettings(self.settings) + } + _ = self.settings.multiAccountMenuLayout + _ = self.settings.tokenAccountsByProvider + _ = self.settings.mergeIcons + _ = self.settings.debugLoadingPattern + _ = self.settings.debugKeepCLISessionsAlive + _ = self.settings.historicalTrackingEnabled + _ = self.settings.providerStorageFootprintsEnabled + return 0 + } + var attachedOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? { guard self.openAIDashboardAttachmentAuthorized else { return nil } return self.openAIDashboard @@ -112,6 +116,16 @@ final class UsageStore { } } + struct AccountInfoCacheEntry { + let account: AccountInfo + let configRevision: Int + let expiresAt: Date + + func isValid(now: Date, configRevision: Int) -> Bool { + self.configRevision == configRevision && self.expiresAt > now + } + } + enum CodexCreditsSource { case none case api @@ -216,6 +230,7 @@ final class UsageStore { @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] + @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @@ -226,7 +241,9 @@ final class UsageStore { @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? + @ObservationIgnored var storageRefreshInFlightRequestKey: String? @ObservationIgnored var lastStorageRefreshSignature: String? + @ObservationIgnored var lastStorageRefreshRequestKey: String? @ObservationIgnored var lastStorageRefreshAt: Date? @ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]? @ObservationIgnored private var pathDebugRefreshTask: Task? @@ -247,6 +264,7 @@ final class UsageStore { @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 + @ObservationIgnored let accountInfoCacheTTL: TimeInterval = 30 @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored let startupBehavior: StartupBehavior @@ -305,7 +323,7 @@ final class UsageStore { self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore { self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load( - for: settings.codexVisibleAccountProjection.visibleAccounts) + for: self.freshCodexVisibleAccountsForSnapshotHydration()) } self.logStartupState() self.bindSettings() @@ -612,7 +630,7 @@ final class UsageStore { "phase": openAIWebRefreshPhase == .startup ? "startup" : "regular", ]) if shouldRefreshOpenAIWeb { - let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() + let codexDashboardGuard = self.freshCodexOpenAIWebRefreshGuard() if forceTokenUsage { await self.refreshOpenAIDashboardIfNeeded( force: true, diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 588693608..29a0f23d5 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -78,6 +78,7 @@ extension CodexBarCLI { Usage: quotakit serve [--port ] [--refresh-interval ] + [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] @@ -95,7 +96,7 @@ extension CodexBarCLI { Examples: quotakit serve - quotakit serve --port 8080 --refresh-interval 60 + quotakit serve --port 8080 --refresh-interval 60 --request-timeout 30 curl http://127.0.0.1:8080/usage?provider=all """ } @@ -209,6 +210,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] quotakit serve [--port ] [--refresh-interval ] + [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] quotakit config [--format text|json] [--json] diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1ab81023b..a0d497672 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "518924b891f96a03" + static let value = "dd86017647affbc8" } diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 585001705..ad1bd5d5b 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -981,7 +981,11 @@ public struct TTYCommandRunner { proc.executableURL = URL(fileURLWithPath: "/usr/bin/which") proc.arguments = [tool] var env = ProcessInfo.processInfo.environment - env["PATH"] = PathBuilder.effectivePATH(purposes: [.tty, .nodeTooling], env: env) + let loginPATH = LoginShellPathCache.shared.currentOrCapture() + env["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], + env: env, + loginPATH: loginPATH) proc.environment = env let pipe = Pipe() proc.standardOutput = pipe diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 05f99cb46..d3a6f2f5e 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -905,9 +905,11 @@ public enum PathBuilder { } enum LoginShellPathCapturer { + static let defaultTimeout: TimeInterval = 6.0 + static func capture( shell: String? = ProcessInfo.processInfo.environment["SHELL"], - timeout: TimeInterval = 2.0) -> [String]? + timeout: TimeInterval = Self.defaultTimeout) -> [String]? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) @@ -944,10 +946,15 @@ public final class LoginShellPathCache: @unchecked Sendable { public static let shared = LoginShellPathCache() private let lock = NSLock() + private let capture: @Sendable (String?, TimeInterval) -> [String]? private var captured: [String]? private var isCapturing = false private var callbacks: [([String]?) -> Void] = [] + init(capture: @escaping @Sendable (String?, TimeInterval) -> [String]? = LoginShellPathCapturer.capture) { + self.capture = capture + } + public var current: [String]? { self.lock.lock() let value = self.captured @@ -957,7 +964,7 @@ public final class LoginShellPathCache: @unchecked Sendable { public func captureOnce( shell: String? = ProcessInfo.processInfo.environment["SHELL"], - timeout: TimeInterval = 2.0, + timeout: TimeInterval = 6.0, onFinish: (([String]?) -> Void)? = nil) { self.lock.lock() @@ -979,8 +986,9 @@ public final class LoginShellPathCache: @unchecked Sendable { self.isCapturing = true self.lock.unlock() + let capture = self.capture DispatchQueue.global(qos: .utility).async { [weak self] in - let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) + let result = capture(shell, timeout) guard let self else { return } self.lock.lock() @@ -993,4 +1001,42 @@ public final class LoginShellPathCache: @unchecked Sendable { callbacks.forEach { $0(result) } } } + + public func currentOrCapture( + shell: String? = ProcessInfo.processInfo.environment["SHELL"], + timeout: TimeInterval = 6.0) -> [String]? + { + self.lock.lock() + if let captured { + self.lock.unlock() + return captured + } + + if self.isCapturing { + let semaphore = DispatchSemaphore(value: 0) + var callbackResult: [String]? + self.callbacks.append { result in + callbackResult = result + semaphore.signal() + } + self.lock.unlock() + let deadline = DispatchTime.now() + timeout + _ = semaphore.wait(timeout: deadline) + return callbackResult ?? self.current + } + + self.isCapturing = true + self.lock.unlock() + + let result = self.capture(shell, timeout) + self.lock.lock() + self.captured = result + self.isCapturing = false + let callbacks = self.callbacks + self.callbacks.removeAll() + self.lock.unlock() + + callbacks.forEach { $0(result) } + return result + } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index e59e90a81..93bc5d93a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,6 +26,7 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } +// swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let log = CodexBarLog.logger("alibaba-token-plan") private static let gatewayBaseURLString = "https://bailian.console.aliyun.com" @@ -306,6 +307,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return token } + if let token = await self.fetchSECTokenFromUserInfo( + cookieHeader: dashboardCookieHeader, + environment: environment, + session: session) + { + return token + } + if let cookieSECToken, !cookieSECToken.isEmpty { Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") return cookieSECToken @@ -320,6 +329,55 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } + private static func fetchSECTokenFromUserInfo( + cookieHeader: String, + environment: [String: String], + session: URLSession) async -> String? + { + let baseURL = self.consoleBaseURL(environment: environment) + let userInfoURL = baseURL.appendingPathComponent("tool/user/info.json") + var request = URLRequest(url: userInfoURL) + request.httpMethod = "GET" + request.timeoutInterval = 10 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(Self.safariLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + let referer = baseURL.absoluteString.hasSuffix("/") ? baseURL.absoluteString : "\(baseURL.absoluteString)/" + request.setValue(referer, forHTTPHeaderField: "Referer") + + guard let (data, response) = try? await session.data(for: request), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + + let expanded = self.expandedJSON(object) + guard let token = self.findFirstString(forKeys: ["secToken", "sec_token"], in: expanded), + !token.isEmpty + else { + return nil + } + + Self.log.info( + "Resolved Alibaba Token Plan sec_token from user info", + metadata: [ + "userInfoHost": userInfoURL.host ?? "unknown", + "bodyBytes": "\(data.count)", + ]) + return token + } + + private static func consoleBaseURL(environment: [String: String]) -> URL { + let dashboard = self.dashboardURL(environment: environment) + var components = URLComponents() + components.scheme = dashboard.scheme + components.host = dashboard.host + components.port = dashboard.port + return components.url ?? URL(string: Self.dashboardOriginURLString)! + } + private static func quotaURL(from rawHost: String) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } @@ -408,11 +466,27 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + if self.parseBool(dictionary["successResponse"]) == false { + if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), + statusCode == 401 || statusCode == 403 + { + throw AlibabaTokenPlanUsageError.invalidCredentials + } + let code = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary) + let message = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary) ?? + code ?? + "request was not successful" + if self.isLoginOrTokenError(code: code, message: message) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError(message) + } + if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { + let code = self.findFirstString(forKeys: ["Code", "code"], in: dictionary) let message = self.findFirstString(forKeys: ["Message", "message", "msg", "Code", "code"], in: dictionary) ?? "request was not successful" - let lowered = message.lowercased() - if lowered.contains("needlogin") || lowered.contains("login") { + if self.isLoginOrTokenError(code: code, message: message) { throw AlibabaTokenPlanUsageError.loginRequired } throw AlibabaTokenPlanUsageError.apiError(message) @@ -435,15 +509,24 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let codeText = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary)?.lowercased() let messageText = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary)? .lowercased() - if codeText?.contains("needlogin") == true || - codeText?.contains("login") == true || - messageText?.contains("log in") == true || - messageText?.contains("login") == true - { + if self.isLoginOrTokenError(code: codeText, message: messageText) { throw AlibabaTokenPlanUsageError.loginRequired } } + private static func isLoginOrTokenError(code: String?, message: String?) -> Bool { + let combined = [code, message] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + return combined.contains("needlogin") || + combined.contains("login") || + combined.contains("postonlyortokenerror") || + combined.contains("tokenerror") || + combined.contains("request has expired") || + combined.contains("refresh page") || + combined.contains("请求已经过期") + } + private static let planNameKeys = [ "planName", "plan_name", diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index e1d3bbb7f..79c8dd2b8 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -559,7 +559,7 @@ public struct AntigravityStatusProbe: Sendable { // MARK: - Port detection - private struct ProcessInfoResult { + struct ProcessInfoResult { let pid: Int let extensionPort: Int? let extensionServerCSRFToken: String? @@ -592,14 +592,25 @@ public struct AntigravityStatusProbe: Sendable { timeout: timeout, label: "antigravity-ps") - let lines = result.stdout.split(separator: "\n") - var sawAntigravity = false + return try Self.processInfo(fromProcessListOutput: result.stdout) + } + + static func processInfo(fromProcessListOutput output: String) throws -> ProcessInfoResult { + let lines = output.split(separator: "\n") + var sawTokenlessIDE = false for line in lines { let text = String(line) guard let match = Self.matchProcessLine(text) else { continue } - guard Self.isAntigravityLanguageServerCommandLine(match.command) else { continue } - sawAntigravity = true - guard let token = Self.extractFlag("--csrf_token", from: match.command) else { continue } + guard let kind = Self.antigravityProcessKind(match.command) else { continue } + // The IDE language server authenticates local requests with a + // `--csrf_token` and must keep requiring it: skip a tokenless IDE + // match so a later valid IDE server can still be found (and surface + // `missingCSRFToken` if none is). The CLI's language server exposes + // no token flag and needs none, so an empty token is allowed there. + guard let token = Self.resolvedCSRFToken(forKind: kind, command: match.command) else { + sawTokenlessIDE = true + continue + } let port = Self.extractPort("--extension_server_port", from: match.command) let extensionServerCSRFToken = Self.extractFlag("--extension_server_csrf_token", from: match.command) return ProcessInfoResult( @@ -610,7 +621,7 @@ public struct AntigravityStatusProbe: Sendable { commandLine: match.command) } - if sawAntigravity { + if sawTokenlessIDE { throw AntigravityStatusProbeError.missingCSRFToken } throw AntigravityStatusProbeError.notRunning @@ -629,9 +640,43 @@ public struct AntigravityStatusProbe: Sendable { return ProcessLineMatch(pid: pid, command: String(parts[1])) } + enum AntigravityProcessKind: Equatable { + /// IDE language server (`language_server*`). Requires a `--csrf_token`. + case ide + /// CLI language server (`agy` / `antigravity-cli`). Needs no CSRF token. + case cli + } + static func isAntigravityLanguageServerCommandLine(_ command: String) -> Bool { + self.antigravityProcessKind(command) != nil + } + + /// Classify a process command line as the Antigravity IDE language server, + /// the Antigravity CLI language server, or neither. The IDE match takes + /// precedence so its CSRF-token requirement is preserved. + static func antigravityProcessKind(_ command: String) -> AntigravityProcessKind? { let lower = command.lowercased() - return Self.isLanguageServerCommandLine(lower) && Self.isAntigravityCommandLine(lower) + if Self.isLanguageServerCommandLine(lower), Self.isAntigravityCommandLine(lower) { + return .ide + } + if Self.isAntigravityCLICommandLine(lower) { + return .cli + } + return nil + } + + /// Resolve the CSRF token to use for a matched process, or `nil` when the + /// match must be skipped. IDE matches keep requiring `--csrf_token` + /// (tokenless IDE matches are skipped). CLI matches accept an empty token + /// because the CLI's language server requires none. + static func resolvedCSRFToken(forKind kind: AntigravityProcessKind, command: String) -> String? { + if let token = extractFlag("--csrf_token", from: command) { + return token + } + switch kind { + case .ide: return nil + case .cli: return "" + } } private static func isLanguageServerCommandLine(_ lowerCommand: String) -> Bool { @@ -639,6 +684,19 @@ public struct AntigravityStatusProbe: Sendable { return lowerCommand.range(of: pattern, options: .regularExpression) != nil } + /// The Antigravity CLI (`agy` / `antigravity-cli`) hosts the same language + /// server locally as the IDE, but launches it without a `--csrf_token` flag + /// and under a different process name. Match it so usage can be probed when + /// only the CLI is running. + private static func isAntigravityCLICommandLine(_ lowerCommand: String) -> Bool { + let cliPathPattern = #"(^|[/\\])(antigravity-cli|antigravity_cli)([\s/\\]|$)"# + if lowerCommand.range(of: cliPathPattern, options: .regularExpression) != nil { + return true + } + let agyPattern = #"(^|[/\\])agy(\s|$)"# + return lowerCommand.range(of: agyPattern, options: .regularExpression) != nil + } + private static func isAntigravityCommandLine(_ command: String) -> Bool { if command.contains("--app_data_dir") && command.contains("antigravity") { return true } if command.contains("/antigravity/") || command.contains("\\antigravity\\") { return true } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift new file mode 100644 index 000000000..fa7cce370 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift @@ -0,0 +1,105 @@ +import Foundation + +enum ClaudeProbeSessionArtifactCleaner { + private static let log = CodexBarLog.logger(LogCategories.claudeProbe) + private static let maxProjectDirectoryNameLength = 200 + + @discardableResult + static func cleanupProbeSessionArtifacts( + probeDirectory: URL = ClaudeStatusProbe.probeWorkingDirectoryURL(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager fm: FileManager = .default) -> [URL] + { + let projectDirectoryName = self.claudeProjectDirectoryName(for: probeDirectory) + var visitedDirectories = Set() + var removedFiles: [URL] = [] + + for root in self.claudeConfigRoots(environment: environment, fileManager: fm) { + let projectsRoot = root.appendingPathComponent("projects", isDirectory: true) + let directories = [projectsRoot.appendingPathComponent(projectDirectoryName, isDirectory: true)] + + for directory in directories where visitedDirectories.insert(directory.path).inserted { + guard let entries = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { continue } + + for entry in entries where entry.pathExtension == "jsonl" { + let values = try? entry.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + do { + try fm.removeItem(at: entry) + removedFiles.append(entry) + } catch { + Self.log.debug( + "Claude probe session artifact cleanup skipped file", + metadata: ["error": error.localizedDescription]) + } + } + + if (try? fm.contentsOfDirectory(atPath: directory.path).isEmpty) == true { + try? fm.removeItem(at: directory) + } + } + } + + return removedFiles + } + + static func claudeProjectDirectoryName(for directory: URL) -> String { + let path = directory.path.precomposedStringWithCanonicalMapping + let sanitized = String(path.utf16.map { codeUnit in + switch codeUnit { + case 48...57, 65...90, 97...122: + Character(UnicodeScalar(codeUnit)!) + default: + "-" + } + }) + + guard sanitized.count > self.maxProjectDirectoryNameLength else { return sanitized } + return "\(sanitized.prefix(self.maxProjectDirectoryNameLength))-\(self.javaScriptHashBase36(path))" + } + + private static func javaScriptHashBase36(_ string: String) -> String { + var hash: Int32 = 0 + for codeUnit in string.utf16 { + hash = hash &* 31 &+ Int32(truncatingIfNeeded: codeUnit) + } + + let magnitude = hash < 0 ? -Int64(hash) : Int64(hash) + return String(magnitude, radix: 36) + } + + private static func claudeConfigRoots( + environment: [String: String], + fileManager fm: FileManager) -> [URL] + { + var roots: [URL] = [] + var seen = Set() + + func append(_ url: URL) { + let standardized = url.standardizedFileURL + guard seen.insert(standardized.path).inserted else { return } + roots.append(standardized) + } + + if let raw = environment["CLAUDE_CONFIG_DIR"] { + for part in raw.split(separator: ",") { + let path = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { continue } + append(URL(fileURLWithPath: path)) + } + } + + let home = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() + append(URL(fileURLWithPath: home).appendingPathComponent(".claude", isDirectory: true)) + append(URL(fileURLWithPath: home).appendingPathComponent(".config/claude", isDirectory: true)) + + if roots.isEmpty { + append(fm.homeDirectoryForCurrentUser.appendingPathComponent(".claude", isDirectory: true)) + } + return roots + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index e92dca1f8..a7a4dab25 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -112,17 +112,24 @@ public struct ClaudeStatusProbe: Sendable { "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { - await ClaudeCLISession.shared.reset() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } return snap } catch { if !keepAlive { - await ClaudeCLISession.shared.reset() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } throw error } } + private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { + await ClaudeCLISession.current.reset() + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() + guard !removed.isEmpty else { return } + Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) + } + // MARK: - Parsing helpers private struct LabelSearchContext { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index fd247289e..71a199dda 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1167,6 +1167,11 @@ extension ClaudeUsageFetcher { let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) environment["PWD"] = workingDirectory.path + defer { + ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: workingDirectory, + environment: environment) + } let result = try await SubprocessRunner.run( binary: claudeBinary, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 73475622f..a2da97221 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -356,6 +356,8 @@ public struct CursorStatusSnapshot: Sendable { public let teamOnDemandUsedUSD: Double? /// Team on-demand limit in USD public let teamOnDemandLimitUSD: Double? + /// Billing cycle start date + public let billingCycleStart: Date? /// Billing cycle reset date public let billingCycleEnd: Date? /// Membership type (e.g., "enterprise", "pro", "hobby") @@ -389,6 +391,7 @@ public struct CursorStatusSnapshot: Sendable { onDemandLimitUSD: Double?, teamOnDemandUsedUSD: Double?, teamOnDemandLimitUSD: Double?, + billingCycleStart: Date? = nil, billingCycleEnd: Date?, membershipType: String?, accountEmail: String?, @@ -406,6 +409,7 @@ public struct CursorStatusSnapshot: Sendable { self.onDemandLimitUSD = onDemandLimitUSD self.teamOnDemandUsedUSD = teamOnDemandUsedUSD self.teamOnDemandLimitUSD = teamOnDemandLimitUSD + self.billingCycleStart = billingCycleStart self.billingCycleEnd = billingCycleEnd self.membershipType = membershipType self.accountEmail = accountEmail @@ -428,9 +432,13 @@ public struct CursorStatusSnapshot: Sendable { self.planPercentUsed } + let billingCycleWindowMinutes = Self.billingCycleWindowMinutes( + start: self.billingCycleStart, + end: self.billingCycleEnd) + let primary = RateWindow( usedPercent: primaryUsedPercent, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) @@ -438,7 +446,7 @@ public struct CursorStatusSnapshot: Sendable { let secondary: RateWindow? = self.autoPercentUsed.map { pct in RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } @@ -447,7 +455,7 @@ public struct CursorStatusSnapshot: Sendable { let tertiary: RateWindow? = self.apiPercentUsed.map { pct in RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } @@ -502,6 +510,14 @@ public struct CursorStatusSnapshot: Sendable { return "Resets " + formatter.string(from: date) } + private static func billingCycleWindowMinutes(start: Date?, end: Date?) -> Int? { + guard let start, + let end + else { return nil } + let minutes = Int((end.timeIntervalSince(start) / 60).rounded()) + return minutes > 0 ? minutes : nil + } + private static func formatMembershipType(_ type: String) -> String { switch type.lowercased() { case "enterprise": @@ -1018,12 +1034,14 @@ public struct CursorStatusProbe: Sendable { rawJSON: String?, requestUsage: CursorUsageResponse? = nil) -> CursorStatusSnapshot { - // Parse billing cycle end date - let billingCycleEnd: Date? = summary.billingCycleEnd.flatMap { dateString in + func parseBillingCycleDate(_ dateString: String?) -> Date? { + guard let dateString else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.date(from: dateString) ?? ISO8601DateFormatter().date(from: dateString) } + let billingCycleStart = parseBillingCycleDate(summary.billingCycleStart) + let billingCycleEnd = parseBillingCycleDate(summary.billingCycleEnd) // Convert cents to USD (plan percent derives from raw values to avoid percent unit mismatches). // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. @@ -1119,6 +1137,7 @@ public struct CursorStatusProbe: Sendable { onDemandLimitUSD: onDemandLimit, teamOnDemandUsedUSD: teamOnDemandUsed, teamOnDemandLimitUSD: teamOnDemandLimit, + billingCycleStart: billingCycleStart, billingCycleEnd: billingCycleEnd, membershipType: summary.membershipType, accountEmail: userInfo?.email, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index bf93d57a7..56e77e2c7 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -7,6 +7,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let remainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" public var displayName: String { @@ -57,6 +58,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath) } + public var tokenPlanRemainsURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.tokenPlanRemainsPath) + } + public var dashboardURL: URL { var components = URLComponents(string: self.baseURLString)! components.path = "/" + Self.codingPlanPath diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift index 3f16aef95..ab68319a0 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift @@ -24,7 +24,11 @@ public enum MiniMaxCookieHeader { #"(?i)(?:--cookie|-b)\s*([^\s]+)"#, ] private static let authorizationPattern = #"(?i)\bauthorization:\s*bearer\s+([A-Za-z0-9._\-+=/]+)"# - private static let groupIDPattern = #"(?i)\bgroup[_]?id=([0-9]{4,})"# + private static let groupIDPatterns = [ + #"(?i)\bx-group-id:\s*([0-9]{4,})"#, + #"(?i)\bminimax_group_id_v2=([0-9]{4,})"#, + #"(?i)\bgroup[_]?id=([0-9]{4,})"#, + ] public static func override(from raw: String?) -> MiniMaxCookieOverride? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -34,7 +38,7 @@ public enum MiniMaxCookieHeader { } guard let cookie = self.normalized(from: raw) else { return nil } let authorizationToken = self.extractFirst(pattern: self.authorizationPattern, text: raw) - let groupID = self.extractFirst(pattern: self.groupIDPattern, text: raw) + let groupID = self.extractFirst(patterns: self.groupIDPatterns, text: raw) return MiniMaxCookieOverride( cookieHeader: cookie, authorizationToken: authorizationToken, @@ -102,4 +106,13 @@ public enum MiniMaxCookieHeader { let value = text[captureRange].trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : String(value) } + + private static func extractFirst(patterns: [String], text: String) -> String? { + for pattern in patterns { + if let value = self.extractFirst(pattern: pattern, text: text) { + return value + } + } + return nil + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift new file mode 100644 index 000000000..6ad41bb9e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift @@ -0,0 +1,46 @@ +import Foundation + +enum MiniMaxDecoding { + static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKeys keys: [K]) -> Double? { + for key in keys { + if let value = self.decodeDouble(container, forKey: key) { + return value + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift new file mode 100644 index 000000000..6dc8838aa --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift @@ -0,0 +1,64 @@ +struct MiniMaxModelRemains: Decodable { + let modelName: String? + let currentIntervalTotalCount: Int? + let currentIntervalUsageCount: Int? + let startTime: Int? + let endTime: Int? + let remainsTime: Int? + let intervalBoostPermille: Int? + let currentIntervalRemainingPercent: Double? + let currentIntervalStatus: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + let weeklyBoostPermille: Int? + let currentWeeklyRemainingPercent: Double? + let currentWeeklyStatus: Int? + + private enum CodingKeys: String, CodingKey { + case modelName = "model_name" + case currentIntervalTotalCount = "current_interval_total_count" + case currentIntervalUsageCount = "current_interval_usage_count" + case startTime = "start_time" + case endTime = "end_time" + case remainsTime = "remains_time" + case intervalBoostPermille = "interval_boost_permill" + case currentIntervalRemainingPercent = "current_interval_remaining_percent" + case currentIntervalStatus = "current_interval_status" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + case weeklyBoostPermille = "weekly_boost_permill" + case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" + case currentWeeklyStatus = "current_weekly_status" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) + self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) + self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) + self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) + self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.intervalBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .intervalBoostPermille) + self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentIntervalRemainingPercent) + self.currentIntervalStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalStatus) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.weeklyBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .weeklyBoostPermille) + self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentWeeklyRemainingPercent) + self.currentWeeklyStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyStatus) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index 488b0b0f5..71101e8ca 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -34,6 +34,9 @@ public struct MiniMaxServiceUsage: Sendable { /// The percentage of quota used (0-100) public let percent: Double + /// Whether this quota window is explicitly unlimited. + public let isUnlimited: Bool + /// The timestamp when the quota will reset, if available public let resetsAt: Date? @@ -49,6 +52,10 @@ public struct MiniMaxServiceUsage: Sendable { public var displayName: String { let normalized = self.serviceType.lowercased() return switch normalized { + case "general": + "General" + case "video": + "Video" case "text-generation": "Text Generation" case "text-to-speech": @@ -80,6 +87,11 @@ public struct MiniMaxServiceUsage: Sendable { } } + public var isPrimaryTextQuotaLane: Bool { + let normalized = self.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "general" || self.displayName == "Text Generation" + } + /// Creates a new MiniMaxServiceUsage instance. /// /// - Parameters: @@ -98,6 +110,7 @@ public struct MiniMaxServiceUsage: Sendable { usage: Int, limit: Int, percent: Double, + isUnlimited: Bool = false, resetsAt: Date?, resetDescription: String) { @@ -107,6 +120,7 @@ public struct MiniMaxServiceUsage: Sendable { self.usage = usage self.limit = limit self.percent = percent + self.isUnlimited = isUnlimited self.resetsAt = resetsAt self.resetDescription = resetDescription } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift new file mode 100644 index 000000000..56a2d45c0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -0,0 +1,270 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct MiniMaxSubscriptionMetadata: Sendable, Equatable { + let planName: String? + let subscriptionExpiresAt: Date? + let subscriptionRenewsAt: Date? +} + +enum MiniMaxSubscriptionMetadataFetcher { + private static let comboPath = "v1/api/openplatform/charge/combo/cycle_audio_resource_package" + + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> MiniMaxSubscriptionMetadata + { + let url = try self.resolveComboURL(region: region, environment: environment) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("zh-CN,zh;q=0.9", forHTTPHeaderField: "accept-language") + request.setValue(self.platformOrigin(region: region).absoluteString, forHTTPHeaderField: "origin") + request.setValue(self.platformOrigin(region: region).absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parse(data: response.data) + } + + static func parse(data: Data) throws -> MiniMaxSubscriptionMetadata { + let object = try JSONSerialization.jsonObject(with: data, options: []) + try self.validateBaseResponse(in: object) + let planName = self.findPlanName(in: object) + let subscriptionExpiresAt = self.findDate( + in: object, + keys: ["current_subscribe_end_time_ts", "current_subscribe_end_time"]) + let subscriptionRenewsAt = self.findDate( + in: object, + keys: ["renewal_trigger_time_ts", "renewal_date"]) + guard planName != nil || subscriptionExpiresAt != nil || subscriptionRenewsAt != nil else { + throw MiniMaxUsageError.parseFailed("MiniMax combo metadata did not include subscription metadata.") + } + return MiniMaxSubscriptionMetadata( + planName: planName, + subscriptionExpiresAt: subscriptionExpiresAt, + subscriptionRenewsAt: subscriptionRenewsAt) + } + + static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { + let host = MiniMaxSettingsReader.hostOverride(environment: environment) ?? self.defaultWebHost(region: region) + guard var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)"), + components.host?.isEmpty == false + else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } + guard components.scheme?.lowercased() == "https" else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host must use HTTPS.") + } + components.path = "/" + Self.comboPath + components.queryItems = [ + URLQueryItem(name: "biz_line", value: "2"), + URLQueryItem(name: "cycle_type", value: "3"), + URLQueryItem(name: "resource_package_type", value: "7"), + ] + guard let url = components.url else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } + return url + } + + private static func validateBaseResponse(in object: Any) throws { + guard let root = object as? [String: Any], + let baseResp = root["base_resp"] as? [String: Any] + else { return } + let status = self.intValue(baseResp["status_code"]) ?? 0 + guard status != 0 else { return } + let message = (baseResp["status_msg"] as? String) ?? "MiniMax combo metadata error \(status)" + if status == 1004 || message.lowercased().contains("cookie") { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError(message) + } + + private static func findPlanName(in object: Any) -> String? { + let currentSubscriptionStrings = self.collectCurrentSubscriptionStrings(in: object) + if let tokenPlan = self.bestPlanName(in: currentSubscriptionStrings) { + return tokenPlan + } + + let strings = self.collectStrings(in: object) + if let tokenPlan = self.bestPlanName(in: strings) { + return tokenPlan + } + + return nil + } + + private static func bestPlanName(in strings: [String]) -> String? { + let tokenPlans = strings.compactMap { value -> (rank: Int, value: String)? in + guard let rank = self.tokenPlanRank(value) else { return nil } + return (rank, value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if let tokenPlan = tokenPlans.min(by: { lhs, rhs in + lhs.rank == rhs.rank ? lhs.value.count < rhs.value.count : lhs.rank < rhs.rank + }) { + return tokenPlan.value + } + return strings.first { value in + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + return ["plus", "max", "ultra"].contains(cleaned.lowercased()) + } + } + + private static func collectCurrentSubscriptionStrings(in object: Any) -> [String] { + guard let dictionary = object as? [String: Any] else { + if let array = object as? [Any] { + return array.flatMap(self.collectCurrentSubscriptionStrings(in:)) + } + return [] + } + + return dictionary.flatMap { key, value in + let lowercasedKey = key.lowercased() + let stringsForCurrentField: [String] = if lowercasedKey == "current_subscribe" || + lowercasedKey == "current_subscription" || + lowercasedKey.contains("current_subscribe") || + lowercasedKey.contains("current_subscription") || + lowercasedKey.contains("current_plan") + { + self.collectStrings(in: value) + } else { + [] + } + return stringsForCurrentField + self.collectCurrentSubscriptionStrings(in: value) + } + } + + private static func tokenPlanRank(_ value: String) -> Int? { + let lower = value.lowercased() + if lower.contains("tokenplanplus") { return 0 } + if lower.contains("tokenplanmax") { return 1 } + if lower.contains("tokenplanultra") { return 2 } + if lower.contains("token plan"), lower.contains("plus") || lower.contains("max") || lower.contains("ultra") { + return 3 + } + return nil + } + + private static func collectStrings(in object: Any) -> [String] { + if let string = object as? String { return [string] } + if let array = object as? [Any] { return array.flatMap(self.collectStrings(in:)) } + if let dictionary = object as? [String: Any] { + return dictionary.sorted { $0.key < $1.key }.flatMap { self.collectStrings(in: $0.value) } + } + return [] + } + + private static func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let string = value as? String { return Int(string) } + return nil + } + + private static func findDate(in object: Any, keys: [String]) -> Date? { + keys.lazy.compactMap { key in + self.findValue(forKey: key, in: object).flatMap(self.dateValue(from:)) + }.first + } + + private static func findValue(forKey key: String, in object: Any) -> Any? { + if let dictionary = object as? [String: Any] { + if let value = dictionary[key] { return value } + for nested in dictionary.values { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + if let array = object as? [Any] { + for nested in array { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + return nil + } + + private static func dateValue(from value: Any) -> Date? { + if let int = value as? Int { + return self.dateValue(fromNumber: Double(int)) + } + if let double = value as? Double { + return self.dateValue(fromNumber: double) + } + guard let string = value as? String else { return nil } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let numeric = Double(trimmed) { + return self.dateValue(fromNumber: numeric) + } + return self.dateFromMonthDayYear(trimmed) + } + + private static func dateValue(fromNumber value: Double) -> Date? { + guard value.isFinite, value > 0 else { return nil } + let seconds = value > 10_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + private static func dateFromMonthDayYear(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "MM/dd/yyyy" + return formatter.date(from: value) + } + + private static func defaultWebHost(region: MiniMaxAPIRegion) -> String { + switch region { + case .global: "https://www.minimax.io" + case .chinaMainland: "https://www.minimaxi.com" + } + } + + private static func platformOrigin(region: MiniMaxAPIRegion) -> URL { + switch region { + case .global: URL(string: "https://platform.minimax.io")! + case .chinaMainland: URL(string: "https://platform.minimaxi.com")! + } + } +} + +extension MiniMaxUsageFetcher { + static func attachingSubscriptionMetadataIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> MiniMaxUsageSnapshot + { + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + guard resolvedGroupID?.isEmpty == false else { return snapshot } + do { + let metadata = try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withSubscriptionMetadata(metadata) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + Self.log.debug("MiniMax subscription metadata unavailable: \(error.localizedDescription)") + return snapshot + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift new file mode 100644 index 000000000..4cd856816 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "MiniMax credentials are invalid or expired." + case let .networkError(message): + "MiniMax network error: \(message)" + case let .apiError(message): + "MiniMax API error: \(message)" + case let .parseFailed(message): + "Failed to parse MiniMax coding plan: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 444daa705..d259174a1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -8,6 +8,7 @@ public struct MiniMaxUsageFetcher: Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let codingPlanRemainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" private static let billingHistoryLimit = 100 private struct RemainsContext { @@ -15,7 +16,7 @@ public struct MiniMaxUsageFetcher: Sendable { let groupID: String? } - private struct WebFetchContext { + struct WebFetchContext { let cookie: String let authorizationToken: String? let region: MiniMaxAPIRegion @@ -44,7 +45,10 @@ public struct MiniMaxUsageFetcher: Sendable { environment: environment, transport: transport) do { - let snapshot = try await self.fetchCodingPlanHTML(context: context, now: now) + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanHTML(context: context, now: now), + context: context, + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -53,12 +57,15 @@ public struct MiniMaxUsageFetcher: Sendable { } catch let error as MiniMaxUsageError { if case .parseFailed = error { Self.log.debug("MiniMax coding plan HTML parse failed, trying remains API") - let snapshot = try await self.fetchCodingPlanRemains( + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanRemains( + context: context, + remainsContext: RemainsContext( + authorizationToken: authorizationToken, + groupID: groupID), + now: now), context: context, - remainsContext: RemainsContext( - authorizationToken: authorizationToken, - groupID: groupID), - now: now) + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -112,7 +119,35 @@ public struct MiniMaxUsageFetcher: Sendable { now: Date, transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { - var request = URLRequest(url: region.apiRemainsURL) + var lastError: Error? + for remainsURL in [region.tokenPlanRemainsURL, region.apiRemainsURL] { + do { + return try await self.fetchAPIUsageOnce( + apiToken: apiToken, + remainsURL: remainsURL, + now: now, + transport: transport) + } catch let error as MiniMaxUsageError { + lastError = error + guard remainsURL == region.tokenPlanRemainsURL, + self.shouldTryLegacyAPIEndpoint(after: error) + else { + throw error + } + Self.log.debug("MiniMax token-plan API failed, trying legacy coding-plan endpoint") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax API remains URL.") + } + + private static func fetchAPIUsageOnce( + apiToken: String, + remainsURL: URL, + now: Date, + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot + { + var request = URLRequest(url: remainsURL) request.httpMethod = "GET" request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") @@ -122,10 +157,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -137,13 +170,31 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } return snapshot } + private static func shouldTryLegacyAPIEndpoint(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + true + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + private static func fetchCodingPlanHTML( context: WebFetchContext, now: Date) async throws -> MiniMaxUsageSnapshot @@ -171,10 +222,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -189,7 +238,14 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -211,7 +267,30 @@ public struct MiniMaxUsageFetcher: Sendable { remainsContext: RemainsContext, now: Date) async throws -> MiniMaxUsageSnapshot { - let baseRemainsURL = self.resolveRemainsURL(region: context.region, environment: context.environment) + var lastError: Error? + for baseRemainsURL in self.resolveRemainsURLs(region: context.region, environment: context.environment) { + do { + return try await self.fetchCodingPlanRemainsOnce( + baseRemainsURL: baseRemainsURL, + context: context, + remainsContext: remainsContext, + now: now) + } catch let error as MiniMaxUsageError { + lastError = error + guard self.shouldTryNextRemainsURL(after: error) else { throw error } + Self.log.debug("MiniMax remains API failed for \(baseRemainsURL.host ?? "unknown host"), trying next") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax remains URL.") + } + + private static func fetchCodingPlanRemainsOnce( + baseRemainsURL: URL, + context: WebFetchContext, + remainsContext: RemainsContext, + now: Date) async throws -> MiniMaxUsageSnapshot + { let remainsURL = self.appendGroupID(remainsContext.groupID, to: baseRemainsURL) var request = URLRequest(url: remainsURL) request.httpMethod = "GET" @@ -236,10 +315,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -254,7 +331,14 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -268,6 +352,33 @@ public struct MiniMaxUsageFetcher: Sendable { return try MiniMaxUsageParser.parse(html: html, now: now) } + private static func shouldTryNextRemainsURL(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + false + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + + private static func normalizedTransportError(_ error: Error) -> Error { + if error is MiniMaxUsageError || error is CancellationError { + return error + } + if let urlError = error as? URLError { + if urlError.code == .cancelled { + return error + } + if urlError.code == .badServerResponse { + return MiniMaxUsageError.networkError("Invalid response") + } + return MiniMaxUsageError.networkError(urlError.localizedDescription) + } + return error + } + private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, @@ -427,6 +538,50 @@ public struct MiniMaxUsageFetcher: Sendable { return region.remainsURL } + static func resolveRemainsURLs( + region: MiniMaxAPIRegion, + environment: [String: String]) -> [URL] + { + if let override = MiniMaxSettingsReader.remainsURL(environment: environment) { + return [override] + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment), + let hostURL = self.url(from: host, path: Self.codingPlanRemainsPath) + { + return [hostURL] + } + + let primary = region.remainsURL + let webCandidates = self.webRemainsFallbackURLs(region: region) + return self.deduplicated([primary] + webCandidates) + } + + static func resolveTokenPlanRemainsURL(region: MiniMaxAPIRegion) -> URL { + region.tokenPlanRemainsURL + } + + private static func webRemainsFallbackURLs(region: MiniMaxAPIRegion) -> [URL] { + let hosts = switch region { + case .global: + ["https://www.minimax.io"] + case .chinaMainland: + ["https://www.minimaxi.com"] + } + return hosts.compactMap { self.url(from: $0, path: Self.codingPlanRemainsPath) } + } + + private static func deduplicated(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var result: [URL] = [] + for url in urls { + let key = url.absoluteString + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(url) + } + return result + } + static func resolveBillingHistoryURL( region: MiniMaxAPIRegion, environment: [String: String], @@ -542,6 +697,7 @@ struct MiniMaxCodingPlanData: Decodable { let comboTitle: String? let currentPlanTitle: String? let currentComboCard: MiniMaxComboCard? + let pointsBalance: Double? let modelRemains: [MiniMaxModelRemains] private enum CodingKeys: String, CodingKey { @@ -551,6 +707,11 @@ struct MiniMaxCodingPlanData: Decodable { case comboTitle = "combo_title" case currentPlanTitle = "current_plan_title" case currentComboCard = "current_combo_card" + case pointsBalance = "points_balance" + case pointBalance = "point_balance" + case creditsBalance = "credits_balance" + case creditBalance = "credit_balance" + case balance case modelRemains = "model_remains" } @@ -562,6 +723,13 @@ struct MiniMaxCodingPlanData: Decodable { self.comboTitle = try container.decodeIfPresent(String.self, forKey: .comboTitle) self.currentPlanTitle = try container.decodeIfPresent(String.self, forKey: .currentPlanTitle) self.currentComboCard = try container.decodeIfPresent(MiniMaxComboCard.self, forKey: .currentComboCard) + self.pointsBalance = MiniMaxDecoding.decodeDouble(container, forKeys: [ + .pointsBalance, + .pointBalance, + .creditsBalance, + .creditBalance, + .balance, + ]) self.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] } } @@ -570,49 +738,6 @@ struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable { - let modelName: String? - let currentIntervalTotalCount: Int? - let currentIntervalUsageCount: Int? - let startTime: Int? - let endTime: Int? - let remainsTime: Int? - let currentWeeklyTotalCount: Int? - let currentWeeklyUsageCount: Int? - let weeklyStartTime: Int? - let weeklyEndTime: Int? - let weeklyRemainsTime: Int? - - private enum CodingKeys: String, CodingKey { - case modelName = "model_name" - case currentIntervalTotalCount = "current_interval_total_count" - case currentIntervalUsageCount = "current_interval_usage_count" - case startTime = "start_time" - case endTime = "end_time" - case remainsTime = "remains_time" - case currentWeeklyTotalCount = "current_weekly_total_count" - case currentWeeklyUsageCount = "current_weekly_usage_count" - case weeklyStartTime = "weekly_start_time" - case weeklyEndTime = "weekly_end_time" - case weeklyRemainsTime = "weekly_remains_time" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) - self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) - self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) - self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) - self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) - self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) - self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) - self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) - self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) - self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) - self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) - } -} - struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? @@ -676,42 +801,6 @@ struct MiniMaxServiceItem: Decodable { } } -enum MiniMaxDecoding { - static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - return nil - } - - static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Double(trimmed) - } - return nil - } -} - enum MiniMaxUsageParser { static func decodePayload(data: Data) throws -> MiniMaxCodingPlanPayload { let decoder = JSONDecoder() @@ -799,25 +888,31 @@ enum MiniMaxUsageParser { windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, start: item.startTime, end: item.endTime, - remainsTime: item.remainsTime), + remainsTime: item.remainsTime, + boostPermille: item.intervalBoostPermille), now: now) { services.append(intervalService) } // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. - if self.isTextGenerationModelName(modelName), + if self.shouldRenderWeeklyWindow(for: modelName), let weeklyService = self.makeServiceUsage( ServiceUsageInput( serviceType: serviceTypeIdentifier, windowTypeOverride: "Weekly", total: item.currentWeeklyTotalCount, remaining: item.currentWeeklyUsageCount, + remainingPercent: item.currentWeeklyRemainingPercent, + status: item.currentWeeklyStatus, start: item.weeklyStartTime, end: item.weeklyEndTime, - remainsTime: item.weeklyRemainsTime), + remainsTime: item.weeklyRemainsTime, + boostPermille: item.weeklyBoostPermille), now: now) { services.append(weeklyService) @@ -826,9 +921,15 @@ enum MiniMaxUsageParser { // Use first service for backward compatibility fields let first = payload.data.modelRemains.first - let total = first?.currentIntervalTotalCount - let remaining = first?.currentIntervalUsageCount - let usedPercent = self.usedPercent(total: total, remaining: remaining) + let hasPercentQuota = first?.currentIntervalRemainingPercent != nil + let total = hasPercentQuota && first?.currentIntervalTotalCount == 0 ? nil : first?.currentIntervalTotalCount + let remaining = hasPercentQuota && first?.currentIntervalUsageCount == 0 + ? nil + : first?.currentIntervalUsageCount + let usedPercent = self.usedPercent( + total: total, + remaining: remaining, + remainingPercent: first?.currentIntervalRemainingPercent) let windowMinutes = self.windowMinutes( start: self.dateFromEpoch(first?.startTime), @@ -856,16 +957,24 @@ enum MiniMaxUsageParser { usedPercent: usedPercent, resetsAt: resetsAt, updatedAt: now, - services: services.isEmpty ? nil : services) + services: services.isEmpty ? nil : services, + pointsBalance: payload.data.pointsBalance) } - private static func usedPercent(total: Int?, remaining: Int?) -> Double? { + private static func usedPercent(total: Int?, remaining: Int?, remainingPercent: Double? = nil) -> Double? { + if let remainingPercent { + return self.usedPercent(remainingPercent: remainingPercent) + } guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) let percent = Double(used) / Double(total) * 100 return min(100, max(0, percent)) } + private static func usedPercent(remainingPercent: Double) -> Double { + min(100, max(0, 100 - remainingPercent)) + } + private static func dateFromEpoch(_ value: Int?) -> Date? { guard let raw = value else { return nil } if raw > 1_000_000_000_000 { @@ -893,40 +1002,53 @@ enum MiniMaxUsageParser { } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { - let candidates = [ + [ data.currentSubscribeTitle, data.planName, data.comboTitle, data.currentPlanTitle, data.currentComboCard?.title, - ].compactMap(\.self) + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? self.inferredTokenPlanName(data: data) + } - for candidate in candidates { - let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } + private static func inferredTokenPlanName(data: MiniMaxCodingPlanData) -> String? { + let hasTextGeneration = data.modelRemains.contains { $0.modelName.map(self.isTextGenerationModelName) ?? false } + let hasUnavailableVideo = data.modelRemains.contains { item in + item.modelName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "video" && + self.isUnavailableQuotaPlaceholder(ServiceUsageInput( + serviceType: "Text to Video", + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, + start: nil, + end: nil, + remainsTime: nil, + boostPermille: nil)) } - return nil + return hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } private static func parsePlanName(html: String, text: String) -> String? { - let candidates = [ + [ self.extractFirst(pattern: #"(?i)"planName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"plan"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"packageName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)Coding\s*Plan\s*([A-Za-z0-9][A-Za-z0-9\s._-]{0,32})"#, text: text), - ].compactMap(\.self) - - for candidate in candidates { - let cleaned = UsageFormatter.cleanPlanName(candidate) - let trimmed = cleaned - .replacingOccurrences( - of: #"(?i)\s+available\s+usage.*$"#, - with: "", - options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil + ] + .compactMap(\.self) + .map { + UsageFormatter.cleanPlanName($0) + .replacingOccurrences( + of: #"(?i)\s+available\s+usage.*$"#, + with: "", + options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .first { !$0.isEmpty } } private static func parseNextData(html: String, now: Date) -> MiniMaxUsageSnapshot? { @@ -987,6 +1109,12 @@ enum MiniMaxUsageParser { if normalized["base_resp"] == nil, let value = normalized["baseResp"] { normalized["base_resp"] = value } + if normalized["points_balance"] == nil, let value = normalized["pointsBalance"] { + normalized["points_balance"] = value + } + if normalized["credits_balance"] == nil, let value = normalized["creditsBalance"] { + normalized["credits_balance"] = value + } if let data = normalized["data"] as? [String: Any] { normalized["data"] = self.normalizeCodingPlanPayload(data) @@ -1388,15 +1516,16 @@ enum MiniMaxUsageParser { let windowTypeOverride: String? let total: Int? let remaining: Int? + let remainingPercent: Double? + let status: Int? let start: Int? let end: Int? let remainsTime: Int? + let boostPermille: Int? } private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { - guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } - let used = max(0, total - remaining) - if used == 0, total == 0 { return nil } + guard self.shouldRenderQuotaWindow(input) else { return nil } let startTime = self.dateFromEpoch(input.start) let endTime = self.dateFromEpoch(input.end) @@ -1408,33 +1537,91 @@ enum MiniMaxUsageParser { timeRange = weeklyRange } - let resetsAt = self.resetsAt(end: endTime, remains: input.remainsTime, now: now) - let resetDescription = self.resetDescription( - for: windowType, - timeRange: timeRange, - now: now, - resetsAt: resetsAt) + let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) + let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = if isUnlimited { + "Unlimited" + } else { + self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + } + let limit: Int + let usage: Int + let percent: Double + if isUnlimited { + percent = 0 + limit = 0 + usage = 0 + } else if let remainingPercent = input.remainingPercent { + let quotaLimit = self.percentQuotaLimit(boostPermille: input.boostPermille) + percent = self.usedPercent(remainingPercent: remainingPercent) + limit = quotaLimit + usage = Int((percent * Double(quotaLimit) / 100.0).rounded()) + } else { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + percent = Double(used) / Double(total) * 100.0 + limit = total + usage = used + } - let percent = Double(used) / Double(total) * 100.0 return MiniMaxServiceUsage( serviceType: input.serviceType, windowType: windowType, timeRange: timeRange, - usage: used, - limit: total, + usage: usage, + limit: limit, percent: min(100.0, max(0.0, percent)), + isUnlimited: isUnlimited, resetsAt: resetsAt, resetDescription: resetDescription) } + private static func percentQuotaLimit(boostPermille: Int?) -> Int { + guard let boostPermille, boostPermille > 0 else { return 100 } + return max(1, Int((Double(boostPermille) / 10.0).rounded())) + } + + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { + // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in + // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. + !self.isUnavailableQuotaPlaceholder(input) + } + + private static func isUnavailableQuotaPlaceholder(_ input: ServiceUsageInput) -> Bool { + if let windowType = input.windowTypeOverride, self.isUnlimitedQuotaWindow(input, windowType: windowType) { + return false + } + return input.status == 3 && + (input.total ?? 0) == 0 && + (input.remaining ?? 0) == 0 && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } + + private static func isUnlimitedQuotaWindow(_ input: ServiceUsageInput, windowType: String) -> Bool { + let normalizedService = input.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedWindow = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let unlimitedServices = ["text generation", "general"] + return input.status == 3 && + unlimitedServices.contains(normalizedService) && + normalizedWindow == "weekly" && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } + private static func mapModelNameToServiceType(modelName: String) -> String { - // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. + let lower = modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lower == "general" || lower == "video" { + return lower + } + + // Legacy text model names are separate from Token Plan's `general` bucket. if self.isTextGenerationModelName(modelName) { return "Text Generation" } - let lower = modelName.lowercased() - // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. if lower.contains("speech") { return "Text to Speech" @@ -1466,7 +1653,11 @@ enum MiniMaxUsageParser { private static func isTextGenerationModelName(_ modelName: String) -> Bool { let lower = modelName.lowercased() - return lower.contains("minimax-m") || lower.hasPrefix("m2.") + return lower == "general" || lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func shouldRenderWeeklyWindow(for modelName: String) -> Bool { + self.isTextGenerationModelName(modelName) } private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { @@ -1480,23 +1671,3 @@ enum MiniMaxUsageParser { return "\(start) - \(end)(UTC+8)" } } - -public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { - case invalidCredentials - case networkError(String) - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .invalidCredentials: - "MiniMax credentials are invalid or expired." - case let .networkError(message): - "MiniMax network error: \(message)" - case let .apiError(message): - "MiniMax API error: \(message)" - case let .parseFailed(message): - "Failed to parse MiniMax coding plan: \(message)" - } - } -} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift new file mode 100644 index 000000000..17532c9fa --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -0,0 +1,45 @@ +import Foundation + +extension MiniMaxUsageSnapshot { + func withPlanNameIfAvailable(_ planName: String?) -> MiniMaxUsageSnapshot { + let cleaned = planName?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let cleaned, !cleaned.isEmpty else { return self } + return MiniMaxUsageSnapshot( + planName: cleaned, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } + + func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: metadata.planName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + } +} + +extension String { + fileprivate var nonEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f66de9d23..fe73270d0 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -11,40 +11,59 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + public let pointsBalance: Double? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public var primaryService: MiniMaxServiceUsage? { - // Priority: "Text Generation" > first service - if let services = self.services, !services.isEmpty { - if let textGenService = services.first(where: { $0.displayName == "Text Generation" }) { - return textGenService - } - return services.first - } - return nil + self.orderedQuotaServices.first } public var secondaryService: MiniMaxServiceUsage? { - // Return second service for RateWindow.secondary if exists - guard let services = self.services, services.count >= 2 else { return nil } - // If we have Text Generation as primary, get the next non-Text Generation service - if let textGenIndex = services.firstIndex(where: { $0.displayName == "Text Generation" }) { - // If Text Generation is first, secondary is second - if textGenIndex == 0 { - return services[1] - } - // If Text Generation is not first, secondary could be first or second depending on count - return services[0] - } - // No Text Generation found, just return second service + let services = self.orderedQuotaServices + guard services.count >= 2 else { return nil } return services[1] } public var tertiaryService: MiniMaxServiceUsage? { - // Return third service for RateWindow.tertiary if exists - guard let services = self.services, services.count >= 3 else { return nil } + let services = self.orderedQuotaServices + guard services.count >= 3 else { return nil } return services[2] } + public var orderedQuotaServices: [MiniMaxServiceUsage] { + guard let services, !services.isEmpty else { return [] } + return services.enumerated().sorted { lhs, rhs in + let lhsRank = self.quotaServiceRank(lhs.element, originalIndex: lhs.offset) + let rhsRank = self.quotaServiceRank(rhs.element, originalIndex: rhs.offset) + if lhsRank.primary != rhsRank.primary { + return lhsRank.primary < rhsRank.primary + } + if lhsRank.window != rhsRank.window { + return lhsRank.window < rhsRank.window + } + return lhsRank.originalIndex < rhsRank.originalIndex + }.map(\.element) + } + + private func quotaServiceRank( + _ service: MiniMaxServiceUsage, + originalIndex: Int) -> (primary: Int, window: Int, originalIndex: Int) + { + ( + primary: service.isPrimaryTextQuotaLane ? 0 : 1, + window: self.quotaWindowRank(service), + originalIndex: originalIndex) + } + + private func quotaWindowRank(_ service: MiniMaxServiceUsage) -> Int { + let window = service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if window == "weekly" { + return 1 + } + return 0 + } + public init( planName: String?, availablePrompts: Int?, @@ -55,7 +74,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: Date?, updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, - billingSummary: MiniMaxBillingSummary? = nil) + billingSummary: MiniMaxBillingSummary? = nil, + pointsBalance: Double? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -67,6 +89,9 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.pointsBalance = pointsBalance + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -80,7 +105,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: self.resetsAt, updatedAt: self.updatedAt, services: self.services, - billingSummary: billingSummary) + billingSummary: billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) } } @@ -104,8 +132,10 @@ extension MiniMaxUsageSnapshot { primary: primaryWindow, secondary: secondaryWindow, tertiary: tertiaryWindow, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -131,8 +161,10 @@ extension MiniMaxUsageSnapshot { primary: primary, secondary: nil, tertiary: nil, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -178,6 +210,9 @@ extension MiniMaxUsageSnapshot { if windowType == "today" { return 24 * 60 } + if windowType == "weekly" { + return 7 * 24 * 60 + } // Handle time duration formats like "5 hours", "30 minutes", etc. let components = windowType.split(separator: " ") @@ -197,4 +232,14 @@ extension MiniMaxUsageSnapshot { return nil } } + + private func pointsBalanceSnapshot() -> ProviderCostSnapshot? { + guard let pointsBalance, pointsBalance >= 0 else { return nil } + return ProviderCostSnapshot( + used: pointsBalance, + limit: 0, + currencyCode: "Points", + period: "MiniMax points balance", + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index e71f36370..0727067ab 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -55,7 +55,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { // swiftformat:enable sortDeclarations -public enum IconStyle: Sendable, CaseIterable { +public enum IconStyle: String, Sendable, CaseIterable { case codex case openai case claude diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index d77f68e0c..c4b7ab6ca 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -30,9 +30,14 @@ public struct RateWindow: Codable, Equatable, Sendable { public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { if self.resetsAt != nil { return self } guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + let windowMinutes = if let windowMinutes = self.windowMinutes, windowMinutes > 0 { + windowMinutes + } else { + cached?.windowMinutes + } return RateWindow( usedPercent: self.usedPercent, - windowMinutes: self.windowMinutes ?? cached?.windowMinutes, + windowMinutes: windowMinutes, resetsAt: cachedReset, resetDescription: self.resetDescription ?? cached?.resetDescription, nextRegenPercent: self.nextRegenPercent) @@ -110,6 +115,8 @@ public struct UsageSnapshot: Codable, Sendable { public let azureOpenAIUsage: AzureOpenAIUsageSnapshot? /// gap G — transient, like azureOpenAIUsage. public let alibabaTokenPlanUsage: AlibabaTokenPlanUsageSnapshot? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -129,6 +136,8 @@ public struct UsageSnapshot: Codable, Sendable { case elevenLabsUsage case groqUsage case llmProxyUsage + case subscriptionExpiresAt + case subscriptionRenewsAt case updatedAt case identity case accountEmail @@ -159,6 +168,8 @@ public struct UsageSnapshot: Codable, Sendable { cursorRequests: CursorRequestUsage? = nil, azureOpenAIUsage: AzureOpenAIUsageSnapshot? = nil, alibabaTokenPlanUsage: AlibabaTokenPlanUsageSnapshot? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) { @@ -184,6 +195,8 @@ public struct UsageSnapshot: Codable, Sendable { self.cursorRequests = cursorRequests self.azureOpenAIUsage = azureOpenAIUsage self.alibabaTokenPlanUsage = alibabaTokenPlanUsage + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity } @@ -219,6 +232,8 @@ public struct UsageSnapshot: Codable, Sendable { self.cursorRequests = nil // Not persisted, fetched fresh each time self.azureOpenAIUsage = nil // Not persisted, fetched fresh each time self.alibabaTokenPlanUsage = nil // Not persisted, fetched fresh each time + self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) + self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity @@ -256,6 +271,8 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.elevenLabsUsage, forKey: .elevenLabsUsage) try container.encodeIfPresent(self.groqUsage, forKey: .groqUsage) try container.encodeIfPresent(self.llmProxyUsage, forKey: .llmProxyUsage) + try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) + try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) @@ -359,6 +376,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -384,6 +403,7 @@ public struct UsageSnapshot: Codable, Sendable { secondary: secondary, tertiary: tertiary, extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, @@ -394,6 +414,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: self.identity) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 87801eb67..1d4797f42 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -318,6 +318,57 @@ struct ModelsDevCacheLoadResult: Equatable { var error: ModelsDevCache.Error? } +/// In-memory memo for the decoded models.dev catalog, keyed by file path + on-disk identity. +/// +/// `ModelsDevCache.load` is called once per usage row whenever a cost lookup is performed without a +/// pre-resolved catalog (see `CostUsagePricing.modelsDevLookup`). Without this memo, scanning a large +/// `~/.codex` history re-reads and re-decodes the ~800 KB catalog JSON for every row, which pegs the CPU +/// and freezes the menu during a refresh. +/// +/// The full load *outcome* is memoized, not just successful decodes: a corrupt or wrong-version cache is +/// read and decode-attempted exactly as expensively as a valid one, so caching only successes would leave +/// the per-row storm in place whenever the cache is unreadable. Reusing the outcome while the file is +/// unchanged keeps every fallback path cheap. +private final class ModelsDevCacheMemo: @unchecked Sendable { + enum Outcome { + case decoded(ModelsDevCacheArtifact) + case failure(ModelsDevCache.Error) + } + + private struct Entry { + let modificationDate: Date? + let size: Int? + let outcome: Outcome + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + + func outcome(path: String, modificationDate: Date?, size: Int?) -> Outcome? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entries[path], + entry.modificationDate == modificationDate, + entry.size == size + else { + return nil + } + return entry.outcome + } + + func store(path: String, modificationDate: Date?, size: Int?, outcome: Outcome) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries[path] = Entry(modificationDate: modificationDate, size: size, outcome: outcome) + } + + func invalidate(path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeValue(forKey: path) + } +} + enum ModelsDevCache { enum Error: Swift.Error, Equatable { case unreadable @@ -328,6 +379,17 @@ enum ModelsDevCache { static let artifactVersion = 1 static let ttlSeconds: TimeInterval = 24 * 60 * 60 + private static let memo = ModelsDevCacheMemo() + + private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return (nil, nil) + } + let modificationDate = attributes[.modificationDate] as? Date + let size = (attributes[.size] as? NSNumber)?.intValue + return (modificationDate, size) + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -342,23 +404,52 @@ enum ModelsDevCache { static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { let url = self.cacheFileURL(cacheRoot: cacheRoot) + let metadata = Self.fileMetadata(at: url) + + // Staleness depends on `now`, so the result is always rebuilt; only the read+decode outcome is memoized. + if let outcome = Self.memo.outcome( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size) + { + return Self.result(for: outcome, now: now) + } + + let outcome = Self.readOutcome(at: url) + Self.memo.store( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size, + outcome: outcome) + return Self.result(for: outcome, now: now) + } + + private static func readOutcome(at url: URL) -> ModelsDevCacheMemo.Outcome { guard let data = try? Data(contentsOf: url) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .unreadable) + return .failure(.unreadable) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidJSON) + return .failure(.invalidJSON) } guard decoded.version == Self.artifactVersion else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidVersion) + return .failure(.invalidVersion) + } + return .decoded(decoded) + } + + private static func result(for outcome: ModelsDevCacheMemo.Outcome, now: Date) -> ModelsDevCacheLoadResult { + switch outcome { + case let .decoded(artifact): + ModelsDevCacheLoadResult( + artifact: artifact, + isStale: now.timeIntervalSince(artifact.fetchedAt) > Self.ttlSeconds, + error: nil) + case let .failure(error): + ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: error) } - - return ModelsDevCacheLoadResult( - artifact: decoded, - isStale: now.timeIntervalSince(decoded.fetchedAt) > Self.ttlSeconds, - error: nil) } static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { @@ -386,6 +477,8 @@ enum ModelsDevCache { } else { try FileManager.default.moveItem(at: tmp, to: url) } + // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. + Self.memo.invalidate(path: url.path) } catch { try? FileManager.default.removeItem(at: tmp) } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 4916dd0e5..fb17b4d0f 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -260,6 +260,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `post only token payload maps to login required`() { + let json = """ + { + "code": "PostonlyOrTokenError", + "message": "Your request has expired. Please refresh the page.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `nested unsuccessful subscription summary maps to API error`() throws { let body = """ @@ -290,6 +305,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `failed forbidden payload maps to invalid credentials`() { + let json = """ + { + "successResponse": false, + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `html login payload maps to login required`() { let html = """ @@ -311,7 +341,7 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `cookie only request continues without SEC token`() async throws { + func `SEC token preflight falls back to user info`() async throws { defer { AlibabaTokenPlanStubURLProtocol.handler = nil } @@ -319,17 +349,40 @@ struct AlibabaTokenPlanUsageParsingTests { AlibabaTokenPlanStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - if url.host == "alibaba-token-plan.test", request.httpMethod == "GET" { + if url.host == "alibaba-token-plan.test", + url.path == "/cn-beijing", + request.httpMethod == "GET" + { + #expect(url.port == 9443) return Self.makeResponse(url: url, body: "", statusCode: 200) } + if url.host == "alibaba-token-plan.test", + url.path == "/tool/user/info.json", + request.httpMethod == "GET" + { + #expect(url.port == 9443) + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*") + let json = """ + { + "code": "200", + "data": { + "secToken": "user-info-token" + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL .absoluteString) let body = Self.requestBodyString(from: request) - #expect(!body.contains("sec_token=")) + #expect(body.contains("sec_token=user-info-token")) #expect(body.contains("GetSubscriptionSummary")) #expect(body.contains("BssOpenAPI-V3")) #expect(body.contains("ProductCode")) @@ -356,7 +409,7 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", - environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test"], + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test:9443"], session: session) #expect(snapshot.planName == "TOKEN PLAN") diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index fe35ba608..bd71cbd2b 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -62,6 +62,124 @@ struct AntigravityStatusProbeTests { #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(command)) } + @Test + func `process detection accepts antigravity cli without csrf token`() { + // The CLI launches its language server without a `--csrf_token` flag. + let node = """ + node /Users/test/.gemini/antigravity-cli/build/mcp-server.cjs \ + --app_data_dir /Users/test/.gemini/antigravity + """ + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(node)) + + let agy = "/Users/test/.local/bin/agy -p hello" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agy)) + + let agyUnderscore = "/usr/local/bin/agy --app_data_dir /Users/test/.gemini/antigravity_cli" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agyUnderscore)) + } + + @Test + func `process detection ignores unrelated binaries containing agy substring`() { + // "agy" must be path-anchored so unrelated commands do not match. + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/usr/bin/legacy --run")) + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/opt/imagymagic/bin/tool")) + } + + @Test + func `process detection ignores cli names outside explicit cli path segments`() { + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/node /tmp/not-antigravity-cli/build/server.js")) + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/helper --workspace antigravity-cli")) + } + + @Test + func `process kind distinguishes ide language server from cli`() { + let ide = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token token --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.antigravityProcessKind(ide) == .ide) + #expect(AntigravityStatusProbe.antigravityProcessKind("/Users/test/.local/bin/agy -p hi") == .cli) + #expect( + AntigravityStatusProbe.antigravityProcessKind( + "node /x/.gemini/antigravity-cli/build/mcp-server.cjs --app_data_dir /x/.gemini/antigravity") == .cli) + #expect(AntigravityStatusProbe.antigravityProcessKind("/usr/bin/legacy --run") == nil) + } + + @Test + func `csrf token stays required for ide but optional for cli`() { + // IDE with a token returns it. + let ideWithToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token ide-token --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .ide, command: ideWithToken) == "ide-token") + + // Tokenless IDE is skipped (nil) so detection keeps scanning for a valid + // server and preserves the missing-token diagnostic — no empty-token probe. + let ideNoToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .ide, command: ideNoToken) == nil) + + // CLI without a token resolves to an empty token (its server needs none). + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy -p hi")?.isEmpty == true) + + // A CLI that does carry a token still uses it. + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy --csrf_token cli-token") == "cli-token") + } + + @Test + func `process scan skips tokenless ide before later valid ide`() throws { + let tokenlessIDE = + " 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity" + let validIDE = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token ide-token --app_data_dir antigravity " + + "--extension_server_port 64432 --extension_server_csrf_token extension-token" + let output = [tokenlessIDE, validIDE].joined(separator: "\n") + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 101) + #expect(result.csrfToken == "ide-token") + #expect(result.extensionPort == 64432) + #expect(result.extensionServerCSRFToken == "extension-token") + } + + @Test + func `process scan reports missing csrf when only tokenless ide matches`() { + let output = """ + 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity + """ + + #expect(throws: AntigravityStatusProbeError.missingCSRFToken) { + try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + } + } + + @Test + func `process scan allows empty csrf only for explicit cli match`() throws { + let output = """ + 200 /Users/test/.local/bin/agy -p hello + """ + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 200) + #expect(result.csrfToken.isEmpty) + #expect(result.commandLine == "/Users/test/.local/bin/agy -p hello") + } +} + +extension AntigravityStatusProbeTests { @Test func `localhost trust policy only accepts local server trust challenges`() { #expect( diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 4b21f61bf..ab3cf77a3 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -128,6 +128,16 @@ struct CLIServeRouterTests { flags: [])) == 30) } + @Test + func `serve help documents request timeout option`() { + let serve = CodexBarCLI.serveHelp(version: "0.0.0") + let root = CodexBarCLI.rootHelp(version: "0.0.0") + + #expect(serve.contains("--request-timeout ")) + #expect(serve.contains("quotakit serve --port 8080 --refresh-interval 60 --request-timeout 30")) + #expect(root.contains("--request-timeout ")) + } + @Test func `serve cache skips provider error payloads`() { let success = CLILocalHTTPResponse( diff --git a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift index 3a5f8724d..722ce155b 100644 --- a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift +++ b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift @@ -55,6 +55,90 @@ struct ClaudeProbeWorkingDirectoryTests { #expect(settings["disableDeepLinkRegistration"] as? String == "disable") } + @Test + func `probe project directory name matches Claude Code encoding`() { + let cases = [ + ( + "/Users/test/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test.name/t\u{00E9}st_under/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-name-t-st-under-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test/emoji_😀/ClaudeProbe", + "-Users-test-emoji----ClaudeProbe"), + ( + "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe", + "-tmp-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-seg-x9mpdi"), + ] + + for (path, expected) in cases { + let directory = URL(fileURLWithPath: path) + #expect(ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: directory) == expected) + } + } + + @Test + func `cleanup removes only probe session jsonl artifacts`() throws { + let probeDirectory = try Self.makeTemporaryDirectory() + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + let unrelatedProject = projectsRoot.appendingPathComponent("unrelated-project", isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: probeDirectory) + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + let probeNote = probeProject.appendingPathComponent("keep.txt") + let unrelatedSession = unrelatedProject.appendingPathComponent("user-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + try Data("keep".utf8).write(to: probeNote) + try Data("{}\n".utf8).write(to: unrelatedSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + #expect(FileManager.default.fileExists(atPath: probeNote.path)) + #expect(FileManager.default.fileExists(atPath: unrelatedSession.path)) + } + + @Test + func `cleanup removes hashed long probe project artifacts`() throws { + let probeDirectory = URL(fileURLWithPath: "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe") + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + } + private static func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-probe-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift new file mode 100644 index 000000000..2fa77f04e --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -0,0 +1,1492 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `same account token refresh fingerprint change keeps codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same account token refresh fingerprint change keeps reset backfill`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-reset-backfill") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + store.lastCodexAccountScopedRefreshGuard = store.freshCodexAccountScopedRefreshGuard() + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + + @Test + func `same account token refresh fingerprint change keeps scoped state during prepare`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-prepare") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + store.snapshots[.codex] = cached + store.lastKnownResetSnapshots[.codex] = cached + store.credits = self.credits(remaining: 42) + store.lastCodexAccountScopedRefreshGuard = store.freshCodexAccountScopedRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let invalidated = store.prepareCodexAccountScopedRefreshIfNeeded() + + #expect(!invalidated) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.credits?.remaining == 42) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + + @Test + func `usage success applies when auth fingerprint appears after refresh starts`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-auth-fingerprint-appears") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: nil, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + + #expect(store.shouldApplyCodexUsageResult( + expectedGuard: expectedGuard, + usage: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + } + + @Test + func `same account token refresh fingerprint change discards codex usage failure`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-failure") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .failure(TestRefreshError(message: "old token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same account token refresh fingerprint change keeps codex credits success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-credits-success") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let store = self.makeUsageStore(settings: settings) + store._test_codexCreditsLoaderOverride = { + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refreshCreditsIfNeeded() + + #expect(store.credits?.remaining == 42) + #expect(store.lastCreditsSnapshotAccountKey == "alpha@example.com") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.lastCreditsError == nil) + } + + @Test + func `credits refresh key separates same account auth fingerprints`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-credits-key-auth-fingerprint") + let store = self.makeUsageStore(settings: settings) + let oldGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "old-token-material") + let newGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "new-token-material") + + #expect(store.codexCreditsRefreshKey(expectedGuard: oldGuard) != + store.codexCreditsRefreshKey(expectedGuard: newGuard)) + } + + @Test + func `same account token refresh fingerprint change keeps dashboard success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-success") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return self.dashboard(email: "alpha@example.com", creditsRemaining: 64, usedPercent: 27) + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.openAIDashboard?.signedInEmail == "alpha@example.com") + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + @Test + func `dashboard refresh key separates same account auth fingerprints`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-key-auth-fingerprint") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let oldGuard = store.freshCodexOpenAIWebRefreshGuard() + let oldRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: oldGuard) + } + await blocker.waitUntilStarted(count: 1) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let newGuard = store.freshCodexOpenAIWebRefreshGuard() + let newRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: newGuard) + } + + let didStartFreshRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartFreshRefresh) + guard didStartFreshRefresh else { + await blocker.resumeNext(with: .failure(TestRefreshError(message: "stale dashboard failure"))) + await oldRefreshTask.value + await newRefreshTask.value + return + } + await blocker.resumeNext(with: .failure(TestRefreshError(message: "old dashboard failure"))) + await blocker.resumeNext(with: .success(self.dashboard( + email: "alpha@example.com", + creditsRemaining: 64, + usedPercent: 27))) + await oldRefreshTask.value + await newRefreshTask.value + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.lastOpenAIDashboardError == nil) + } + + @Test + func `same account token refresh fingerprint change discards dashboard failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + throw TestRefreshError(message: "old dashboard failure") + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + @Test + func `same account token refresh fingerprint change applies dashboard policy failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-policy-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 64, usedPercent: 27), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + #expect(store.openAIDashboardRequiresLogin == true) + } + + @Test + func `stacked visible refresh discards selected failure after managed token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-token-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "old-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "new-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-token-sibling@example.com", + providerAccountID: "acct-managed-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-token-sibling", + authFingerprint: "sibling-managed-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + } + + @Test + func `stacked visible refresh discards selected failure after managed auth file rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-121212121212")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-131313131313")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Pro", + accountId: "acct-managed-file-token") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-file-token@example.com", + providerAccountID: "acct-managed-file-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-file-token", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-file-token-sibling@example.com", + providerAccountID: "acct-managed-file-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-file-token-sibling", + authFingerprint: "sibling-managed-file-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-file-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Team", + accountId: "acct-managed-file-token") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed auth file failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + } + + @Test + func `stacked visible refresh keeps selected failure when managed auth file rotated before start`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-current-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-failure-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Pro", + accountId: "acct-managed-current-failure") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-current-failure@example.com", + providerAccountID: "acct-managed-current-failure", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-current-failure", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-current-sibling@example.com", + providerAccountID: "acct-managed-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-current-sibling", + authFingerprint: "sibling-managed-current", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Team", + accountId: "acct-managed-current-failure") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + throw TestRefreshError(message: "current managed auth file failure") + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-current-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + await store.refreshCodexVisibleAccountsForMenu() + + #expect(store.errors[.codex] == "current managed auth file failure") + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(targetSnapshot.error == "current managed auth file failure") + #expect(targetSnapshot.account.authFingerprint == newFingerprint) + let persistedTargetSnapshot = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(persistedTargetSnapshot.error == "current managed auth file failure") + #expect(persistedTargetSnapshot.account.authFingerprint == newFingerprint) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file switches accounts`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-141414141414")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-151515151515")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-success-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-success-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-success@example.com", + plan: "Pro", + accountId: "acct-managed-old-success") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-success@example.com", + providerAccountID: "acct-managed-old-success", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-old-success", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-success-sibling@example.com", + providerAccountID: "acct-managed-success-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-success-sibling", + authFingerprint: "sibling-managed-success", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-success-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-success@example.com", + plan: "Pro", + accountId: "acct-managed-new-success") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-success@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + } + + @Test + func `stacked visible refresh keeps migrated managed account after token rotation`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-migrated-managed-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-migrated-managed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-migrated-managed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Pro", + accountId: "acct-migrated-managed") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "migrated-sibling@example.com", + plan: "Pro", + accountId: "acct-migrated-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "migrated-managed@example.com", + providerAccountID: "acct-migrated-managed", + workspaceLabel: "Managed Team", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "migrated-sibling@example.com", + providerAccountID: "acct-migrated-sibling", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Team", + accountId: "acct-migrated-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-managed@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 64) + let targetRow = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(targetRow.account.authFingerprint == newFingerprint) + #expect(targetRow.snapshot?.primary?.usedPercent == 64) + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(persistedTarget.account.authFingerprint == newFingerprint) + #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-email-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-email-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "managed-email-sibling@example.com", + plan: "Pro", + accountId: "acct-managed-email-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-email@example.com", + providerAccountID: "acct-managed-email-same", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-same", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-email-sibling@example.com", + providerAccountID: "acct-managed-email-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-email-sibling", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-email-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + } + + @Test + func `managed failure guard reads current auth file fingerprint`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-auth-file-fingerprint") + settings.refreshFrequency = .manual + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Pro", + accountId: "acct-managed-auth") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-auth@example.com", + providerAccountID: "acct-managed-auth", + workspaceLabel: "Managed Auth", + workspaceAccountID: "acct-managed-auth", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Team", + accountId: "acct-managed-auth") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == newFingerprint) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + + try FileManager.default.removeItem(at: managedHome) + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == nil) + let staleUsage = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Auth")) + #expect(!store.shouldApplyCodexUsageResult(expectedGuard: expectedGuard, usage: staleUsage)) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + } + + @Test + func `stale auth fingerprint cache at refresh start keeps current codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stale-start-cache-current-auth") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 33))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 33) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-email-only-auth") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account live email change discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-provider-email-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old@example.com", + authFingerprint: "old-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new@example.com", + authFingerprint: "new-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "old@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account managed email change discards stale codex usage success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-provider-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed@example.com", + providerAccountID: "acct-managed-shared", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(self.codexSnapshot(email: "old-managed@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `managed codex usage success without email applies when auth guard matches`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-usage-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-usage-without-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "email-less-managed@example.com", + plan: "Pro", + accountId: "acct-managed-email-less") + let authFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "email-less-managed@example.com", + providerAccountID: "acct-managed-email-less", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-less", + authFingerprint: authFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account managed email change discards stale codex usage success without email`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-managed-provider-email-without-email-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed-empty@example.com", + providerAccountID: "acct-managed-shared-empty", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared-empty", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same email email-only auth fingerprint switch discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } +} diff --git a/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift new file mode 100644 index 000000000..1b149c576 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift @@ -0,0 +1,120 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `backfills non active email only codex row from own history`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-non-active-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let activeID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-333333333333")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-444444444444")) + let activeHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-active-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: activeHome, + email: "active-email-history@example.com", + plan: "Pro") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "sibling-email-history@example.com", + plan: "Pro") + let activeFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: activeHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let activeAccount = ManagedCodexAccount( + id: activeID, + email: "active-email-history@example.com", + workspaceLabel: "Active Team", + authFingerprint: activeFingerprint, + managedHomePath: activeHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-email-history@example.com", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [activeAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: activeHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: activeID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let siblingHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "sibling-email-history@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + siblingHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 6, resetsAt: sessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 36, resetsAt: weeklyReset), + ]), + ], + ]) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: activeID), + identity: .emailOnly(normalizedEmail: "active-email-history@example.com"), + accountKey: "active-email-history@example.com", + authFingerprint: activeFingerprint) + self.installContextualCodexProvider(on: store) { context in + let isActive = context.env["CODEX_HOME"] == activeHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isActive ? 3 : 6, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let activeSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "active-email-history@example.com" + }?.snapshot) + #expect(activeSnapshot.primary?.usedPercent == 3) + #expect(activeSnapshot.primary?.windowMinutes == 0) + #expect(activeSnapshot.primary?.resetsAt == nil) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(siblingSnapshot.primary?.usedPercent == 6) + #expect(siblingSnapshot.primary?.windowMinutes == 300) + #expect(siblingSnapshot.primary?.resetsAt == sessionReset) + #expect(siblingSnapshot.secondary?.usedPercent == 36) + #expect(siblingSnapshot.secondary?.resetsAt == weeklyReset) + let persistedSibling = try #require(snapshotStore.storedSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(persistedSibling.primary?.resetsAt == sessionReset) + #expect(persistedSibling.secondary?.resetsAt == weeklyReset) + } +} diff --git a/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift new file mode 100644 index 000000000..b1b5d837a --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift @@ -0,0 +1,239 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `stale stacked projection collapse runs single codex fetch`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stacked-collapse-single-fetch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + settings._test_managedCodexAccountStoreURL = nil + } + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "live-collapse@example.com", + identity: .providerAccount(id: "acct-live-collapse")) + + let managedAccountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed-collapse@example.com", + managedHomePath: "/tmp/codex-managed-collapse", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let staleStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let emptyStoreURL = try self.makeManagedAccountStoreURL(accounts: []) + defer { + try? FileManager.default.removeItem(at: staleStoreURL) + try? FileManager.default.removeItem(at: emptyStoreURL) + } + settings._test_managedCodexAccountStoreURL = staleStoreURL + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + #expect(CodexVisibleAccountProjection.make(from: staleReconciliationSnapshot).visibleAccounts.count == 2) + + settings._test_managedCodexAccountStoreURL = emptyStoreURL + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "live-collapse@example.com", usedPercent: 42)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 42) + #expect(store.codexAccountSnapshots.isEmpty) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file is removed`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-removed") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-212121212121")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-removed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-removed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-removed@example.com", + plan: "Pro", + accountId: "acct-managed-removed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-removed@example.com", + providerAccountID: "acct-managed-removed", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-removed", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-removed-sibling@example.com", + providerAccountID: "acct-managed-removed-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-removed-sibling", + authFingerprint: "sibling-managed-removed", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-removed-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManager.default.removeItem(at: targetHome) + await blocker.resume(with: .success(self.codexSnapshot(email: "managed-removed@example.com", usedPercent: 44))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + } + + @Test + func `startup snapshot hydration refreshes managed auth fingerprint from disk`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-startup-managed-auth-hydration") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Pro") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-startup@example.com", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let snapshotURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString).json") + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: snapshotURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let staleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == accountID }) + #expect(staleAccount.authFingerprint == oldFingerprint) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Team") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + let freshAccount = CodexVisibleAccount( + id: staleAccount.id, + email: staleAccount.email, + workspaceLabel: staleAccount.workspaceLabel, + workspaceAccountID: staleAccount.workspaceAccountID, + authFingerprint: newFingerprint, + storedAccountID: staleAccount.storedAccountID, + selectionSource: staleAccount.selectionSource, + isActive: staleAccount.isActive, + isLive: staleAccount.isLive, + canReauthenticate: staleAccount.canReauthenticate, + canRemove: staleAccount.canRemove) + let snapshotStore = FileCodexAccountUsageSnapshotStore(fileURL: snapshotURL) + snapshotStore.store([ + CodexAccountUsageSnapshot( + account: freshAccount, + snapshot: self.codexSnapshot(email: freshAccount.email, usedPercent: 64), + error: nil, + sourceLabel: "cached"), + ]) + #expect(snapshotStore.load(for: [staleAccount]).isEmpty) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + + let hydrated = try #require(store.codexAccountSnapshots.first) + #expect(store.codexAccountSnapshots.count == 1) + #expect(hydrated.id == freshAccount.id) + #expect(hydrated.account.authFingerprint == newFingerprint) + #expect(hydrated.snapshot?.primary?.usedPercent == 64) + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 4316b0ade..719b07c97 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -119,4 +119,155 @@ extension CodexAccountScopedRefreshTests { #expect(store.openAIDashboardRequiresLogin == true) #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) } + + @Test + func `dashboard fail closed cleanup applies after same account managed token rotation`() async throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-token-rotation-cleanup") + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: managedHome) } + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + providerAccountID: "acct-managed", + workspaceLabel: "Managed", + workspaceAccountID: "acct-managed", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_activeManagedCodexAccount = nil + try? FileManager.default.removeItem(at: managedStoreURL) + } + + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + store._setSnapshotForTesting( + self.codexSnapshot(email: "managed@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "managed@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "managed@example.com", + snapshot: self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20))) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "team", + accountId: "acct-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == expectedGuard.accountKey) + #expect(currentGuard.authFingerprint == newFingerprint) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "managed@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + } + + @Test + func `dashboard fail closed cleanup applies after same live account email changes during token rotation`() async { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-live-email-rotation-cleanup") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.accountKey == "alpha@example.com") + #expect(expectedGuard.authFingerprint == "old-token-material") + store._setSnapshotForTesting( + self.codexSnapshot(email: "alpha@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "alpha@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "alpha@example.com", + snapshot: self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20))) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "beta@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == "beta@example.com") + #expect(currentGuard.authFingerprint == "new-token-material") + + await store.applyOpenAIDashboard( + self.dashboard(email: "alpha@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as alpha@example.com") == true) + } } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index 8e0fc39cd..48e114c13 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -199,6 +199,16 @@ extension CodexAccountScopedRefreshTests { } } + func installContextualCodexProvider( + on store: UsageStore, + loader: @escaping @Sendable (ProviderFetchContext) async throws -> UsageSnapshot) + { + let baseSpec = store.providerSpecs[.codex]! + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { _ in + [ContextualTestCodexFetchStrategy(loader: loader, sourceLabel: "test-codex")] + } + } + static func makeCodexProviderSpec( baseSpec: ProviderSpec, loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec @@ -327,6 +337,30 @@ struct TestCodexFetchStrategy: ProviderFetchStrategy { } } +struct ContextualTestCodexFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable (ProviderFetchContext) async throws -> UsageSnapshot + let sourceLabel: String + + var id = "contextual-test-codex" + var kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader(context) + return self.makeResult( + usage: snapshot, + credits: nil, + sourceLabel: self.sourceLabel) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + struct ThrowingTestCodexFetchStrategy: ProviderFetchStrategy { let loader: @Sendable () async throws -> UsageSnapshot diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift new file mode 100644 index 000000000..b97059d18 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -0,0 +1,1540 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `repairs collapsed codex windows from matching provider account history`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "target@example.com", + providerAccountID: "acct-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling@example.com", + providerAccountID: "acct-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 1 : 22, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + let targetHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-target"))) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + targetHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 1, resetsAt: sessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 13, resetsAt: weeklyReset), + ]), + ], + ]) + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 1) + #expect(targetSnapshot.primary?.windowMinutes == 300) + #expect(targetSnapshot.primary?.resetsAt == sessionReset) + #expect(targetSnapshot.secondary?.usedPercent == 13) + #expect(targetSnapshot.secondary?.windowMinutes == 10080) + #expect(targetSnapshot.secondary?.resetsAt == weeklyReset) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-sibling" + }?.snapshot) + #expect(siblingSnapshot.primary?.windowMinutes == 0) + #expect(siblingSnapshot.primary?.resetsAt == nil) + #expect(siblingSnapshot.secondary == nil) + + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(persistedTarget.primary?.resetsAt == sessionReset) + #expect(persistedTarget.secondary?.resetsAt == weeklyReset) + #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) + #expect(store.planUtilizationHistory[.codex]?.accounts[targetHistoryKey]?.count == 2) + } + + @Test + func `materializes single visible codex account email history into provider account history`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-single-account-materialize") + let store = self.makeUsageStore(settings: settings) + let visibleAccount = CodexVisibleAccount( + id: "materialize@example.com", + email: "materialize@example.com", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize", + storedAccountID: nil, + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) + } + + @Test + func `materializes provider account email history when sibling visible account uses another email`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-different-email-materialize") + settings.multiAccountMenuLayout = .stacked + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-121212121212")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-343434343434")) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "materialize-stack@example.com", + providerAccountID: "acct-materialize-stack", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize-stack", + managedHomePath: "/tmp/materialize-stack-target", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-stack@example.com", + providerAccountID: "acct-materialize-other", + workspaceLabel: "Other Team", + workspaceAccountID: "acct-materialize-other", + managedHomePath: "/tmp/materialize-stack-other", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + let store = self.makeUsageStore(settings: settings) + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.workspaceAccountID == "acct-materialize-stack" + }) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize-stack"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize-stack@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize-stack@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) + } + + @Test + func `selected codex refresh keeps ambiguous same email history out of provider account`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-ambiguous-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-111111111111")) + let siblingID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-target") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-sibling") + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-selected-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-selected-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let store = self.makeUsageStore(settings: settings) + let now = Date(timeIntervalSince1970: 1_800_000_000) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-selected-target"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "selected-shared@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "selected-shared@example.com") + let staleSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 12), + ]) + let staleWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 24), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [staleSession], + legacyEmailHistoryKey: [staleWeekly], + ]) + let currentSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 4, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 6, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-shared@example.com", + accountOrganization: nil, + loginMethod: "Target Team")) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: currentSnapshot, + now: now) + + let buckets = try #require(store.planUtilizationHistory[.codex]) + let providerHistory = try #require(buckets.accounts[providerHistoryKey]) + #expect(providerHistory.flatMap(\.entries).allSatisfy { $0.capturedAt == now }) + #expect(buckets.accounts[emailHistoryKey] == [staleSession]) + #expect(buckets.accounts[legacyEmailHistoryKey] == [staleWeekly]) + } + + @Test + func `ignores active reset cache from another visible codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-stale-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-444444444444")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-555555555555")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "shared@example.com", + providerAccountID: "acct-cache-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-cache-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "shared@example.com", + providerAccountID: "acct-cache-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-cache-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: siblingID), + identity: .providerAccount(id: "acct-cache-sibling"), + accountKey: "shared@example.com") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "shared@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.snapshots[.codex]?.primary?.resetsAt == nil) + #expect(store.snapshots[.codex]?.secondary == nil) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == nil) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot?.primary?.resetsAt == nil) + } + + @Test + func `uses active reset cache when scoped guard matches codex workspace with plan label`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-current-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-666666666666")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-777777777777")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "current@example.com", + providerAccountID: "acct-current-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-current-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "current@example.com", + providerAccountID: "acct-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-current-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let now = Date() + let staleSessionReset = now.addingTimeInterval(3 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.workspaceAccountID == "acct-current-target" + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 10080, + resetsAt: staleWeeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: targetID), + identity: .providerAccount(id: "acct-current-target"), + accountKey: "current@example.com") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "current@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 300) + #expect(targetSnapshot.primary?.resetsAt == sessionReset) + #expect(targetSnapshot.secondary?.usedPercent == 55) + #expect(targetSnapshot.secondary?.windowMinutes == 10080) + #expect(targetSnapshot.secondary?.resetsAt == weeklyReset) + #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot?.secondary?.resetsAt == weeklyReset) + } + + @Test + func `ignores prior snapshot from same email different codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-prior-workspace") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-888888888888")) + let oldID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-999999999999")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-AAAAAAAAAAAA")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "prior@example.com", + providerAccountID: "acct-prior-new", + workspaceLabel: "New Team", + workspaceAccountID: "acct-prior-new", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-prior@example.com", + providerAccountID: "acct-prior-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-prior-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let oldVisibleAccount = CodexVisibleAccount( + id: "prior@example.com", + email: "prior@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-prior-old", + storedAccountID: oldID, + selectionSource: .managedAccount(id: oldID), + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: oldVisibleAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 72, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "prior@example.com", + accountOrganization: nil, + loginMethod: "Old Team")), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-prior-new" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + } + + @Test + func `ignores ambiguous email history for same email codex workspaces`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-ambiguous-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-111111111111")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "history-shared@example.com", + providerAccountID: "acct-history-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-history-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "history-shared@example.com", + providerAccountID: "acct-history-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-history-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let normalizedEmail = try #require(CodexIdentityResolver.normalizeEmail("history-shared@example.com")) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 2, resetsAt: now.addingTimeInterval(3600)), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry( + at: now.addingTimeInterval(-60), + usedPercent: 33, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-history-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.planUtilizationHistory[.codex]?.histories(for: emailHistoryKey).isEmpty == false) + } + + @Test + func `backfills live codex row from same id prior snapshot`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-111111111111")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior@example.com", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior@example.com", + providerAccountID: "acct-managed-prior", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.selectionSource == .liveSystem + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 300) + #expect(liveSnapshot.primary?.resetsAt == priorReset) + } + + @Test + func `ignores live codex prior snapshot after auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior-auth@example.com", + authFingerprint: "current-live-auth-fingerprint", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior-auth@example.com", + providerAccountID: "acct-managed-prior-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.selectionSource == .liveSystem + }) + let priorLiveAccount = CodexVisibleAccount( + id: liveAccount.id, + email: liveAccount.email, + workspaceLabel: liveAccount.workspaceLabel, + workspaceAccountID: liveAccount.workspaceAccountID, + authFingerprint: "stale-live-auth-fingerprint", + storedAccountID: liveAccount.storedAccountID, + selectionSource: liveAccount.selectionSource, + isActive: liveAccount.isActive, + isLive: liveAccount.isLive, + canReauthenticate: liveAccount.canReauthenticate, + canRemove: liveAccount.canRemove) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: priorLiveAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + } + + @Test + func `ignores active reset cache and email history after live auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-active-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-active-auth@example.com", + authFingerprint: "current-live-active-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-active-auth@example.com", + providerAccountID: "acct-managed-active-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-active-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleSessionReset = now.addingTimeInterval(2 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com"), + accountKey: "live-active-auth@example.com", + authFingerprint: "stale-live-active-auth") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "live-active-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "live-active-auth@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 44, resetsAt: staleSessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 55, resetsAt: staleWeeklyReset), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + #expect(liveSnapshot.secondary == nil) + } + + @Test + func `stacked visible refresh skips selected apply after live auth fingerprint changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "old-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-auth@example.com", + providerAccountID: "acct-managed-selected-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "new-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + } + + @Test + func `stacked visible refresh keeps selected apply after live token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-888888888888")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "old-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-token@example.com", + providerAccountID: "acct-managed-selected-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-token", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + let now = Date() + let reset = now.addingTimeInterval(2 * 60 * 60) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-token@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "new-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-token@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 77) + #expect(selectedSnapshot.accountEmail(for: .codex) == "selected-token@example.com") + #expect(selectedSnapshot.loginMethod(for: .codex) == "Pro") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-live-selected-token") + + let liveRow = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(liveRow.account.authFingerprint == "new-live-selected-token") + #expect(liveRow.snapshot?.primary?.usedPercent == 77) + + let persistedLive = try #require(snapshotStore.storedSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(persistedLive.account.authFingerprint == "new-live-selected-token") + #expect(persistedLive.snapshot?.primary?.usedPercent == 77) + } + + @Test + func `stacked visible refresh clears selected state after live account email changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-777777777777")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "old-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-email@example.com", + providerAccountID: "acct-managed-selected-email", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-email", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "new-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.codexAccountSnapshots.isEmpty) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + } + + @Test + func `stacked visible refresh keeps selected apply after provider account email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-provider-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-666666666666")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let originalTarget = ManagedCodexAccount( + id: targetID, + email: "old-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "new-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-provider@example.com", + providerAccountID: "acct-provider-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-provider-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [originalTarget, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + let reset = now.addingTimeInterval(90 * 60) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "sibling-provider@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-provider@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 64) + #expect(selectedSnapshot.accountEmail(for: .codex) == "new-provider@example.com") + #expect(selectedSnapshot.loginMethod(for: .codex) == "Pro") + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == reset) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "new-provider@example.com") + + let targetRow = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-provider-email" + }) + #expect(targetRow.account.email == "new-provider@example.com") + #expect(targetRow.snapshot?.primary?.usedPercent == 64) + #expect(targetRow.snapshot?.accountEmail(for: .codex) == "new-provider@example.com") + #expect(targetRow.snapshot?.loginMethod(for: .codex) == "Pro") + + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-provider-email" + }) + #expect(persistedTarget.account.email == "new-provider@example.com") + #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) + #expect(persistedTarget.snapshot?.accountEmail(for: .codex) == "new-provider@example.com") + #expect(persistedTarget.snapshot?.loginMethod(for: .codex) == "Pro") + } +} diff --git a/Tests/CodexBarTests/CodexLoginRunnerTests.swift b/Tests/CodexBarTests/CodexLoginRunnerTests.swift new file mode 100644 index 000000000..dd33a5397 --- /dev/null +++ b/Tests/CodexBarTests/CodexLoginRunnerTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CodexBar + +struct CodexLoginRunnerTests { + @Test + func `login runner returns timeout before hung codex exits`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-login-runner-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let homeDir = root.appendingPathComponent("home", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: homeDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let codex = binDir.appendingPathComponent("codex") + let script = """ + #!/usr/bin/python3 + import time + + print("login-started", flush=True) + time.sleep(5) + print("login-finished", flush=True) + """ + try script.write(to: codex, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) + + let start = Date() + let result = await CodexLoginRunner.run( + homePath: homeDir.path, + timeout: 0.2, + environment: ["PATH": binDir.path], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-finished") == false) + #expect(elapsed < 2.0, "Timeout should return promptly, took \(elapsed)s") + } +} diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift new file mode 100644 index 000000000..7ac845b4a --- /dev/null +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CursorMenuCardModelTests { + @Test + func `cursor billing cycle metrics show deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + secondary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Total", "Auto", "API"]) + for metric in model.metrics { + #expect(metric.percentLabel == "10% left") + #expect(metric.detailLeftText == "10% in deficit") + #expect(metric.detailRightText == "Runs out in 2d 16h") + #expect(metric.pacePercent == 20) + #expect(metric.paceOnTop == false) + } + } +} diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index e34277f9f..988d14e2e 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -314,7 +314,12 @@ struct CursorStatusProbeTests { #expect(snapshot.planPercentUsed == 0.441025641025641) #expect(snapshot.autoPercentUsed == 0.36) #expect(snapshot.apiPercentUsed == 0.7111111111111111) - #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) + #expect(snapshot.billingCycleStart != nil) + let usageSnapshot = snapshot.toUsageSnapshot() + #expect(usageSnapshot.primary?.remainingPercent == 99.55897435897436) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) + #expect(usageSnapshot.tertiary?.windowMinutes == 44640) } @Test @@ -329,6 +334,7 @@ struct CursorStatusProbeTests { onDemandLimitUSD: 100.0, teamOnDemandUsedUSD: 25.0, teamOnDemandLimitUSD: 500.0, + billingCycleStart: Date(timeIntervalSince1970: 1_735_689_600), // Jan 1, 2025 billingCycleEnd: Date(timeIntervalSince1970: 1_738_368_000), // Feb 1, 2025 membershipType: "pro", accountEmail: "user@example.com", @@ -342,6 +348,8 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.loginMethod(for: .cursor) == "Cursor Pro") #expect(usageSnapshot.secondary != nil) #expect(usageSnapshot.secondary?.usedPercent == 5.0) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) #expect(usageSnapshot.providerCost?.used == 5.0) #expect(usageSnapshot.providerCost?.limit == 100.0) #expect(usageSnapshot.providerCost?.currencyCode == "USD") diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift new file mode 100644 index 000000000..7a7d2572c --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBar + +/// Regression coverage for the localized-bundle caching added for #1347. +/// +/// The cache is process-global and these tests run in a parallel suite, so identity (`===`) assertions +/// would race against any other test that resolves a different language. Instead these assert the +/// concurrency-safe property that matters for correctness: every call resolves to the right `.lproj` +/// regardless of what is currently cached, so a language switch (and switch-back) is always honored and +/// the cache can never serve a stale localization. +struct LocalizationBundleCacheTests { + @Test + func `resolves the correct lproj per language and re-resolves on switch`() { + resetCodexBarLocalizationCacheForTesting() + + let fr = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(fr.bundleURL.lastPathComponent == "fr.lproj") + + // Switching language must re-resolve rather than return the cached French bundle. + let es = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(es.bundleURL.lastPathComponent == "es.lproj") + + // Switching back must still produce the French bundle (cache key is the language). + let frAgain = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(frAgain.bundleURL.lastPathComponent == "fr.lproj") + } + + @Test + func `repeated same-language calls keep resolving the same lproj`() { + resetCodexBarLocalizationCacheForTesting() + + for _ in 0..<5 { + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "es.lproj") + } + } + + @Test + func `unknown language falls back to en lproj`() { + resetCodexBarLocalizationCacheForTesting() + + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "en.lproj") + } + + @Test + func `resolution survives an explicit cache reset`() { + let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(first.bundleURL.lastPathComponent == "uk.lproj") + + resetCodexBarLocalizationCacheForTesting() + + let afterReset = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(afterReset.bundleURL.lastPathComponent == "uk.lproj") + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift new file mode 100644 index 000000000..32eeff2be --- /dev/null +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationLanguageCatalogTests { + private let languageKeys = [ + "language_system", + "language_english", + "language_spanish", + "language_catalan", + "language_chinese_simplified", + "language_chinese_traditional", + "language_portuguese_brazilian", + "language_swedish", + "language_french", + "language_dutch", + "language_ukrainian", + "language_vietnamese", + ] + + @Test + func `app language catalog includes Ukrainian`() { + #expect(AppLanguage.allCases.contains(.ukrainian)) + #expect(AppLanguage.ukrainian.rawValue == "uk") + } + + @Test + func `localized catalogs include every app language label`() throws { + #expect(self.languageKeys.count == AppLanguage.allCases.count) + + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let contents = try String(contentsOf: stringsURL, encoding: .utf8) + for key in self.languageKeys { + #expect(contents.contains("\"\(key)\""), "Missing \(key) in \(catalogURL.lastPathComponent)") + } + } + } + + @Test + func `ukrainian localization bundle exists and contains key UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let ukURL = root.appendingPathComponent("Sources/CodexBar/Resources/uk.lproj/Localizable.strings") + let contents = try String(contentsOf: ukURL, encoding: .utf8) + + let requiredKeys = [ + "\"language_title\"", + "\"language_subtitle\"", + "\"language_system\"", + "\"language_ukrainian\"", + "\"tab_general\"", + "\"quit_app\"", + ] + for key in requiredKeys { + #expect(contents.contains(key), "Missing localization key: \(key)") + } + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift index 5841ace2e..9e042d7eb 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift @@ -38,6 +38,34 @@ struct ManagedCodexAccountCoordinatorTests { #expect(coordinator.isAuthenticatingManagedAccount == false) #expect(coordinator.authenticatingManagedAccountID == nil) } + + @Test + func `coordinator clears in flight state after managed login timeout`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result(outcome: .timedOut, output: "timed out") + let existingAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForCoordinatorTests( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: CoordinatorTestManagedCodexHomeFactory(root: root), + loginRunner: TimedOutManagedCodexLoginRunner(result: loginResult), + identityReader: CoordinatorStubManagedCodexIdentityReader(email: "user@example.com")) + let coordinator = ManagedCodexAccountCoordinator(service: service) + + do { + _ = try await coordinator.authenticateManagedAccount(existingAccountID: existingAccountID, timeout: 0.2) + Issue.record("Expected managed login timeout to throw") + } catch let error as ManagedCodexAccountServiceError { + #expect(error == .loginFailed(loginResult)) + } catch { + Issue.record("Expected ManagedCodexAccountServiceError.loginFailed, got \(error)") + } + + #expect(coordinator.isAuthenticatingManagedAccount == false) + #expect(coordinator.authenticatingManagedAccountID == nil) + } } private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { @@ -68,6 +96,14 @@ private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { } } +private struct TimedOutManagedCodexLoginRunner: ManagedCodexLoginRunning { + let result: CodexLoginRunner.Result + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + self.result + } +} + private final class InMemoryManagedCodexAccountStoreForCoordinatorTests: ManagedCodexAccountStoring, @unchecked Sendable { var snapshot: ManagedCodexAccountSet diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 267f3098b..d0e769550 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -21,6 +21,70 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + @Test + func `automatic metric uses minimax weekly token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 97) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `automatic metric uses constrained antigravity family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetDescription == "Gemini Pro") + } + + @Test + func `explicit antigravity metric keeps requested family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let primary = MenuBarMetricWindowResolver.rateWindow( + preference: .primary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let secondary = MenuBarMetricWindowResolver.rateWindow( + preference: .secondary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let tertiary = MenuBarMetricWindowResolver.rateWindow( + preference: .tertiary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(primary?.resetDescription == "Claude") + #expect(secondary?.resetDescription == "Gemini Pro") + #expect(tertiary?.resetDescription == "Gemini Flash") + } + @Test func `extra usage metric maps provider cost into a menu bar window`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift new file mode 100644 index 000000000..e81a29331 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -0,0 +1,73 @@ +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardHeightFingerprintTests { + @Test + func `height fingerprint does not retain raw text fields`() { + let model = Self.model() + + let fingerprint = model.heightFingerprint(section: "card") + + #expect(!fingerprint.contains("very-secret@example.com")) + #expect(!fingerprint.contains("Secret Provider Name")) + #expect(!fingerprint.contains("Secret Metric")) + #expect(!fingerprint.contains("Secret note")) + } + + @Test + func `height fingerprint field distinguishes nil from empty string`() { + let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) + let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") + + #expect(nilField != emptyField) + } + + @Test + func `height fingerprint keeps cheap metric percent identity`() { + let left = Self.model(percent: 42, percentStyle: .left).heightFingerprint(section: "card") + let used = Self.model(percent: 42, percentStyle: .used).heightFingerprint(section: "card") + let changedPercent = Self.model(percent: 43, percentStyle: .left).heightFingerprint(section: "card") + + #expect(left != used) + #expect(left != changedPercent) + } + + private static func model( + percent: Double = 42, + percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Secret Provider Name", + email: "very-secret@example.com", + subtitleText: "Signed in as very-secret@example.com", + subtitleStyle: .info, + planText: "Secret Plan", + metrics: [ + .init( + id: "primary", + title: "Secret Metric", + percent: percent, + percentStyle: percentStyle, + statusText: "Secret status", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ], + usageNotes: ["Secret note"], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index cd6e59419..506f4b23e 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -632,9 +632,9 @@ struct MenuCardModelCodexProjectionTests { id: "codex-spark", title: "Codex Spark 5-hour", window: RateWindow( - usedPercent: 30, + usedPercent: 80, windowMinutes: 300, - resetsAt: now.addingTimeInterval(60 * 60), + resetsAt: now.addingTimeInterval(2 * 60 * 60), resetDescription: nil)), NamedRateWindow( id: "codex-spark-weekly", @@ -683,14 +683,18 @@ struct MenuCardModelCodexProjectionTests { let spark = try #require(model.metrics.first { $0.id == "codex-spark" }) #expect(spark.title == "Codex Spark 5-hour") - #expect(spark.percent == 70) - #expect(spark.percentLabel == "70% left") + #expect(spark.percent == 20) + #expect(spark.percentLabel == "20% left") #expect(spark.resetText != nil) + #expect(spark.detailLeftText == "20% in deficit") + #expect(spark.detailRightText == "Projected empty in 45m") let sparkWeekly = try #require(model.metrics.first { $0.id == "codex-spark-weekly" }) #expect(sparkWeekly.title == "Codex Spark Weekly") #expect(sparkWeekly.percent == 0) #expect(sparkWeekly.percentLabel == "0% left") #expect(sparkWeekly.resetText != nil) + #expect(sparkWeekly.detailLeftText == "86% in deficit") + #expect(sparkWeekly.detailRightText == "Runs out now") // Spark trails the core session/weekly lanes rather than replacing them. let sparkIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark" }) let sparkWeeklyIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark-weekly" }) diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 6e64710af..584266351 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -531,7 +531,7 @@ struct FactoryMenuCardModelTests { struct MiniMaxMenuCardModelTests { @Test - func `minimax service metrics use quota card copy`() throws { + func `minimax service metrics use codex aligned quota copy`() throws { let now = Date() let minimax = MiniMaxUsageSnapshot( planName: "Max", @@ -587,10 +587,10 @@ struct MiniMaxMenuCardModelTests { #expect(used.metrics.first?.title == "Text Generation") #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") - #expect(used.metrics.first?.detailRightText == "Used 20%") - #expect(used.metrics.first?.detailText == "10:00-15:00(UTC+8)") + #expect(used.metrics.first?.detailRightText == nil) + #expect(used.metrics.first?.detailText == nil) #expect(used.metrics.first?.percent == 20) - #expect(used.metrics.first?.cardStyle == true) + #expect(used.metrics.first?.cardStyle == false) } @Test @@ -661,6 +661,79 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[0].title == "Text Generation · Today") #expect(model.metrics[1].title == "Text Generation · Weekly") } + + @Test + func `minimax token plan model shows weekly quota and points balance`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan · TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ], + pointsBalance: 14000, + subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "Plus") + #expect(model.metrics[0].title == "Text Generation · 5h") + #expect(model.metrics[1].title == "Text Generation · Weekly") + #expect(model.metrics[0].detailLeftText == "Usage: 4 / 100") + #expect(model.metrics[1].detailLeftText == "Usage: 1 / 100") + #expect(model.metrics[0].detailRightText == nil) + #expect(model.metrics[1].detailRightText == nil) + #expect(model.metrics[0].detailText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[0].cardStyle == false) + #expect(model.metrics[1].cardStyle == false) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.usageNotes == ["Renews: May 18, 2027"]) + } } struct ClaudeMenuCardCostTests { diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index f1a69d37b..4027ca11d 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -49,9 +49,16 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) #expect(snapshot.planName == "Max") - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test @@ -83,9 +90,18 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) } - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) } @Test diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 968962590..23740cb50 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,189 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax quota rows include configured warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 31, + limit: 100, + percent: 31, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + } + + @Test + func `minimax quota rows use canonical general first order`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanMax-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "video", + windowType: "Today", + timeRange: "06/01 00:00 - 06/02 00:00(UTC+8)", + usage: 70, + limit: 100, + percent: 70, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["General · 5h", "General · Weekly", "Video"]) + #expect(model.metrics.map(\.percent) == [4, 1, 70]) + } + + @Test + func `minimax unlimited quota rows omit usage copy and warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 2, + limit: 200, + percent: 2, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[1].title == "General · Weekly") + #expect(model.metrics[1].statusText == "∞ Unlimited") + #expect(model.metrics[1].detailLeftText == nil) + #expect(model.metrics[1].warningMarkerPercents == []) + } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 1313c24e9..be79cc1dd 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -124,6 +124,18 @@ struct MiniMaxCookieHeaderTests { #expect(override?.authorizationToken == "token-abc") #expect(override?.groupID == "98765") } + + @Test + func `extracts group ID from combo curl header and cookie`() { + let raw = """ + curl 'https://www.minimaxi.com/v1/api/openplatform/charge/combo/cycle_audio_resource_package' \ + -b 'foo=bar; minimax_group_id_v2=2013894056999916075' \ + -H 'x-group-id: 2013894056999916075' + """ + let override = MiniMaxCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "foo=bar; minimax_group_id_v2=2013894056999916075") + #expect(override?.groupID == "2013894056999916075") + } } struct MiniMaxUsageParserTests { @@ -1009,6 +1021,24 @@ struct MiniMaxAPIRegionTests { #expect(codingPlan.query == "cycle_type=3") } + @Test + func `resolves web remains fallback hosts`() { + let global = MiniMaxUsageFetcher.resolveRemainsURLs(region: .global, environment: [:]) + let china = MiniMaxUsageFetcher.resolveRemainsURLs(region: .chinaMainland, environment: [:]) + + #expect(global.map(\.host).contains("platform.minimax.io")) + #expect(global.map(\.host).contains("www.minimax.io")) + #expect(china.map(\.host).contains("platform.minimaxi.com")) + #expect(china.map(\.host).contains("www.minimaxi.com")) + } + + @Test + func `resolves official token plan remains URL`() { + let url = MiniMaxUsageFetcher.resolveTokenPlanRemainsURL(region: .chinaMainland) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + } + @Test func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift new file mode 100644 index 000000000..899d60c0b --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -0,0 +1,741 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanChangeTests { + @Test + func `parses percent based general token plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.percentBasedRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.availablePrompts == nil) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.usedPercent == 4) + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 4) + #expect(services[0].limit == 100) + #expect(services[0].percent == 4) + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 1) + #expect(services[1].limit == 100) + #expect(services[1].percent == 1) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `zero count fields do not suppress percent based quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "data": { + "current_subscribe_title": "Token Plan · TokenPlanPlus-年度会员", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan · TokenPlanPlus-年度会员") + #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.count == 2) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) + } + + @Test + func `video first token plan still uses general quota as primary and weekly secondary`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "model_remains": [ + { + "model_name": "video", + "current_interval_total_count": 100, + "current_interval_usage_count": 70, + "current_interval_remaining_percent": 30, + "start_time": 1780243200000, + "end_time": 1780329600000 + }, + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 96, + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 99, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.services?.map(\.serviceType) == ["video", "general", "general"]) + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 70) + } + + @Test + func `plus token plan omits unavailable video quota lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + }, + { + "start_time": 1780243200000, + "end_time": 1780329600000, + "remains_time": 49059830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") + #expect(services.map(\.serviceType) == ["general", "general"]) + #expect(services.map(\.displayName) == ["General", "General"]) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().tertiary == nil) + } + + @Test + func `plus token plan renders boosted interval and unlimited weekly lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_347_620) + let json = """ + { + "model_remains": [ + { + "start_time": 1780347600000, + "end_time": 1780365600000, + "remains_time": 4650822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 1, + "current_interval_remaining_percent": 99, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100, + "interval_boost_permill": 2000, + "weekly_boost_permill": 2000 + }, + { + "start_time": 1780329600000, + "end_time": 1780416000000, + "remains_time": 55050822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 2) + #expect(services[0].limit == 200) + #expect(services[0].percent == 1) + #expect(services[0].isUnlimited == false) + #expect(services[1].serviceType == "general") + #expect(services[1].displayName == "General") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 0) + #expect(services[1].limit == 0) + #expect(services[1].percent == 0) + #expect(services[1].isUnlimited) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") + } + + @Test + func `web usage fetch falls back to www remains host after platform parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: "not json", contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { + $0.url?.host == "platform.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + #expect(requests.contains { + $0.url?.host == "www.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + } + + @Test + func `web usage fetch falls back to www remains host after platform transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + throw URLError(.timedOut) + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + "www.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves coding plan json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `web usage fetch preserves remains json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + #expect(url.path.contains("coding_plan/remains")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch uses official token plan remains endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official auth failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + throw URLError(.timedOut) + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect([ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ].contains(url.path)) + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + session: transport) + } + let requests = await transport.requests() + + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `combo metadata parser extracts token plan subscription label`() throws { + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(Self.comboMetadataJSON.utf8)) + #expect(metadata.planName == "TokenPlanMax-年度会员") + #expect(metadata.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(metadata.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + } + + @Test + func `combo metadata parser prefers current subscription over package catalog`() throws { + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanUltra-年度会员" + }, + "packages": [ + { "resource_package_name": "TokenPlanPlus" }, + { "resource_package_name": "TokenPlanMax" }, + { "resource_package_name": "TokenPlanUltra" } + ] + } + } + """ + + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(json.utf8)) + + #expect(metadata.planName == "TokenPlanUltra-年度会员") + } + + @Test + func `web usage fetch merges combo subscription metadata`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") + #expect(url.query?.contains("biz_line=2") == true) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2013894056999916075") + #expect(request.value(forHTTPHeaderField: "origin") == "https://platform.minimaxi.com") + return Self.httpResponse(url: url, body: Self.comboMetadataJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc; minimax_group_id_v2=2013894056999916075", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.planName == "TokenPlanMax-年度会员") + #expect(snapshot.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.toUsageSnapshot().subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) + } + + @Test + func `combo metadata rejects non https host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "http://metadata.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `combo metadata rejects malformed host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "bad host"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `web usage fetch preserves combo metadata cancellation`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + } + } + + @Test + func `combo metadata failure does not block quota rendering`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.planName == nil) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + private static let comboMetadataJSON = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanMax-年度会员", + "current_subscribe_end_time": "05/19/2027", + "renewal_date": "05/18/2027", + "current_subscribe_end_time_ts": 1810656000000, + "renewal_trigger_time_ts": 1810569600000 + }, + "packages": [ + { + "resource_package_name": "TokenPlanMax", + "display_name": "Token Plan · TokenPlanMax-年度会员" + } + ] + } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 3a4d1807d..90bdeed5d 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -508,6 +508,72 @@ struct ModelsDevPricingTests { #expect(load.error == .invalidJSON) } + @Test + func `serves decoded catalog from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + + // Pin a whole-second modification date so the memo key (which compares modification dates) round-trips + // deterministically through the filesystem. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + // Prime the in-memory memo with a successful decode. + let primed = ModelsDevCache.load(cacheRoot: root) + let cachedArtifact = try #require(primed.artifact) + + // Corrupt the file contents while preserving its size and modification date, so the on-disk identity + // the memo keys on is unchanged. A re-decode would now fail; a memo hit returns the cached artifact. + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact == cachedArtifact) + } + + @Test + func `saving a new catalog invalidates the memo`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + #expect(ModelsDevCache.load(cacheRoot: root).artifact?.catalog.providers["openai"] != nil) + + // Overwriting the cache must drop the memo so the next load reflects the freshly written catalog. + ModelsDevCache.save(catalog: ModelsDevCatalog(providers: [:]), fetchedAt: Date(), cacheRoot: root) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact?.catalog.providers.isEmpty == true) + } + + @Test + func `serves a failed load from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let validData = try Self.encodedArtifactData() + + // Write invalid JSON of the same size as a valid encoding, with a pinned modification date, then prime + // the memo with the resulting failure. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try Data(repeating: 0x7B, count: validData.count).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + #expect(ModelsDevCache.load(cacheRoot: root).error == .invalidJSON) + + // Replace the bytes with a valid encoding of identical size + modification date. A re-read would now + // succeed, so a returned failure proves the unchanged-identity file was not read and decoded again. + try validData.write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == .invalidJSON) + #expect(reloaded.artifact == nil) + } + @Test func `client fetches with mock transport`() async throws { let data = try Self.fixtureData() @@ -545,6 +611,17 @@ struct ModelsDevPricingTests { try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) } + /// A valid `ModelsDevCacheArtifact` encoding, written the same way `ModelsDevCache.save` writes the file. + private static func encodedArtifactData() throws -> Data { + let artifact = try ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: self.fixtureCatalog()) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(artifact) + } + private static func catalog(_ json: String) throws -> ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index fb88a4103..53c367f61 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -193,7 +193,7 @@ struct OpenAIDashboardWebViewCacheTests { #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") var bodyText: String? - let deadline = Date().addingTimeInterval(2) + let deadline = Date().addingTimeInterval(5) repeat { try? await Task.sleep(for: .milliseconds(100)) bodyText = try await webView.evaluateJavaScript( diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 7ac329e0c..124d1748e 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -44,6 +44,31 @@ struct PathBuilderTests { #expect(async == sync) } + @Test + func `login shell cache retries after timed out nil capture`() { + let capture = LoginShellPathCaptureStub([ + nil, + ["/login/bin", "/usr/bin"], + ]) + + let cache = LoginShellPathCache { _, _ in capture.next() } + let semaphore = DispatchSemaphore(value: 0) + var firstResult: [String]? + cache.captureOnce(shell: "/unused", timeout: 0.01) { result in + firstResult = result + semaphore.signal() + } + + #expect(semaphore.wait(timeout: .now() + 10.0) == .success) + #expect(firstResult == nil) + #expect(cache.current == nil) + + let recovered = cache.currentOrCapture(shell: "/unused", timeout: 2.0) + #expect(recovered == ["/login/bin", "/usr/bin"]) + #expect(cache.current == ["/login/bin", "/usr/bin"]) + #expect(capture.callCount == 2) + } + @Test func `shell runner drains noisy stdout and stderr`() throws { let script = """ @@ -628,6 +653,29 @@ struct PathBuilderTests { } } +private final class LoginShellPathCaptureStub: @unchecked Sendable { + private let lock = NSLock() + private var results: [[String]?] + private var callCountStorage = 0 + + var callCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.callCountStorage + } + + init(_ results: [[String]?]) { + self.results = results + } + + func next() -> [String]? { + self.lock.lock() + defer { self.lock.unlock() } + self.callCountStorage += 1 + return self.results.isEmpty ? nil : self.results.removeFirst() + } +} + private final class MockFileManager: FileManager { private let executables: Set diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 0a903e95e..8879df6d7 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -401,6 +401,69 @@ struct ProviderStorageFootprintTests { #expect(store.storageRefreshGeneration == 41) } + @Test + @MainActor + func `scheduled storage refresh notices managed Codex home changes`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let ambientHome = home.appendingPathComponent("ambient", isDirectory: true) + let firstManagedHome = home.appendingPathComponent("managed-a", isDirectory: true) + let secondManagedHome = home.appendingPathComponent("managed-b", isDirectory: true) + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstManagedHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondManagedHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: firstManagedHome.appendingPathComponent("session.jsonl")) + try Data(repeating: 2, count: 32).write(to: secondManagedHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-managed-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": ambientHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: firstManagedHome.path), + ] + + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: secondManagedHome.path), + ] + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + + private static func managedCodexAccount(homePath: String) -> ManagedCodexAccount { + ManagedCodexAccount( + id: UUID(), + email: "storage@example.com", + managedHomePath: homePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + } + private static func makeTemporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 7cdbf152f..e03bcf36c 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -28,6 +28,22 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.nextRegenPercent, 4) } + func test_backfillsZeroWindowDurationFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil) + let fresh = RateWindow(usedPercent: 62, windowMinutes: 0, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + } + func test_skipsExpiredCachedReset() { let now = Date(timeIntervalSince1970: 1_800_000_000) let cached = RateWindow( @@ -76,6 +92,8 @@ final class ResetTimeBackfillTests: XCTestCase { secondary: nil, extraRateWindows: [extra], cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) @@ -87,6 +105,8 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43200)) XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") } diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 44a3e3e40..fc68cba5b 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -74,6 +74,31 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `minimax settings snapshot uses selected token account as manual cookie`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-token-account") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + settings.addTokenAccount(provider: .minimax, label: "account", token: "HERTZ-SESSION=selected") + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=selected") + } + + @Test + func `minimax settings snapshot falls back to global cookie without token accounts`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-global-cookie") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .auto) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 7c3b70790..d252d758b 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1034,6 +1034,34 @@ struct SettingsStoreTests { #expect(didChange.get() == true) } + @Test + func `menu observation token ignores merged switcher selection churn`() async throws { + let suite = "SettingsStoreTests-observation-switcher-selection" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.selectedMenuProvider = .claude + store.mergedMenuLastSelectedWasOverview.toggle() + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == false) + } + @Test func `menu observation token updates on per-window quota threshold changes`() async throws { let suite = "SettingsStoreTests-observation-quota-threshold-windows" diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index ba9bc457d..fc96c1310 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -932,6 +932,104 @@ struct StatusItemAnimationTests { #expect(both == "40%") } + @Test + func `claude primary menu bar metric computes pace from selected session window`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-primary-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "20% · +60%") + } + + @Test + func `codex menu bar pace does not fall back to session when weekly projection is unavailable`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-no-weekly-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "20%") + } + @Test func `menu bar display text uses credits when codex weekly is exhausted`() { let settings = SettingsStore( diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 73a427366..fb06f513f 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -58,6 +58,64 @@ struct StatusItemControllerShutdownTests { #expect(controller.mergedMenu == nil) } + @Test + func `status menu quit defers shutdown until menu tracking can unwind`() { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + + var scheduledTermination: (@MainActor () -> Void)? + var didTerminate = false + controller.scheduleQuitTermination = { operation in + scheduledTermination = operation + } + controller.terminateApplicationForQuit = { + didTerminate = true + } + + controller.quit() + + #expect(scheduledTermination != nil) + #expect(!controller.hasPreparedForAppShutdown) + #expect(!didTerminate) + #expect(controller.openMenus[key] === menu) + + scheduledTermination?() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.statusItem.menu == nil) + #expect(didTerminate) + } + + private func makeController() -> StatusItemController { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + if let codexMetadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + private func makeSettings() -> SettingsStore { let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 8fce39ad8..ebb5ca6f2 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -108,12 +108,12 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") - #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") - #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") - #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") - #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") - #expect(codexButton.accessibilityTitle() == "CodexBar") - #expect(claudeButton.accessibilityTitle() == "CodexBar") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "QuotaKit.StatusItem") + #expect(codexButton.accessibilityIdentifier() == "QuotaKit.StatusItem.codex") + #expect(claudeButton.accessibilityIdentifier() == "QuotaKit.StatusItem.claude") + #expect(controller.statusItem.button?.accessibilityTitle() == "QuotaKit") + #expect(codexButton.accessibilityTitle() == "QuotaKit") + #expect(claudeButton.accessibilityTitle() == "QuotaKit") } @Test @@ -377,7 +377,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(newClaudeItem === oldClaudeItem) #expect(newCodexItem.button === oldCodexButton) #expect(newCodexItem.autosaveName == "codexbar-codex") - #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "QuotaKit.StatusItem.codex") } @Test @@ -395,7 +395,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItem === oldMergedItem) #expect(controller.statusItem.button === oldMergedButton) #expect(controller.statusItem.autosaveName == "codexbar-merged") - #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "QuotaKit.StatusItem") } @Test @@ -427,7 +427,7 @@ struct StatusItemControllerSplitLifecycleTests { let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) #expect(newCodexItem.autosaveName == "codexbar-codex") - #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "QuotaKit.StatusItem.codex") } @Test @@ -445,6 +445,6 @@ struct StatusItemControllerSplitLifecycleTests { let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) #expect(controller.statusItem.autosaveName == "codexbar-merged") - #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(mergedButton.accessibilityIdentifier() == "QuotaKit.StatusItem") } } diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 6660c264e..5d2968a9e 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -20,6 +20,9 @@ struct StatusItemIconObservationSignatureTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -55,6 +58,126 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() == baseline) } + @Test + func `store icon observation signature ignores non visual snapshot churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `merged store icon observation signature ignores non primary snapshot churn`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-snapshot") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + store._setSnapshotForTesting( + Self.makeSnapshot(provider: .claude, email: "claude@example.com"), + provider: .claude) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .claude, + email: "changed@example.com", + primaryUsedPercent: 99, + secondaryUsedPercent: 88, + updatedAt: Date(timeIntervalSince1970: 300)), + provider: .claude) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when icon percentages change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-percent-change") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 42, + secondaryUsedPercent: 63), + provider: .codex) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when credit fallback changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-credit-fallback") + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 100, + secondaryUsedPercent: 20), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature ignores unused credit balance`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-unused-credits") + defer { controller.releaseStatusItemsForTesting() } + + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `merged store icon observation signature changes when non primary status changes`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + @Test func `store icon observation signature changes when status indicator changes`() { let (_, store, controller) = self.makeController( @@ -75,11 +198,26 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() != baseline) } - private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + private static func makeSnapshot( + provider: UsageProvider, + email: String, + primaryUsedPercent: Double = 10, + secondaryUsedPercent: Double = 20, + updatedAt: Date = Date(timeIntervalSince1970: 100)) + -> UsageSnapshot + { UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 100), + primary: RateWindow( + usedPercent: primaryUsedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: secondaryUsedPercent, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, identity: ProviderIdentitySnapshot( providerID: provider, accountEmail: email, diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift new file mode 100644 index 000000000..e2e1fbaf3 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -0,0 +1,161 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `stale data refresh suppresses icon attached closed menu preparation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Simulate a closed menu that was attached by an icon update but has never been opened. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + let key = ObjectIdentifier(menu) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.prepareAttachedClosedMenusIfNeeded() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == nil) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `stale refresh completion requeues required closed menu preparation blocked by refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + store.isRefreshing = true + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `data refresh while persistent menu is open rebuilds on close`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift index cf65a2102..58352c341 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -276,4 +276,125 @@ struct StatusMenuCodexSwitcherPresentationTests { #expect(hydrated.first?.snapshot?.primary?.usedPercent == 17) #expect(hydrated.first?.account.email == account.email) } + + @Test + func `codex account snapshot store rejects mismatched workspace records`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let oldAccountID = UUID() + let newAccountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-old", + storedAccountID: oldAccountID, + selectionSource: .managedAccount(id: oldAccountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: newAccountID, + selectionSource: .managedAccount(id: newAccountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + + @Test + func `codex account snapshot store rejects same stored account after auth fingerprint changes`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "old-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "new-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + + @Test + func `codex account snapshot store rejects legacy workspace records without identity`() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + let payload = """ + { + "records" : [ + { + "error" : "cached", + "id" : "legacy@example.com", + "snapshot" : null, + "sourceLabel" : "legacy" + } + ], + "version" : 1 + } + """ + try Data(payload.utf8).write(to: fileURL) + + let accountID = UUID() + let workspaceAccount = CodexVisibleAccount( + id: "legacy@example.com", + email: "legacy@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + + let hydrated = store.load(for: [workspaceAccount]) + + #expect(hydrated.isEmpty) + } } diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift new file mode 100644 index 000000000..18f27e501 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -0,0 +1,302 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `menu card sizing uses displayed hosting view`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let counter = MenuCardRepresentableCounter() + let item = controller.makeMenuCardItem( + CountingMenuCardRepresentable(counter: counter), + id: "countingCard-\(UUID().uuidString)", + width: 320, + heightCacheScope: "counting", + heightCacheFingerprint: "counting-\(UUID().uuidString)") + let view = try #require(item.view) + + view.layoutSubtreeIfNeeded() + + #expect(counter.makeViewCount == 1) + } + + @Test + func `menu card height cache is reused for stable card content`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstKeys = Set(controller.menuCardHeightCache.keys) + + #expect(!firstKeys.isEmpty) + + controller.populateMenu(menu, provider: .codex) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + + controller.invalidateMenus() + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + } + + @Test + func `standard menu width cache is reused for stable action rows`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstCache = controller.measuredStandardMenuWidthCache + + #expect(!firstCache.isEmpty) + #expect(firstCache.keys.allSatisfy { + $0.contains("font=\(StatusItemController.menuCardHeightTextScaleToken())") + }) + + controller.populateMenu(menu, provider: .codex) + #expect(controller.measuredStandardMenuWidthCache == firstCache) + } + + @Test + func `fingerprinted menu card height cache survives content version invalidation`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 42) + #expect(measureCount == 1) + } + + @Test + func `fingerprinted menu card height cache remeasures when content changes`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:a") + { + measureCount += 1 + return 42 + } + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:b") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `unfingerprinted menu card height cache remains content version scoped`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `menu invalidation prunes old version scoped height cache entries`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + _ = controller.cachedMenuCardHeight( + for: "versioned", + scope: UsageProvider.codex.rawValue, + width: 320) + { + 42 + } + _ = controller.cachedMenuCardHeight( + for: "fingerprinted", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + 99 + } + + controller.invalidateMenus() + + #expect(controller.menuCardHeightCache.keys.allSatisfy { !$0.fingerprint.hasPrefix("version:") }) + #expect(controller.menuCardHeightCache.keys.contains { $0.fingerprint == "content:stable" }) + } + + @Test + func `menu card height cache scopes same row ids by provider`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.populateMenu(menu, provider: .claude) + + let scopes = Set(controller.menuCardHeightCache.keys.map(\.scope)) + #expect(scopes.contains(UsageProvider.codex.rawValue)) + #expect(scopes.contains(UsageProvider.claude.rawValue)) + } + + private func makeHeightCacheController() -> StatusItemController { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } +} + +@MainActor +private final class MenuCardRepresentableCounter { + var makeViewCount = 0 +} + +private struct CountingMenuCardRepresentable: NSViewRepresentable { + let counter: MenuCardRepresentableCounter + + func makeNSView(context: Context) -> NSTextField { + self.counter.makeViewCount += 1 + return NSTextField(labelWithString: "Counted") + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + _ = nsView + _ = context + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index a528c5c51..2a6323058 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -171,6 +171,76 @@ struct StatusMenuHostedSubmenuRefreshTests { seed: Self.seedZaiHourlyUsage) } + @Test + func `hosted chart items size to the displayed view without a throwaway controller`() throws { + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + { controller, submenu, width in + controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + { controller, submenu, width in + controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + { controller, submenu, width in + controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) + } + } + + private func assertHostedChartItemHeightMatchesRefresh( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void, + append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = NSMenu() + submenu.minimumWidth = width + #expect(append(controller, submenu, width)) + + let item = try #require(submenu.items.first) + let view = try #require(item.view) + let heightFromAppend = view.frame.height + // The height the append path assigns must match the authoritative re-measure pass; otherwise + // dropping the throwaway NSHostingController would have changed sizing behavior. + controller.refreshHostedSubviewHeights(in: submenu) + #expect(view.frame.height == heightFromAppend) + #expect(heightFromAppend > 1) + } + private func assertHostedSubmenuPreservesIdentity( chartID: String, provider: UsageProvider, diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 3d622dd3d..21e57ae44 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -169,7 +169,7 @@ extension StatusMenuTests { } @Test - func `closed attached menu is prepared before next open after invalidation`() async { + func `closed merged menu defers rebuild until next open instead of pre-warming`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -199,12 +199,122 @@ extension StatusMenuTests { let key = ObjectIdentifier(menu) let openedVersion = controller.menuVersions[key] + // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so + // the closed merged menu must not be pre-warmed or marked deferred. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // A required (non-stale) invalidation must also leave the closed merged menu deferred. controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // The deferred merged menu is repopulated synchronously on the next open. + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + } + + @Test + func `data refresh invalidation does not rebuild closed non merged attached menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu: stale data-refresh invalidations should not pre-warm any + // closed attached menu, while required invalidations still may prepare non-merged menus. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `required non merged closed menu preparation survives later data refresh invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu so this covers the delayed closed-menu rebuild path. Merged + // menus are intentionally deferred until next open on current main (#1274). + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) for _ in 0..<40 where controller.menuVersions[key] == openedVersion { await Task.yield() } #expect(controller.openMenus.isEmpty) + #expect(requiredVersion > (openedVersion ?? -1)) #expect(controller.menuVersions[key] == controller.menuContentVersion) } @@ -231,7 +341,9 @@ extension StatusMenuTests { controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.mergedMenu = menu + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu controller.statusItem.menu = menu controller.populateMenu(menu, provider: nil) @@ -281,7 +393,9 @@ extension StatusMenuTests { controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.mergedMenu = menu + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu controller.statusItem.menu = menu controller.populateMenu(menu, provider: nil) @@ -345,6 +459,50 @@ extension StatusMenuTests { #expect(controller.closedMenuRebuildTokens[key] == nil) } + @Test + func `merged menu close defers stale rebuild until next open`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + controller.invalidateMenus(refreshOpenMenus: false) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menuDidClose(menu) + await self.waitUntilClosedMenuRebuildRemainsDeferred(controller, key: key, openedVersion: openedVersion) + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + @Test func `menu open keeps stale nonempty content while store refresh is active`() { self.disableMenuCardsForTesting() @@ -654,6 +812,84 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) } + @Test + func `rapid switcher rebuild requests coalesce before populating open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 0 + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + var refreshGateEntries = 0 + var pendingRefreshGates: [CheckedContinuation] = [] + func resumePendingRefreshGates() { + let gates = pendingRefreshGates + pendingRefreshGates.removeAll(keepingCapacity: true) + for gate in gates { + gate.resume() + } + } + controller._test_openMenuRefreshYieldOverride = { + refreshGateEntries += 1 + await withCheckedContinuation { continuation in + pendingRefreshGates.append(continuation) + } + } + defer { + resumePendingRefreshGates() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + for _ in 0..<20 where refreshGateEntries == 0 { + await Task.yield() + } + #expect(refreshGateEntries == 1) + #expect(rebuildCount == 0) + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + resumePendingRefreshGates() + for _ in 0..<20 where refreshGateEntries < 2 { + await Task.yield() + } + #expect(refreshGateEntries == 2) + #expect(rebuildCount == 0) + resumePendingRefreshGates() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 1) + } + @Test func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { self.disableMenuCardsForTesting() @@ -1389,6 +1625,19 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == controller.menuContentVersion) } + private func waitUntilClosedMenuRebuildRemainsDeferred( + _ controller: StatusItemController, + key: ObjectIdentifier, + openedVersion: Int?) async + { + for _ in 0..<40 + where controller.closedMenuRebuildTasks[key] != nil || + controller.menuVersions[key] != openedVersion + { + await Task.yield() + } + } + private func makeOpenAIDashboard( dailyBreakdown: [OpenAIDashboardDailyBreakdown], updatedAt: Date) -> OpenAIDashboardSnapshot diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 6b90c164e..df65ad9e4 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -61,4 +61,68 @@ extension StatusMenuTests { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) } + + @Test + func `overview row submenu action does not switch provider detail`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .zai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [ZaiUsageDetail(modelCode: "glm-4.5", usage: 512)], + nextResetTime: now.addingTimeInterval(3600)), + planName: "Pro", + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .zai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let zaiRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-zai" + }) + #expect(zaiRow.submenu != nil) + + let action = try #require(zaiRow.action) + let target = try #require(zaiRow.target as? StatusItemController) + _ = target.perform(action, with: zaiRow) + + #expect(settings.mergedMenuLastSelectedWasOverview) + #expect(settings.selectedMenuProvider == .claude) + #expect(menu.items.contains { + ($0.representedObject as? String) == "overviewRow-zai" + }) + } } diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index dc5208375..c4784c81c 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -97,7 +97,7 @@ struct StatusMenuPersistentRefreshTests { let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) - for title in ["Update ready, restart now?", "Refresh", "Settings...", "About CodexBar", "Quit"] { + for title in ["Update ready, restart now?", "Refresh", "Settings...", "About QuotaKit", "Quit"] { let item = try #require(menu.items.first { $0.title == title }) #expect(item.view is PersistentMenuActionItemView) #expect(item.view?.frame.height == PersistentMenuActionItemView.rowHeight) @@ -127,7 +127,7 @@ struct StatusMenuPersistentRefreshTests { width: 320, onClick: {}), PersistentMenuActionItemView( - title: "About CodexBar", + title: "About QuotaKit", systemImageName: "info.circle", shortcutText: nil, width: 320, diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift new file mode 100644 index 000000000..8cf42bbf9 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -0,0 +1,591 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `reopening root menu resyncs readiness baseline so reverted store data still refreshes`() { + // Regression for the readiness-signature optimization (#1351): the baseline is no longer + // recomputed on every store change while menus are closed, so it must be re-anchored when a + // root menu opens. Otherwise a closed-then-reopened menu built from new data, followed by an + // open-menu change that reverts to the *previous* baseline value, would be treated as + // "unchanged" and skip the rebuild, leaving the visible menu showing stale content. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + + // Root open anchors the baseline to snapshot A. Normalize via an explicit comparison so the + // assertion below is independent of whatever the controller's initial baseline happened to be. + controller.menuWillOpen(menu) + _ = controller.didMenuAdjunctReadinessChange() + controller.menuDidClose(menu) + + // Closed store change to B: the optimization intentionally skips recomputing the baseline here. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + + // Reopening the root menu rebuilds from B and must re-anchor the baseline to B. + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + // Reverting to A (the value the *first* baseline held) must still register as a change. + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open during in flight refresh preserves stale content and does not resync baseline`() { + // When `refreshMenuForOpenIfNeeded` keeps existing menu content during an in-flight provider + // refresh, the readiness baseline must not be re-anchored to live store data. Otherwise the + // refresh-completion store mutation would compare equal against the prematurely resynced baseline + // and skip the rebuild, leaving stale content visible (#1351). + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // Stale content was preserved: the menu is still behind the current content version. + #expect(controller.menuNeedsRefresh(menu)) + + store.isRefreshing = false + // Refresh completion must still register as a readiness change so the open menu can rebuild. + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open before deferred store observation rebuilds and refreshes matching observer`() { + // Store observation invalidates menus from a deferred main-actor task. If a closed menu opens after + // live data changes but before that task runs, it must rebuild from live data and let the matching + // observer invalidate any coalesced non-readiness menu state without losing the readiness baseline. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + // Simulate the live store mutation being visible before the observation task has invalidated menus. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let versionAfterOpen = controller.menuContentVersion + let menuVersionAfterOpen = controller.menuVersions[key] + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuVersions[key] == menuVersionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open before deferred store observation during refresh leaves observer pending`() { + // The pre-observer root-open repair must not bypass the in-flight refresh stale-content path. + // While data is refreshing, the deferred observer should still invalidate the open menu and defer + // parent rebuild instead of marking an intermediate snapshot fresh. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `fresh newer-version root open during unrelated refresh still reanchors baseline`() { + // An in-flight refresh elsewhere must not block re-anchoring when this menu was already rebuilt for + // a newer menuContentVersion. Otherwise the stale baseline can still hide a later reverted update. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + store.isRefreshing = true + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open advances baseline version before next pre-observer change`() { + // A root open whose signature still equals the baseline can nevertheless confirm that the visible + // menu is fresh for a newer menuContentVersion. Record that version so a later live-data change before + // its deferred observer does not look like already-rendered data. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `newer-version root open rebuilds when rendered signature is older than live data`() { + // A menu can be fresh for the current menuContentVersion while still having rendered an older + // readiness signature than the current live store. Root open must rebuild in that pre-observer gap. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + let snapshotC = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 333, + sessionCostUSD: 3.33, + last30DaysTokens: 3333, + last30DaysCostUSD: 33.33, + updatedAt: Date(timeIntervalSince1970: 300)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotC, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open rebuilds when rendered signature reverted before observer`() { + // A closed provider menu can be rebuilt from B while the readiness baseline remains A. If live data + // reverts to A before the deferred observer runs, root open must still repair the B-rendered menu. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + let key = ObjectIdentifier(menu) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + let renderedBSignature = controller.menuReadinessSignatures[key] + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeOpen) + #expect(controller.menuReadinessSignatures[key] != renderedBSignature) + #expect(controller.menuReadinessSignatures[key] == controller.menuAdjunctReadinessSignature()) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `provider root open before deferred store observation leaves sibling provider menu stale`() { + // The readiness signature is global across enabled providers. In split-icon mode, opening one + // provider's menu must not consume a pending global observation while leaving sibling menus marked + // fresh even though their provider data changed. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableProvidersForReadinessBaseline(settings, providers: [.claude, .codex]) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .claude) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let claudeMenu = controller.makeMenu(for: .claude) + controller.populateMenu(claudeMenu, provider: .claude) + controller.markMenuFresh(claudeMenu) + let codexMenu = controller.makeMenu(for: .codex) + controller.populateMenu(codexMenu, provider: .codex) + controller.markMenuFresh(codexMenu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .claude) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { + self.enableProvidersForReadinessBaseline(settings, providers: [.codex]) + } + + private func enableProvidersForReadinessBaseline(_ settings: SettingsStore, providers: Set) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func makeReadinessBaselineTokenSnapshot( + sessionTokens: Int, + sessionCostUSD: Double, + last30DaysTokens: Int, + last30DaysCostUSD: Double, + updatedAt: Date) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 9fe93b78d..67acc94dc 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -16,6 +16,7 @@ struct StatusMenuSwitcherClickTests { let suite = "StatusMenuSwitcherClickTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") let configStore = testConfigStore(suiteName: suite) return SettingsStore( userDefaults: defaults, @@ -129,6 +130,241 @@ struct StatusMenuSwitcherClickTests { #expect(settings.selectedMenuProvider == .codex) } + @Test + func `merged switcher commits selection on matching mouse up`() throws { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateMouseDown(buttonTag: 0)) + #expect(selections.isEmpty) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(switcher.handleMenuTrackingMouseUp(mouseUp)) + #expect(selections == [.overview]) + } + + @Test + func `menu tracking routes switcher pointer sequence before AppKit menu dispatch`() throws { + var selected: ProviderSwitcherSelection? + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selected = $0 }) + let menu = StatusItemMenu() + let item = NSMenuItem() + item.view = switcher + item.isEnabled = false + menu.addItem(item) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 0)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(selected == nil) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(selected == .overview) + } + + @Test + func `merged switcher runtime click defers icon rendering until after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + } + + @Test + func `merged switcher click marks menu stale before deferred rebuild`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let openedVersion = controller.menuContentVersion + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + + controller.menuDidClose(menu) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `merged switcher runtime click updates loading animation state after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.selectedMenuProvider == .claude) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == true) + #expect(controller.animationDriver != nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + + #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.selectedMenuProvider == .codex) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + } + @Test func `merged switcher switches provider while overview chart submenu is open`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 4d71f58e1..a49fc7c1c 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -6,6 +6,41 @@ import Testing @MainActor @Suite(.serialized) struct StatusMenuSwitcherRefreshTests { + @Test + func `native switcher action preserves off tab switches after button state toggles`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 1, state: .on)) + #expect(selections == [.provider(.claude)]) + } + + @Test + func `native switcher action restores active tab after native toggle`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 0, state: .off)) + #expect(selections.isEmpty) + #expect(Self.switcherButtons(in: switcher).first { $0.tag == 0 }?.state == .on) + } + @Test func `merged provider switch rebuilds stale width switcher rows`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -73,6 +108,220 @@ struct StatusMenuSwitcherRefreshTests { #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) } + @Test + func `selected provider tab click does not rebuild open menu`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(switcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + try? await Task.sleep(for: .milliseconds(40)) + + #expect(rebuildCount == 0) + #expect(Self.switcherButtons(in: menu).first { $0.tag == selectedButton.tag }?.state == .on) + } + + @Test + func `merged provider switch restores cached tab content`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let originalContentID = ObjectIdentifier(menu.items[contentStartIndex]) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + let alternateContentID = ObjectIdentifier(menu.items[contentStartIndex]) + #expect(alternateContentID != originalContentID) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let restoredSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == alternateContentID) + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + } + + @Test + func `provider switch does not cache stale rows after required invalidation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + let originalContent = try #require( + menu.items.indices.contains(contentStartIndex) ? menu.items[contentStartIndex] : nil) + let originalContentID = ObjectIdentifier(originalContent) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.invalidateMenus() + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) != originalContentID) + } + + @Test + func `tab switch does not replace quota indicator constraints`() { + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in 75.0 }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2, "both providers should have quota indicators") + + switcher.updateQuotaIndicators() + + let afterFirstCall = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterFirstCall == initialConstraints, "same ratio: constraints must not be replaced") + } + + @Test + func `quota indicator constraints are replaced when ratio changes`() { + var currentRemaining = 75.0 + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in currentRemaining }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2) + + currentRemaining = 40.0 + switcher.updateQuotaIndicators() + + let afterDataChange = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterDataChange != initialConstraints, "changed ratio: constraints should be replaced") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -94,9 +343,35 @@ struct StatusMenuSwitcherRefreshTests { } } + private static func disableOverview(_ settings: SettingsStore) { + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + } + + private static func waitForRebuildCount( + _ expectedCount: Int, + rebuildCount: () -> Int) async + { + for _ in 0..<100 where rebuildCount() < expectedCount { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + } + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } - return switcherView.subviews + return self.switcherButtons(in: switcherView) + } + + private static func switcherButtons(in switcherView: ProviderSwitcherView) -> [NSButton] { + switcherView.subviews .compactMap { $0 as? NSButton } .sorted { $0.tag < $1.tag } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift new file mode 100644 index 000000000..c89630ec2 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift @@ -0,0 +1,187 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherTrackingTests { + @Test + func `pointer switch defers structural menu rebuild until mouse up`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 2)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(controller.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu)) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(settings.selectedMenuProvider == .claude) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(rebuildCount == 1) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `pointer switch cancels when mouse up leaves pressed segment`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUpElsewhere = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUpElsewhere, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 0) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `unrelated mouse up remains available to normal menu items`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let item = NSMenuItem() + item.view = switcher + menu.addItem(item) + let unrelatedMouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + #expect(!controller.handleProviderSwitcherTrackingEvent(unrelatedMouseUp, menu: menu)) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherTrackingTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c29d770e3..96d2d1bd5 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -752,7 +752,7 @@ struct StatusMenuTests { #expect(!titles.contains("Status Page")) #expect(titles.contains("Refresh")) #expect(titles.contains("Settings...")) - #expect(titles.contains("About CodexBar")) + #expect(titles.contains("About QuotaKit")) #expect(titles.contains("Quit")) let refreshItem = menu.items.first { $0.title == "Refresh" } @@ -1671,7 +1671,7 @@ extension StatusMenuTests { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude + let shouldEnable = provider == .codex || provider == .cursor settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } @@ -1688,15 +1688,15 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let claudeRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-claude" + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" }) - let action = try #require(claudeRow.action) - let target = try #require(claudeRow.target as? StatusItemController) - _ = target.perform(action, with: claudeRow) + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .claude) + #expect(settings.selectedMenuProvider == .cursor) let ids = self.representedIDs(in: menu) #expect(ids.contains("menuCard")) diff --git a/Tests/CodexBarTests/SyncCoordinatorMultiAccountTests.swift b/Tests/CodexBarTests/SyncCoordinatorMultiAccountTests.swift index cb7b348fc..5d05414a5 100644 --- a/Tests/CodexBarTests/SyncCoordinatorMultiAccountTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorMultiAccountTests.swift @@ -20,16 +20,19 @@ struct SyncCoordinatorMultiAccountTests { private func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") // Reset mock-provider state — see same comment in // SyncMultiAccountEdgeCasesTests.makeSettingsStore. UserDefaults.standard.removeObject( forKey: MockProviderInjector.userDefaultsKey) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings } private func makeUsageStore(settings: SettingsStore) -> UsageStore { diff --git a/Tests/CodexBarTests/SyncMultiAccountEdgeCasesTests.swift b/Tests/CodexBarTests/SyncMultiAccountEdgeCasesTests.swift index c6e65b78e..ba9953547 100644 --- a/Tests/CodexBarTests/SyncMultiAccountEdgeCasesTests.swift +++ b/Tests/CodexBarTests/SyncMultiAccountEdgeCasesTests.swift @@ -18,6 +18,7 @@ struct SyncMultiAccountEdgeCasesTests { private func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") // Ensure mock-provider injection is off — MockProviderInjector // reads UserDefaults.standard (process-wide) so a parallel test // suite that flipped the flag could leak into our SyncCoordinator @@ -26,11 +27,13 @@ struct SyncMultiAccountEdgeCasesTests { UserDefaults.standard.removeObject( forKey: MockProviderInjector.userDefaultsKey) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings } private func makeUsageStore(settings: SettingsStore) -> UsageStore { diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 14dfe6392..148ef2424 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -974,6 +974,8 @@ extension TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) } @@ -993,6 +995,8 @@ extension TokenAccountEnvironmentPrecedenceTests { #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) #expect(after.cursorRequests?.used == before.cursorRequests?.used) #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.subscriptionExpiresAt == before.subscriptionExpiresAt) + #expect(after.subscriptionRenewsAt == before.subscriptionRenewsAt) #expect(after.updatedAt == before.updatedAt) } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index d17aa9245..3428b2d0d 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -1,10 +1,28 @@ import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar @MainActor struct UsageStoreCoverageTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `provider with highest usage and icon style`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-highest") @@ -70,6 +88,36 @@ struct UsageStoreCoverageTests { #expect(label.contains("openai-web")) } + @Test + func `account info caches codex auth parsing until config revision changes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-account-info-cache") + let home = FileManager.default.temporaryDirectory.appendingPathComponent( + "usage-store-account-info-\(UUID().uuidString)", + isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + try Self.writeCodexAuthFile(homeURL: home, email: "first@example.com", plan: "plus") + let env = ["CODEX_HOME": home.path] + settings._test_codexReconciliationEnvironment = env + defer { settings._test_codexReconciliationEnvironment = nil } + let store = UsageStore( + fetcher: UsageFetcher(environment: env), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: env) + + let first = store.accountInfo(for: .codex) + try Self.writeCodexAuthFile(homeURL: home, email: "second@example.com", plan: "pro") + let cached = store.accountInfo(for: .codex) + settings.configRevision &+= 1 + let refreshed = store.accountInfo(for: .codex) + + #expect(first.email == "first@example.com") + #expect(cached.email == "first@example.com") + #expect(refreshed.email == "second@example.com") + } + @Test func `source label uses configured kilo source`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kilo-source") @@ -471,6 +519,38 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `background work settings observation ignores menu provider selection churn`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-switcher-selection-observation") + settings.refreshFrequency = .manual + settings.mergeIcons = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + didChange.set() + } + + settings.selectedMenuProvider = .codex + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(didChange.get() == false) + + let refreshDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + refreshDidChange.set() + } + + settings.refreshFrequency = .oneMinute + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshDidChange.get() == true) + } + @Test func `startup status network failure schedules bounded retry`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") @@ -595,6 +675,38 @@ struct UsageStoreCoverageTests { environmentBase: [:]) } + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = try [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeCodexJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json"), options: .atomic) + } + + private static func fakeCodexJWT(email: String, plan: String) throws -> String { + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ]) + return "\(Self.base64URL(header)).\(Self.base64URL(payload))." + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 49a353234..ebdbd3d16 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -156,6 +156,45 @@ struct UsageStoreHighestUsageTests { #expect(highest?.usedPercent == 85) } + @Test + func `automatic metric ranks antigravity by constrained gemini family lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-constrained-gemini"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 100) + } + @Test func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { let settings = SettingsStore( diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 1798b8eb4..6199b162c 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -510,6 +510,35 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) } + @Test + func `minimax quota warning posts for session and weekly windows`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-minimax") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 55, weeklyUsed: 55)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.minimax, .minimax]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + @Test func `disabling quota warning window clears fired state`() { let settings = self @@ -550,4 +579,37 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.count == 1) #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) } + + private func minimaxSnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + let now = Date() + return MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: Int(sessionUsed), + limit: 100, + percent: sessionUsed, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: Int(weeklyUsed), + limit: 100, + percent: weeklyUsed, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]).toUsageSnapshot() + } } diff --git a/docs/antigravity.md b/docs/antigravity.md index 5ba4978da..4907ce3fb 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -9,7 +9,7 @@ read_when: # Antigravity provider -Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. +Antigravity supports local probing of either the IDE or the CLI (`agy` / `antigravity-cli`) language server, plus Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. ## OAuth account switching @@ -32,11 +32,19 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The 1) **Process detection** - Command: `ps -ax -o pid=,command=`. - - Match process name: `language_server_macos` plus Antigravity markers: - - `--app_data_dir antigravity` OR path contains `/antigravity/`. + - Match either: + - the **IDE** language server: process name `language_server_macos` plus Antigravity + markers (`--app_data_dir antigravity` OR path contains `/antigravity/`); or + - the **CLI**: an `antigravity-cli` / `antigravity_cli` path segment, or the + `agy` binary (path-anchored so unrelated arguments/binaries do not match). - Extract CLI flags: - - `--csrf_token ` (required). - - `--extension_server_port ` (HTTP fallback). + - `--csrf_token `. Requirement depends on the match kind: + - **IDE** matches still require it — a tokenless IDE `language_server` match is + skipped so a later valid IDE server can be found, otherwise `missingCSRFToken` + is reported (unchanged behavior). + - **CLI** matches accept an empty token, because the CLI's language server + exposes no `--csrf_token` flag and requires none. + - `--extension_server_port ` (HTTP fallback; IDE only). 2) **Port discovery** - Command: `lsof -nP -iTCP -sTCP:LISTEN -p `. diff --git a/docs/claude.md b/docs/claude.md index 9357ead3e..916824ffa 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -116,6 +116,8 @@ Admin API key setup: - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. - Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable deep-link URL handler registration during headless probes. +- After transient probes exit, CodexBar removes Claude Code `.jsonl` session artifacts for that dedicated + `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). diff --git a/docs/versioning.md b/docs/versioning.md index a5e74e739..5a4d44b31 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -9,8 +9,14 @@ QuotaKit currently tracks three version lanes. - `MARKETING_VERSION`: Mac app version. - `BUILD_NUMBER`: Mac build number. - `MOBILE_VERSION`: paired iOS companion version. -- `UPSTREAM_VERSION`: last upstream CodexBar version incorporated. -- `UPSTREAM_SYNC_DATE`: date that upstream alignment was last confirmed. +- `UPSTREAM_VERSION`: last upstream CodexBar version shipped to users. +- `UPSTREAM_SYNC_DATE`: date that shipped upstream alignment was last confirmed. +- `UPSTREAM_MONITOR_BASE`: last upstream CodexBar commit already merged or reviewed for the daily monitor. + +`UPSTREAM_VERSION` is release-facing and should only advance after users can get +the corresponding QuotaKit release. `UPSTREAM_MONITOR_BASE` is workflow-facing +and should advance when an upstream sync PR lands, so the daily monitor only +reopens issues for newly-arrived Pete upstream commits. The Mac release tag for Columbus Labs releases is: diff --git a/version.env b/version.env index febabb525..3a5783b63 100644 --- a/version.env +++ b/version.env @@ -6,3 +6,8 @@ MOBILE_VERSION=1.11.1 # Bump this after the merged version is actually live, not at merge time. UPSTREAM_VERSION=v0.32.4 UPSTREAM_SYNC_DATE=2026-06-06 +# Last upstream commit already merged/reviewed for the daily monitor. +# Advance this when an upstream sync PR lands. It is independent of shipped +# release tracking above, so the monitor does not reopen stale issues while a +# merged upstream sync has not yet shipped to users. +UPSTREAM_MONITOR_BASE=d7b58a050cb18bfc3eac41400d238b84839461e5