diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaa584444..523e07b26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,18 +10,20 @@ concurrency: cancel-in-progress: true env: - SWIFT_VERSION: 6.2.1 + SWIFT_VERSION: 6.3.3 + SWIFTLY_VERSION: 1.1.3 + SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069 jobs: changes: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: macos-tests: ${{ steps.macos-tests.outputs.macos-tests }} macos-tests-reason: ${{ steps.macos-tests.outputs.macos-tests-reason }} changed-path-count: ${{ steps.macos-tests.outputs.changed-path-count }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -72,10 +74,10 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install lint tools run: ./Scripts/install_lint_tools.sh swiftlint @@ -87,25 +89,27 @@ jobs: needs: changes if: ${{ needs.changes.outputs.macos-tests == 'true' }} runs-on: macos-15-intel - timeout-minutes: 40 + timeout-minutes: 60 strategy: fail-fast: false matrix: shard-index: [0, 1, 2, 3] shard-count: [4] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Select Xcode 26.1.1 (if present) or fallback to default + - name: Select Xcode 26.3 or 26.2 run: | set -euo pipefail - for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do + # Both versions are part of the official macOS 15 runner image. + for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do if [[ -d "$candidate" ]]; then sudo xcode-select -s "${candidate}/Contents/Developer" echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" break fi done + [[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]] /usr/bin/xcodebuild -version - name: Swift toolchain version @@ -119,7 +123,8 @@ jobs: run: ./Scripts/lint.sh lint-macos - name: Swift Test - timeout-minutes: 35 + # Intel cold builds can spend ~26 minutes compiling the full test target before isolated suites start. + timeout-minutes: 50 run: | CODEXBAR_TEST_GROUP_SIZE=1 \ CODEXBAR_TEST_SUITE_TIMEOUT=120 \ @@ -148,7 +153,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" lint-build-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 needs: - changes @@ -156,7 +161,7 @@ jobs: - swift-test-macos if: ${{ always() && !cancelled() }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Verify lint and macOS test jobs run: | @@ -202,7 +207,7 @@ jobs: runs-on: ubuntu-24.04-arm runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Runner info run: | @@ -212,10 +217,10 @@ jobs: - name: Restore Swift toolchain cache id: swift-toolchain-cache - uses: actions/cache@v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/swiftly - key: swift-${{ runner.os }}-${{ runner.arch }}-${{ env.SWIFT_VERSION }}-v2 + key: swift-${{ runner.os }}-${{ runner.arch }}-${{ env.SWIFT_VERSION }}-swiftly-${{ env.SWIFTLY_VERSION }} - name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly shell: bash @@ -234,14 +239,26 @@ jobs: fi SWIFTLY_ARCH="$(uname -m)" + SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" + SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig" SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" SWIFTLY_BIN_DIR="$HOME/.local/bin" + SWIFT_GNUPGHOME="$(mktemp -d)" + SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc" POST_INSTALL_SCRIPT="$(mktemp)" mkdir -p "$SWIFTLY_BIN_DIR" + chmod 700 "$SWIFT_GNUPGHOME" echo "Swift toolchain cache hit: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}" - curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_ARCH}.tar.gz" -o /tmp/swiftly.tar.gz - tar -xzf /tmp/swiftly.tar.gz -C /tmp + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE" + curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS" + GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS" + SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \ + --verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)" + printf '%s\n' "$SIGNATURE_STATUS" + grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS" + tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp /tmp/swiftly init --assume-yes --skip-install . "$SWIFTLY_HOME_DIR/env.sh" @@ -249,7 +266,7 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install "$SWIFT_VERSION" --use --assume-yes --post-install-file "$POST_INSTALL_SCRIPT" + swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index c9c6528977..75a020a0c1 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -88,24 +88,29 @@ jobs: runs-on: ${{ matrix.runs-on }} env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + SWIFT_VERSION: 6.2.1 + SWIFTLY_VERSION: 1.1.3 + SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069 SWIFT_STATIC_LINUX_SDK_URL: https://download.swift.org/swift-6.2.1-release/static-sdk/swift-6.2.1-RELEASE/swift-6.2.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz SWIFT_STATIC_LINUX_SDK_CHECKSUM: 08e1939a504e499ec871b36826569173103e4562769e12b9b8c2a50f098374ad - SQLITE_AMALGAMATION_VERSION: "3530200" - SQLITE_AMALGAMATION_SHA3_256: 81142986038e18f96c4a54e1a72562ae17e502a916f2a7701eff43388cbf1a40 + SQLITE_AMALGAMATION_VERSION: "3530300" + SQLITE_AMALGAMATION_SHA3_256: d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Select Xcode 26.1.1 (if present) or fallback to default + - name: Select Xcode 26.3 or 26.2 if: matrix.platform == 'macos' run: | set -euo pipefail - for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do + # Both versions are part of the official macOS 15 runner image. + for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do if [[ -d "$candidate" ]]; then sudo xcode-select -s "${candidate}/Contents/Developer" echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" break fi done + [[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]] /usr/bin/xcodebuild -version - name: Runner info @@ -129,7 +134,7 @@ jobs: exit 1 fi - - name: Install Swift 6.2.1 via swiftly + - name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly if: matrix.platform == 'linux' shell: bash run: | @@ -141,13 +146,25 @@ jobs: fi SWIFTLY_ARCH="$(uname -m)" + SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" + SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig" SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" SWIFTLY_BIN_DIR="$HOME/.local/bin" + SWIFT_GNUPGHOME="$(mktemp -d)" + SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc" POST_INSTALL_SCRIPT="$(mktemp)" mkdir -p "$SWIFTLY_BIN_DIR" - curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_ARCH}.tar.gz" -o /tmp/swiftly.tar.gz - tar -xzf /tmp/swiftly.tar.gz -C /tmp + chmod 700 "$SWIFT_GNUPGHOME" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE" + curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS" + GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS" + SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \ + --verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)" + printf '%s\n' "$SIGNATURE_STATUS" + grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS" + tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp /tmp/swiftly init --assume-yes --skip-install . "$SWIFTLY_HOME_DIR/env.sh" @@ -155,7 +172,7 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install 6.2.1 --use --assume-yes --post-install-file "$POST_INSTALL_SCRIPT" + swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" @@ -393,7 +410,7 @@ jobs: - name: Upload workflow artifact (manual runs) if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: codexbar-cli-${{ matrix.name }} path: | @@ -401,7 +418,7 @@ jobs: ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256 update-homebrew-tap: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build-cli if: github.event_name == 'release' steps: diff --git a/.github/workflows/upstream-monitor.yml b/.github/workflows/upstream-monitor.yml index 9018f75e73..13680a6ead 100644 --- a/.github/workflows/upstream-monitor.yml +++ b/.github/workflows/upstream-monitor.yml @@ -18,14 +18,14 @@ on: jobs: check-upstreams: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: issues: write contents: read steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -91,7 +91,7 @@ jobs: - name: Create or update issue if: steps.check.outputs.upstream_commits > 0 || steps.check.outputs.quotio_commits > 0 - uses: actions/github-script@v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}'; diff --git a/.gitignore b/.gitignore index d9d89c907e..462a66f97a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ Codexbar.app/ # Release artifacts *.ipa *.dSYM* +*.xcarchive/ +*.xcresult/ *.zip *.delta *.dmg diff --git a/.mac-release.env b/.mac-release.env index 4e491751ea..cd85737a53 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -17,11 +17,16 @@ MAC_RELEASE_DOWNLOAD_URL_PREFIX='https://github.com/steipete/CodexBar/releases/d MAC_RELEASE_PRECHECK='make check && make test' MAC_RELEASE_PACKAGE_CMD='Scripts/sign-and-notarize.sh' +MAC_RELEASE_OP_ITEM='API Key - App Store Connect - Personal - Release' +MAC_RELEASE_OP_VAULT=Molty +MAC_RELEASE_OP_FIELDS='APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_API_KEY_P8' +MAC_RELEASE_OP_USE_SERVICE_ACCOUNT=1 MAC_RELEASE_CODESIGN_IDENTITY='Developer ID Application: Peter Steinberger (Y5PE65HELJ)' -MAC_RELEASE_CODESIGN_OP_ITEM='Default Release Keychain' +MAC_RELEASE_CODESIGN_OP_ITEM='Developer ID Release Keychain' MAC_RELEASE_CODESIGN_OP_VAULT=Molty MAC_RELEASE_CODESIGN_OP_USE_SERVICE_ACCOUNT=1 MAC_RELEASE_CODESIGN_KEYCHAIN_MANAGED=1 +MAC_RELEASE_CODESIGN_PASSWORDLESS=1 MAC_RELEASE_TAG_SIGNED=1 MAC_RELEASE_TAG_FORCE=1 MAC_RELEASE_GENERATE_APPCAST_ARGS='--maximum-deltas 0' diff --git a/.swiftformat b/.swiftformat index 4f3218d9ec..656852f5b8 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,4 @@ -# SwiftFormat configuration for Peekaboo project +# SwiftFormat configuration for CodexBar # Compatible with Swift 6 strict concurrency mode # IMPORTANT: Don't remove self where it's required for Swift 6 concurrency @@ -39,7 +39,8 @@ --enumthreshold 0 # Swift 6 specific ---swiftversion 6.2 +--swiftversion 6.3 +--disable redundantSendable # Keep explicit concurrency contracts visible # Other --stripunusedargs closure-only diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c66b3cfee..6d957bd5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,101 @@ # Changelog -## 0.38.1 — Unreleased +## 0.41.1 — Unreleased ### Added +- Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI. + +### Fixed +- Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner! +- Ollama: classify current WorkOS sign-in redirects as expired sessions, enabling cookie-candidate fallback instead of a parser error. Thanks @joeVenner! + +## 0.41.0 — 2026-07-06 + +### Added +- CLI: add responsive `codexbar cards` and compact `--brief` terminal usage views. Thanks @DonnieFi! +- Antigravity: show pace details for legacy model-family and current session/weekly quota rows without changing compact icon lane semantics. Thanks @Zihao-Qi! +- Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! +- Kimi: show the subscription 7-day Code quota in menus and large widgets. Thanks @skyzer! +- Claude: distinguish Max 5x and Max 20x in the plan label instead of a flat "Max". Thanks @kes02! + +### Fixed +- Ollama: point missing-session recovery to the current `/signin` page instead of the protected settings page. Thanks @joeVenner! +- Alibaba Token Plan: support International Model Studio while preserving China-mainland upgrades and isolating regional cookie caches. Thanks @harshav167! +- Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! +- Browser cookies: stop automatic Chromium-family probes after the first Safe Storage denial, while keeping an explicit Refresh retry available (#1952). Thanks @CoreyCole! +- Claude: keep yearless reset dates in the upcoming year when a quota crosses New Year's Day. Thanks @devYRPauli! +- Claude web: preserve fractional session and weekly utilization instead of displaying it as zero or unavailable. Thanks @devYRPauli! +- Gemini: detect Google's consumer-tier shutdown response and offer an explicit Antigravity handoff without changing ordinary auth failures or enabling fallback automatically. Thanks @Yuxin-Qiao! +- Gemini: use the real Flash quota in menu-bar metrics when an account has no Pro quota. Thanks @devYRPauli! +- Ollama API: describe rejected keys as invalid or revoked, matching Ollama's current key lifecycle. Thanks @joeVenner! +- Settings: keep Language, Default Terminal, and Refresh cadence selectors interactive on macOS 27. +- Usage formatting: show every positive sub-1% value as `<1%` instead of rounding values above 0.5% up to `1%`. Thanks @devYRPauli! +- Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. +- Codex quotas: show the session quota as unavailable while an exhausted weekly limit is still binding, including menu-bar icons and widgets. Thanks @Yuxin-Qiao! +- Codex cost history: reuse cached aggregate pricing and one pricing catalog across daily and project reports, carry fresh cache state across launches, and treat unpriced models as migrated, avoiding repeated row scans, filesystem work, and duplicate background scans on large local histories. +- Devin: keep exact 1% usage from being inflated to 100% while preserving fractional fallback quota semantics. Thanks @Lex-ic-on! +- Kimi K2: reject invalid and out-of-range numeric timestamps while preserving valid second and millisecond values. Thanks @joeVenner! +- Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner! +- Kimi: call the current `GetSubscriptionStats` membership endpoint so the Monthly subscription quota is populated again. Thanks @skyzer! +- Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! +- Menu bar: detect Tahoe's blocked no-window state at startup when macOS still records the icon as enabled, so affected users receive recovery guidance instead of a silently missing icon (#1945). Thanks @mmyyfirstb! + +## 0.40.0 — 2026-07-05 + +### Added +- Claude: show opt-in read-only claude-swap accounts as stacked usage cards without delaying ambient refreshes. Thanks @optimiz-r! +- Claude: switch inactive claude-swap accounts directly from their stacked usage cards and refresh usage immediately. +- Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! +- Cost charts: show visible, unit-safe scale labels across detailed history, inline menus, and widgets. Thanks @FNDEVVE! +- Cursor: read the signed-in app token on Linux, with explicit manual-cookie web-source support and XDG config paths. Thanks @DonnieFi! +- Devin: show remaining extra-usage balance in menus, CLI, and widgets while respecting optional-usage visibility. Thanks @FNDEVVE! +- Widgets: make Mistral available in provider selection and switching. Thanks @joeVenner! + +### Changed +- Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! +- Debug builds: add a compact `D` beside menu-bar icons and identify them as CodexBar Debug in tooltips and accessibility. +- Usage bars: distinguish full-height quota-warning thresholds from subtle workday-boundary markers. Thanks @Alekstodo! + +### Fixed +- Codex cost history: reuse one pricing catalog while building project rollups and carry fresh cache state across launches, avoiding repeated filesystem work and duplicate background scans on large local histories. +- Providers: detect Claude Desktop on fresh installs and ignore Gemini CLI installations without usable OAuth credentials. +- Claude cost history: include nested Claude Desktop local-agent logs while preserving current Code/Cowork coverage through the shared `~/.claude/projects` store. Thanks @Zihao-Qi! +- Claude: give multiple claude-swap accounts precedence over token-account cards and segmented switching so adapter rows remain visible. Thanks @optimiz-r! +- Menus: scope manual refresh state to the provider being refreshed, allowing independent provider refreshes without greying unrelated rows. Thanks @hhh2210! +- Claude history: quarantine same-directory account-switch samples until credential ownership is stable, preventing plan-utilization history from crossing accounts. Thanks @ss251! +- Language picker: keep language names readable in their native form and make System follow macOS without removing unrelated overrides. Thanks @Zihao-Qi! +- Reset times: preserve minute precision in long day-scale countdowns when there are no whole hours, while keeping countdowns compact to two units. Thanks @konon4! +- Mistral: reject non-finite and overflowing credit balances before they can reach menu, CLI, or widget formatting. Thanks @joeVenner! + +## 0.39.0 — 2026-07-04 + +### Added +- Codex: show every available reset-credit expiry in menus and provider settings, including non-expiring credits, and summarize credits nearing expiry. Thanks @brahimhamichan! +- Cost history: optionally show shorter 7, 30, and 90-day comparisons from the selected local history window (#1500). Thanks @jtl06! +- Codex cost history: group local usage and costs by project and worktree in menus and CLI output. Thanks @clemenspeters! +- Sakana AI: show best-effort pay-as-you-go credit balance and recent usage without delaying subscription quota refreshes. Thanks @ss251! +- Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue! +- Mistral: show available credit balance from the authenticated billing session while preserving API spend and Monthly Plan usage. Thanks @Zihao-Qi! + +### Changed +- Codex: compact reset-credit expiry inventory into a single scannable timeline instead of one row per credit. +- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! + +### Fixed +- Alibaba: keep the browser Safe Storage keychain read non-interactive and honor the "Disable Keychain access" setting, so cookie import can never trigger a Keychain prompt. +- Tests: block real Keychain and `security` CLI access by default so test runs cannot display password prompts. +- Mistral: discard non-finite and overflowing billing costs so malformed price data cannot poison spend totals or charts. Thanks @joeVenner! +- Claude: notify on model-scoped weekly and Daily Routines quota thresholds using independent warning state. Thanks @cleanerzkp! +- Claude CLI: skip the identity probe after terminal usage errors or loading stalls, cutting failed refresh latency and subprocess churn. +- OpenCode web: search Dia after Chrome for automatic cookie import, with Keychain preflight scoped to the candidate browser (fixes #1822). Thanks @zeajose! +- Claude: make the "Avoid Keychain prompts" setting use the no-prompt policy instead of the experimental `security` CLI reader. Thanks @gmkbenjamin! + +## 0.38.1 — 2026-07-04 + +### Added +- Localization: add complete Russian coverage for the app and redesigned website. Thanks @Kirchberg! +- Localization: add Galician app translations and language selection. Thanks @B1NAR10! +- ClawRouter: add API-key tracking for monthly budget, spend, requests, tokens, and routed-provider usage. - Claude: show model-scoped weekly quota windows, including promotional Fable limits, from OAuth and web usage responses. Thanks @konon4! - Usage refresh: add an opt-in Adaptive cadence that polls every 2–30 minutes based on recent menu use, Low Power Mode, and thermal state. Thanks @hhh2210! - Codex: show a conservative 1.5× pace-headroom hint in menus and CLI output when usage is safely ahead of the reset curve. Thanks @astuteprogrammer! @@ -18,11 +111,16 @@ - Xiaomi MiMo: require authoritative cadence evidence before showing reserve or deficit projections, avoiding guesses from plan dates or names. ### Fixed +- Gemini: resolve fnm from the active PATH, stop package-discovery helpers on deadline, and return after the first output line even when descendants keep stdout open. +- Branding: replace the malformed Poe icon and use Poe's official purple consistently across the app, widget, and website. Thanks @garethpaul! - Monthly quota pace: show reserve, deficit, and run-out estimates for OpenCode Go, Doubao, and Alibaba monthly reset windows using their calendar-cycle length. Thanks @Zihao-Qi and @joeVenner! - Localization: translate the Default Terminal setting across every supported app language. Thanks @Zihao-Qi! - Settings: recover collapsed sidebars and undersized saved window frames when reopening Settings. Thanks @ProspectOre! - z.ai: parse successful BigModel CN quota responses that omit the optional message field, while preserving useful API-code errors. Thanks @joeVenner! - Claude: block background delegated CLI OAuth refresh when the keychain holds MCP-only state (`mcpOAuth` without `claudeAiOauth`) while preserving explicit Refresh recovery (#1844). Thanks @Yuxin-Qiao! +- OpenAI API: reject non-finite cost values before they can corrupt usage totals or JSON output. Thanks @joeVenner! +- OpenCode: ignore non-finite and out-of-range reset timestamps instead of crashing usage parsing, while preserving valid quota windows. Thanks @joeVenner! + ## 0.38.0 — 2026-07-03 ### Added diff --git a/README.md b/README.md index 414da327ef..8d93020288 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 56 providers. +CodexBar — every AI coding limit in your menu bar. 57 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. -CodexBar menu popover with provider tiles, usage bars, and reset countdowns +CodexBar menu popover with provider tiles, usage bars, and reset countdowns ## Why @@ -112,7 +112,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Doubao](docs/doubao.md) — API key for Volcengine Ark request-limit probes. - [Sakana AI](docs/sakana.md) — Manual Cookie header for 5-hour and weekly quota windows. - [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. -- Mistral — Browser cookies for monthly spend tracking. +- Mistral — Browser cookies for API spend, credit balance, and monthly-plan usage. - [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown). - [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking. - [Venice](docs/venice.md) — API key for DIEM or USD balance tracking. @@ -124,6 +124,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback. - [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics. - [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns. +- [ClawRouter](docs/clawrouter.md) — API key for monthly budget, spend, requests, tokens, and routed-provider usage. - [LiteLLM](docs/litellm.md) — Virtual key + proxy URL for personal and team budget/spend tracking. - [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics. - [Poe](docs/poe.md) — API key for current point balance and recent points history. diff --git a/Scripts/check-site-locales.mjs b/Scripts/check-site-locales.mjs index 429f7283e0..35f2140abb 100644 --- a/Scripts/check-site-locales.mjs +++ b/Scripts/check-site-locales.mjs @@ -13,7 +13,7 @@ for (const match of indexHtml.matchAll(/ locale.code); const appLanguageSource = fs.readFileSync( diff --git a/Scripts/check_repository_size.sh b/Scripts/check_repository_size.sh new file mode 100755 index 0000000000..b94da55225 --- /dev/null +++ b/Scripts/check_repository_size.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_BYTES=$((2 * 1024 * 1024)) +failures=0 +tracked_files=0 +declare -a blob_paths=() +declare -a blob_ids=() + +cd "$ROOT_DIR" + +while IFS= read -r -d '' entry; do + metadata=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r mode object stage <<<"$metadata" + [[ "$stage" == "0" ]] || continue + tracked_files=$((tracked_files + 1)) + + case "$path" in + *.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.ipa | *.zip | *.delta | *.dmg | \ + *.pkg | *.tar.gz | *.tgz) + printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2 + failures=$((failures + 1)) + ;; + esac + + # Submodule entries name commits rather than file blobs. + [[ "$mode" == "160000" ]] && continue + blob_paths+=("$path") + blob_ids+=("$object") +done < <(git ls-files --stage -z) + +if ((${#blob_ids[@]} > 0)); then + index=0 + while read -r object type size; do + path=${blob_paths[$index]} + if [[ "$type" != "blob" ]]; then + printf 'ERROR: tracked index entry is not a readable blob: %q (%s)\n' "$path" "$object" >&2 + failures=$((failures + 1)) + index=$((index + 1)) + continue + fi + if ((size > MAX_BYTES)); then + printf 'ERROR: tracked file exceeds %d bytes: %q (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2 + failures=$((failures + 1)) + fi + index=$((index + 1)) + done < <(printf '%s\n' "${blob_ids[@]}" | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)') +fi + +if ((failures > 0)); then + printf 'Repository size check failed with %d violation(s).\n' "$failures" >&2 + printf 'Publish build/release artifacts outside Git and optimize required source assets.\n' >&2 + exit 1 +fi + +printf 'repository size OK: %d tracked files, maximum %d bytes each\n' "$tracked_files" "$MAX_BYTES" diff --git a/Scripts/install_lint_tools.sh b/Scripts/install_lint_tools.sh index 6f1fb1288f..99f52c902b 100755 --- a/Scripts/install_lint_tools.sh +++ b/Scripts/install_lint_tools.sh @@ -6,13 +6,15 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TOOLS_DIR="${ROOT_DIR}/.build/lint-tools" BIN_DIR="${TOOLS_DIR}/bin" -SWIFTFORMAT_VERSION="0.59.1" -SWIFTLINT_VERSION="0.63.2" +SWIFTFORMAT_VERSION="0.61.1" +SWIFTLINT_VERSION="0.65.0" -SWIFTFORMAT_SHA256_DARWIN="8b6289b608a44e73cd3851c3589dbd7c553f32cc805aa54b3a496ce2b90febe7" -SWIFTLINT_SHA256_DARWIN="c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" -SWIFTFORMAT_SHA256_LINUX_X86_64="150d9693570cf234ec91d8a03ba7165bd36a78335c5e40ed91e4c013a492eb54" -SWIFTLINT_SHA256_LINUX_X86_64="dd1017cfd20a1457f264590bcb5875a6ee06cd75b9a9d4f77cd43a552499143b" +SWIFTFORMAT_SHA256_DARWIN="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584" +SWIFTLINT_SHA256_DARWIN="d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6" +SWIFTFORMAT_SHA256_LINUX_X86_64="7bc8706e3fd51963f1f29eb99098ebdf482f3497fa527c68e6cf75cbee29c77a" +SWIFTLINT_SHA256_LINUX_X86_64="79306a34e5c7cc55a220cd108cbb861dcad5f10138dcdf261e2624ae8b0a486b" +SWIFTFORMAT_SHA256_LINUX_ARM64="42a35b557a6d56975fba3a48e78d39ab5388c8faac65d4819f25d3e20c7504c0" +SWIFTLINT_SHA256_LINUX_ARM64="12d3b84bc5b69ae13a99a5a5c79904f9ce25867f099f6368d0037854f9ee6c26" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } @@ -145,14 +147,16 @@ case "$OS" in x86_64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip" + SWIFTFORMAT_BINARY="swiftformat_linux" SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_X86_64" SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_X86_64" ;; aarch64|arm64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip" - SWIFTFORMAT_SHA256="" - SWIFTLINT_SHA256="" + SWIFTFORMAT_BINARY="swiftformat_linux_aarch64" + SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_ARM64" + SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_ARM64" ;; *) fail "Unsupported Linux arch: ${ARCH}" @@ -165,7 +169,7 @@ case "$OS" in log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway." fi if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then - install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "swiftformat_linux" "swiftformat" + install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "$SWIFTFORMAT_BINARY" "swiftformat" fi if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256" "swiftlint" diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 1c1e5afd07..6930c074be 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -45,6 +45,11 @@ check_ci_path_gate() { "${ROOT_DIR}/Scripts/test_ci_path_gate.sh" } +check_repository_size() { + "${ROOT_DIR}/Scripts/check_repository_size.sh" + "${ROOT_DIR}/Scripts/test_repository_size.sh" +} + check_shell_scripts() { local count=0 local script @@ -83,6 +88,7 @@ run_portable_checks() { check_sparkle_signing_paths check_swift_test_sharding check_ci_path_gate + check_repository_size check_shell_scripts check_documentation_links check_llms_index diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 73f85f0f4b..4dc3fe94ea 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -350,12 +350,17 @@ strip_release_binary() { ensure_widget_extension_project() { local spec="$ROOT/WidgetExtension/project.yml" local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" - if command -v xcodegen >/dev/null 2>&1; then - xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet - elif [[ ! -f "$project_dir/project.pbxproj" ]]; then + if [[ -f "$project_dir/project.pbxproj" ]]; then + return + fi + if ! command -v xcodegen >/dev/null 2>&1; then echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2 exit 1 fi + + # The tracked project is authoritative. Regenerating it during packaging records the checkout + # directory's spelling in a package file reference and leaves release worktrees dirty. + xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet } build_widget_extension() { diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index 0443992306..081d1a6fc3 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -10,6 +10,15 @@ source "$ROOT/Scripts/release_artifacts.sh" source "$ROOT/Scripts/package_product_paths.sh" source "$ROOT/Scripts/release_dsym_paths.sh" +verify_distribution_policy() { + local app=$1 + if command -v syspolicy_check >/dev/null 2>&1; then + syspolicy_check distribution "$app" + else + spctl -a -t exec -vv "$app" + fi +} + # Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") @@ -79,7 +88,7 @@ find "$APP_BUNDLE" -name '._*' -delete "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME" -spctl -a -t exec -vv "$APP_BUNDLE" +verify_distribution_policy "$APP_BUNDLE" stapler validate "$APP_BUNDLE" echo "Packaging dSYM" diff --git a/Scripts/test.sh b/Scripts/test.sh index 9b0f2c5cd8..d561103b21 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -7,6 +7,13 @@ GROUP_SIZE="${CODEXBAR_TEST_GROUP_SIZE:-12}" SUITE_TIMEOUT="${CODEXBAR_TEST_SUITE_TIMEOUT:-180}" cd "${ROOT_DIR}" + +# Defense in depth: test processes also self-detect, but keep this explicit so runner changes cannot +# expose the user's login Keychain. Deliberate isolated Keychain tests must opt in by setting the allow flag. +if [[ "${CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS:-}" != "1" ]]; then + export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 +fi + ARGS=( --group-size "${GROUP_SIZE}" --timeout "${SUITE_TIMEOUT}" diff --git a/Scripts/test_live_update.sh b/Scripts/test_live_update.sh index db7f9e5a96..76ab2ec326 100755 --- a/Scripts/test_live_update.sh +++ b/Scripts/test_live_update.sh @@ -4,18 +4,27 @@ set -euo pipefail PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"} CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"} -ROOT=$(cd "$(dirname "$0")/.." && pwd) PREV_VER=${PREV_TAG#v} +CUR_VER=${CUR_TAG#v} APP_NAME="CodexBar" -ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-${PREV_VER}.zip" +ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-macos-universal-${PREV_VER}.zip" TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX) trap 'rm -rf "$TMP_DIR"' EXIT echo "Downloading previous release $PREV_TAG from $ZIP_URL" -curl -L -o "$TMP_DIR/prev.zip" "$ZIP_URL" +curl --fail --location --output "$TMP_DIR/prev.zip" "$ZIP_URL" echo "Installing previous release to /Applications/${APP_NAME}.app" +osascript -e 'tell application "CodexBar" to quit' >/dev/null 2>&1 || true +for _ in {1..20}; do + pgrep -x "$APP_NAME" >/dev/null || break + sleep 0.25 +done +if pgrep -x "$APP_NAME" >/dev/null; then + echo "ERROR: ${APP_NAME} did not quit before replacement." >&2 + exit 1 +fi rm -rf /Applications/${APP_NAME}.app ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR" ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app @@ -35,4 +44,11 @@ if [[ ! "$answer" =~ ^[Yy]$ ]]; then exit 1 fi +installed_ver=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "/Applications/${APP_NAME}.app/Contents/Info.plist") +if [[ "$installed_ver" != "$CUR_VER" ]]; then + echo "Live update reported success but installed ${installed_ver}; expected ${CUR_VER}." >&2 + exit 1 +fi + echo "Live update test confirmed." diff --git a/Scripts/test_repository_size.sh b/Scripts/test_repository_size.sh new file mode 100755 index 0000000000..fb760d636d --- /dev/null +++ b/Scripts/test_repository_size.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-repository-size.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +mkdir -p "$TEMP_DIR/Scripts" +cp "$ROOT_DIR/Scripts/check_repository_size.sh" "$TEMP_DIR/Scripts/" +git -C "$TEMP_DIR" init --quiet +empty_output=$("$TEMP_DIR/Scripts/check_repository_size.sh") +grep -Fq 'repository size OK: 0 tracked files' <<<"$empty_output" + +printf 'small source file\n' > "$TEMP_DIR/source.txt" +git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh + +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +dd if=/dev/zero of="$TEMP_DIR/untracked.bin" bs=1024 count=2049 2>/dev/null +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +dd if=/dev/zero of="$TEMP_DIR/boundary.bin" bs=1024 count=2048 2>/dev/null +git -C "$TEMP_DIR" add boundary.bin +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null +printf 'x' >> "$TEMP_DIR/boundary.bin" +git -C "$TEMP_DIR" add boundary.bin +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then + printf 'ERROR: staged blob one byte above the limit was accepted.\n' >&2 + exit 1 +fi +grep -Fq 'tracked file exceeds 2097152 bytes: boundary.bin (2097153 bytes)' "$TEMP_DIR/large.log" + +git -C "$TEMP_DIR" rm --cached --force --quiet boundary.bin +git -C "$TEMP_DIR" add untracked.bin +printf 'working tree is now small\n' > "$TEMP_DIR/untracked.bin" +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/staged-large.log" 2>&1; then + printf 'ERROR: oversized staged blob was accepted after its working-tree file changed.\n' >&2 + exit 1 +fi +grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin (2098176 bytes)' "$TEMP_DIR/staged-large.log" + +git -C "$TEMP_DIR" rm --cached --force --quiet untracked.bin +printf 'small staged blob\n' > "$TEMP_DIR/index-is-authoritative.bin" +git -C "$TEMP_DIR" add index-is-authoritative.bin +dd if=/dev/zero of="$TEMP_DIR/index-is-authoritative.bin" bs=1024 count=2049 2>/dev/null +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +odd_path=$'odd\nname.txt' +printf 'small source file\n' > "$TEMP_DIR/$odd_path" +git -C "$TEMP_DIR" add "$odd_path" +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +artifacts=( + "CodexBar 2.app/Contents/MacOS/CodexBar" + "CodexBar.dSYM/Contents/Info.plist" + "CodexBar.xcarchive/Products/Applications/CodexBar.app/Contents/Info.plist" + "CodexBar.xcresult/Data/data" + "CodexBar.ipa" + "CodexBar.zip" + "CodexBar.delta" + "CodexBar.dmg" + "CodexBar.pkg" + "CodexBar.tar.gz" + "CodexBar.tgz" +) +for artifact in "${artifacts[@]}"; do + mkdir -p "$TEMP_DIR/$(dirname "$artifact")" + printf 'release artifact\n' > "$TEMP_DIR/$artifact" + git -C "$TEMP_DIR" add -f "$artifact" +done +ln -s source.txt "$TEMP_DIR/CodexBar-latest.dmg" +git -C "$TEMP_DIR" add -f CodexBar-latest.dmg +rm "$TEMP_DIR/CodexBar.zip" +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then + printf 'ERROR: tracked release artifacts were accepted.\n' >&2 + exit 1 +fi +for artifact in "${artifacts[@]}" CodexBar-latest.dmg; do + grep -Fq "generated artifact is tracked: $artifact" "$TEMP_DIR/artifact.log" +done + +printf 'Repository size tests passed.\n' diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift new file mode 100644 index 0000000000..a06ed519fd --- /dev/null +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -0,0 +1,155 @@ +import CodexBarCore +import Foundation +import Observation + +struct AgentSessionRemoteRefreshGate { + private(set) var generation = 0 + private(set) var isInFlight = false + private(set) var isPending = false + + mutating func settingsDidChange() { + self.generation += 1 + self.isPending = self.isInFlight + } + + mutating func begin() -> Int? { + guard !self.isInFlight else { + self.isPending = true + return nil + } + self.isInFlight = true + self.isPending = false + return self.generation + } + + mutating func finish(generation: Int) -> (shouldPublish: Bool, shouldRetry: Bool) { + self.isInFlight = false + let outcome = (generation == self.generation, self.isPending) + self.isPending = false + return outcome + } +} + +@MainActor +@Observable +final class AgentSessionsStore { + private let settings: SettingsStore + private let localScanner: LocalAgentSessionScanner + private let remoteFetcher: RemoteSessionFetcher + @ObservationIgnored private var localRefreshTask: Task? + @ObservationIgnored private var remoteRefreshTask: Task? + @ObservationIgnored private var localRefreshInFlight = false + @ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate() + @ObservationIgnored var onUpdate: (@MainActor () -> Void)? + + private(set) var localSessions: [AgentSession] = [] + private(set) var remoteHosts: [RemoteSessionHostResult] = [] + private(set) var lastUpdatedAt: Date? + + init( + settings: SettingsStore, + localScanner: LocalAgentSessionScanner = LocalAgentSessionScanner(), + remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher()) + { + self.settings = settings + self.localScanner = localScanner + self.remoteFetcher = remoteFetcher + } + + var totalCount: Int { + self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count } + } + + func start() { + guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return } + self.localRefreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshLocal() + try? await Task.sleep(for: .seconds(30)) + } + } + self.remoteRefreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshRemote() + try? await Task.sleep(for: .seconds(60)) + } + } + } + + func stop() { + self.localRefreshTask?.cancel() + self.remoteRefreshTask?.cancel() + self.localRefreshTask = nil + self.remoteRefreshTask = nil + } + + func settingsDidChange() { + self.remoteRefreshGate.settingsDidChange() + guard self.settings.agentSessionsEnabled else { + self.localSessions = [] + self.remoteHosts = [] + self.onUpdate?() + return + } + guard !SettingsStore.isRunningTests else { return } + Task { [weak self] in + await self?.refreshLocal() + await self?.refreshRemote() + } + } + + func refreshOnMenuOpen() { + guard self.settings.agentSessionsEnabled, !SettingsStore.isRunningTests else { return } + Task { [weak self] in + await self?.refreshLocal() + await self?.refreshRemote() + } + } + + func focus(_ session: AgentSession, remoteHost: String?) { + if let remoteHost { + Task { + await self.remoteFetcher.focus(sessionID: session.id, host: remoteHost) + } + } else { + _ = SessionWindowFocuser.focus(session) + } + } + + private func refreshLocal() async { + guard self.settings.agentSessionsEnabled, !self.localRefreshInFlight else { return } + self.localRefreshInFlight = true + let sessions = await self.localScanner.scan() + self.localRefreshInFlight = false + guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return } + self.localSessions = sessions + self.lastUpdatedAt = Date() + self.onUpdate?() + } + + private func refreshRemote() async { + guard self.settings.agentSessionsEnabled else { return } + guard var generation = self.remoteRefreshGate.begin() else { return } + while self.settings.agentSessionsEnabled { + var hosts = self.manualHosts + await hosts.append(contentsOf: self.remoteFetcher.discoveredHosts()) + let results = await self.remoteFetcher.fetch(hosts: hosts) + let outcome = self.remoteRefreshGate.finish(generation: generation) + guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return } + if outcome.shouldPublish { + self.remoteHosts = results + self.lastUpdatedAt = Date() + self.onUpdate?() + } + guard outcome.shouldRetry, let nextGeneration = self.remoteRefreshGate.begin() else { return } + generation = nextGeneration + } + } + + private var manualHosts: [String] { + self.settings.agentSessionsManualHosts + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift new file mode 100644 index 0000000000..b4b3d0b000 --- /dev/null +++ b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import CryptoKit +import Foundation + +@MainActor +struct CodexResetCreditExpiryNotifier { + static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60 + static let notificationPrefix = "codex-reset-credit-expiry" + static let summaryFingerprintsKey = "codexResetCreditExpirySummaryFingerprints" + static let maximumRememberedSummaries = 64 + + var userDefaults: UserDefaults = .standard + var notificationPoster: (String, String, String) -> Void = { prefix, title, body in + AppNotifications.shared.post(idPrefix: prefix, title: title, body: body) + } + + func postExpiringCreditsIfNeeded( + snapshot: CodexRateLimitResetCreditsSnapshot, + resetStyle: ResetTimeDisplayStyle, + now: Date = Date()) + { + let expiringCredits = snapshot.availableInventory(at: now).credits.filter { credit in + guard let expiresAt = credit.expiresAt else { return false } + return expiresAt.timeIntervalSince(now) <= Self.expiryWindow + } + guard !expiringCredits.isEmpty else { return } + + let fingerprint = Self.summaryFingerprint(expiringCredits) + // Account-scoped refreshes can alternate inventories, so remember more than the latest summary. + var notifiedFingerprints = self.userDefaults.stringArray(forKey: Self.summaryFingerprintsKey) ?? [] + guard !notifiedFingerprints.contains(fingerprint) else { return } + notifiedFingerprints.append(fingerprint) + if notifiedFingerprints.count > Self.maximumRememberedSummaries { + notifiedFingerprints.removeFirst(notifiedFingerprints.count - Self.maximumRememberedSummaries) + } + self.userDefaults.set(notifiedFingerprints, forKey: Self.summaryFingerprintsKey) + + let expiringSnapshot = CodexRateLimitResetCreditsSnapshot( + credits: expiringCredits, + availableCount: expiringCredits.count, + updatedAt: now) + guard let presentation = CodexResetCreditsPresentation.make( + snapshot: expiringSnapshot, + resetStyle: resetStyle, + now: now) + else { + return + } + self.notificationPoster( + Self.notificationPrefix, + L("Limit Reset Credits"), + presentation.helpText) + } + + private static func summaryFingerprint(_ credits: [CodexRateLimitResetCredit]) -> String { + let material = credits.map { credit in + "\(credit.id)\u{1f}\(credit.expiresAt?.timeIntervalSince1970 ?? 0)" + }.joined(separator: "\u{1e}") + return SHA256.hash(data: Data(material.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 026ede64f9..ae6078a3dc 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -100,7 +100,7 @@ struct CodexBarApp: App { }) } .defaultSize(width: SettingsPane.windowWidth, height: SettingsPane.windowHeight) - .windowResizability(.contentSize) + .windowResizability(.contentMinSize) } private func openSettings(pane: SettingsPane) { @@ -110,12 +110,8 @@ struct CodexBarApp: App { } private static func applyLanguagePreference(from settings: SettingsStore) { - let language = settings.appLanguage - if language.isEmpty { - UserDefaults.standard.removeObject(forKey: "AppleLanguages") - } else { - UserDefaults.standard.set([language], forKey: "AppleLanguages") - } + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(storedAppLanguage: settings.appLanguage) + resetCodexBarLocalizationCache() } } diff --git a/Sources/CodexBar/CookieHeaderStore.swift b/Sources/CodexBar/CookieHeaderStore.swift index ac3905b037..8fd455ab3a 100644 --- a/Sources/CodexBar/CookieHeaderStore.swift +++ b/Sources/CodexBar/CookieHeaderStore.swift @@ -78,7 +78,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() @@ -140,7 +140,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() @@ -157,7 +157,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CookieHeaderStoreError.keychainStatus(addStatus) @@ -176,7 +176,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() diff --git a/Sources/CodexBar/CopilotTokenStore.swift b/Sources/CodexBar/CopilotTokenStore.swift index 4fcff0012a..6f852f3dec 100644 --- a/Sources/CodexBar/CopilotTokenStore.swift +++ b/Sources/CodexBar/CopilotTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CopilotTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 17fc33d3db..ad887cbeaf 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -47,6 +47,7 @@ struct CostHistoryChartMenuView: View { private let currencyCode: String private let historyDays: Int private let windowLabel: String? + private let projects: [CostUsageProjectBreakdown] private let width: CGFloat private let onHeightChange: ((CGFloat) -> Void)? @State private var selectedDateKey: String? @@ -58,6 +59,7 @@ struct CostHistoryChartMenuView: View { currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, + projects: [CostUsageProjectBreakdown] = [], onHeightChange: ((CGFloat) -> Void)? = nil, width: CGFloat) { @@ -67,6 +69,7 @@ struct CostHistoryChartMenuView: View { self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel + self.projects = projects self.onHeightChange = onHeightChange self.width = width } @@ -97,8 +100,33 @@ struct CostHistoryChartMenuView: View { .foregroundStyle(Color(nsColor: .systemYellow)) } } - .chartYAxis(.hidden) - .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in + AxisGridLine().foregroundStyle(Color.clear) + AxisTick().foregroundStyle(Color.clear) + AxisValueLabel(centered: false) { + if let raw = value.as(Double.self) { + Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .padding(.leading, 4) + } + } + } + } + .chartXAxis { + AxisMarks(values: model.axisDates) { value in + AxisGridLine().foregroundStyle(Color.clear) + AxisTick().foregroundStyle(Color.clear) + if let date = value.as(Date.self) { + AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + Text(date, format: .dateTime.month(.abbreviated).day()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } + } + } .chartLegend(.hidden) .frame(height: Self.chartHeight) .accessibilityLabel(L("Cost history chart")) @@ -125,28 +153,6 @@ struct CostHistoryChartMenuView: View { } } - let axisLabelPlacement = Self.axisLabelPlacement(for: model.axisDates) - if axisLabelPlacement != .hidden { - HStack { - if axisLabelPlacement == .centered { - Spacer() - } - Text(model.axisDates[0], format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - Spacer() - if axisLabelPlacement == .edges { - Text( - model.axisDates[model.axisDates.count - 1], - format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - } - } - .frame(height: Self.axisLabelAreaHeight) - .padding(.top, -Self.outerSpacing) - } - let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model) VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) @@ -224,6 +230,41 @@ struct CostHistoryChartMenuView: View { .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) } + + if !self.projects.isEmpty { + VStack(alignment: .leading, spacing: Self.projectRowSpacing) { + Text("Projects") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in + let visibleSources = Self.visibleProjectSources(project) + VStack(alignment: .leading, spacing: Self.projectSourceSpacing) { + self.projectParentRow(project) + if !visibleSources.isEmpty { + ForEach( + Array(visibleSources.prefix(Self.maxVisibleProjectSourceRows)), + id: \.sourceRowID) + { source in + self.projectSourceRow(source) + } + let hiddenSourceCount = visibleSources.count - Self.maxVisibleProjectSourceRows + if hiddenSourceCount > 0 { + Text("+ \(hiddenSourceCount) more") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectMoreRowHeight, alignment: .leading) + } + } + } + .frame(height: Self.projectEntryHeight(project), alignment: .topLeading) + } + } + .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) + } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) @@ -249,28 +290,55 @@ struct CostHistoryChartMenuView: View { private static let compactDetailRowHeight: CGFloat = 36 private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 - private static let chartHeight: CGFloat = 114 - private static let axisLabelAreaHeight: CGFloat = 16 + private static let chartHeight: CGFloat = 130 private static let outerSpacing: CGFloat = 10 + private static let projectRowHeight: CGFloat = 31 + private static let projectRowSpacing: CGFloat = 5 + private static let maxVisibleProjectRows = 5 + private static let projectSourceRowHeight: CGFloat = 29 + private static let projectSourceSpacing: CGFloat = 3 + private static let projectSourceIndent: CGFloat = 10 + private static let projectMoreRowHeight: CGFloat = 16 + private static let maxVisibleProjectSourceRows = 2 static let verticalPadding: CGFloat = 10 /// Deterministic total height of the rendered card for a given selection. NSMenu's modal /// tracking run loop never delivers SwiftUI `onPreferenceChange`, so the live height can't be /// measured via a GeometryReader while the menu is open. Every component height is fixed, so /// we compute the total directly and resize from the hover handler instead. - private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool) -> CGFloat { + private static func totalCardHeight( + rows: [DetailRow], + hasTotal: Bool, + projects: [CostUsageProjectBreakdown] = []) -> CGFloat + { var height = self.verticalPadding * 2 height += self.chartHeight - height += self.axisLabelAreaHeight height += self.outerSpacing height += self.detailBlockHeight(rows: rows) if hasTotal { height += self.outerSpacing height += self.detailPrimaryLineHeight } + if !projects.isEmpty { + height += self.outerSpacing + height += self.projectBlockHeight(projects: projects) + } return height } + private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool, projectCount: Int) -> CGFloat { + let projects = (0.. String { if days == 1 { return L("Today") @@ -294,6 +362,17 @@ struct CostHistoryChartMenuView: View { maxValue * 0.05 } + /// Y-axis tick values for the cost chart: 0, mid, max when the range is at + /// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the + /// axis renders no labels. + private static func yAxisTickValues(maxCostUSD: Double) -> [Double] { + guard maxCostUSD > 0 else { return [] } + if maxCostUSD < 1.0 { + return [0, maxCostUSD] + } + return [0, maxCostUSD / 2, maxCostUSD] + } + private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } var points: [Point] = [] @@ -356,6 +435,21 @@ struct CostHistoryChartMenuView: View { } } + private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { + switch self.axisLabelPlacement(for: axisDates) { + case .hidden, .centered: + .top + case .edges: + if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + .topLeading + } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + .topTrailing + } else { + .top + } + } + } + private static func barColor(for provider: UsageProvider) -> Color { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) @@ -403,6 +497,33 @@ struct CostHistoryChartMenuView: View { return rowHeights + spacing } + private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat { + let visibleProjects = Array(projects.prefix(self.maxVisibleProjectRows)) + guard !visibleProjects.isEmpty else { return 0 } + return self.detailPrimaryLineHeight + + self.projectRowSpacing + + visibleProjects.reduce(CGFloat(0)) { $0 + self.projectEntryHeight($1) } + + CGFloat(max(visibleProjects.count - 1, 0)) * self.projectRowSpacing + } + + private static func projectEntryHeight(_ project: CostUsageProjectBreakdown) -> CGFloat { + let sources = self.visibleProjectSources(project) + guard !sources.isEmpty else { return self.projectRowHeight } + let visibleSources = min(sources.count, self.maxVisibleProjectSourceRows) + let moreRows = sources.count > self.maxVisibleProjectSourceRows ? 1 : 0 + return self.projectRowHeight + + CGFloat(visibleSources) * (self.projectSourceRowHeight + self.projectSourceSpacing) + + CGFloat(moreRows) * (self.projectMoreRowHeight + self.projectSourceSpacing) + } + + static func visibleProjectSources( + _ project: CostUsageProjectBreakdown) -> [CostUsageProjectSourceBreakdown] + { + guard project.sources.count == 1 else { return project.sources } + guard let source = project.sources.first, source.path != project.path else { return [] } + return [source] + } + private static func defaultSelectedDateKey(model: Model) -> String? { model.dateKeys.last?.key } @@ -469,7 +590,77 @@ struct CostHistoryChartMenuView: View { private func notifyHeightChange(selectedDateKey: String?, model: Model) { guard let onHeightChange = self.onHeightChange else { return } let rows = selectedDateKey.map { self.breakdownRows(key: $0, model: model) } ?? [] - onHeightChange(Self.totalCardHeight(rows: rows, hasTotal: self.totalCostUSD != nil)) + onHeightChange(Self.totalCardHeight( + rows: rows, + hasTotal: self.totalCostUSD != nil, + projects: self.projects)) + } + + private func projectSummary(_ project: CostUsageProjectBreakdown) -> String { + let cost = project.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = project.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" + } + + private func projectParentRow(_ project: CostUsageProjectBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 8) { + Text(project.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(self.projectSummary(project)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = project.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(height: Self.projectRowHeight, alignment: .leading) + } + + private func projectSourceRow(_ source: CostUsageProjectSourceBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(source.name) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Text(self.projectSourceSummary(source)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = source.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .quaternaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectSourceRowHeight, alignment: .leading) + } + + private func projectSourceSummary(_ source: CostUsageProjectSourceBreakdown) -> String { + let cost = source.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = source.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" } private func nearestDateKey(to date: Date, model: Model) -> String? { @@ -576,7 +767,15 @@ struct CostHistoryChartMenuView: View { } private func costString(_ value: Double) -> String { - UsageFormatter.currencyString(value, currencyCode: self.currencyCode) + Self.costString(value, currencyCode: self.currencyCode) + } + + private static func costString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } + + private static func yAxisCostString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.compactCurrencyString(value, currencyCode: currencyCode) } private static func breakdownAccentOpacity(for index: Int) -> Double { @@ -601,6 +800,14 @@ extension CostHistoryChartMenuView { self.axisLabelPlacement(for: self.makeModel(provider: provider, daily: daily).axisDates) } + static func _yAxisTickValuesForTesting(maxCostUSD: Double) -> [Double] { + self.yAxisTickValues(maxCostUSD: maxCostUSD) + } + + static func _yAxisCostStringForTesting(_ value: Double, currencyCode: String = "USD") -> String { + self.yAxisCostString(value, currencyCode: currencyCode) + } + static func _detailViewportHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in DetailRow( @@ -625,7 +832,11 @@ extension CostHistoryChartMenuView { return self.detailBlockHeight(rows: rows) } - static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasTotal: Bool) -> CGFloat { + static func _totalCardHeightForTesting( + modeSubtitlePresence: [Bool], + hasTotal: Bool, + projectCount: Int = 0) -> CGFloat + { let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in DetailRow( id: "\(index)", @@ -634,6 +845,52 @@ extension CostHistoryChartMenuView { modeSubtitle: hasModeSubtitle ? "Mode" : nil, accentColor: .blue) } - return self.totalCardHeight(rows: rows, hasTotal: hasTotal) + return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projectCount: projectCount) + } + + static func _totalCardHeightForTesting( + modeSubtitlePresence: [Bool], + hasTotal: Bool, + projectSourceCounts: [Int]) -> CGFloat + { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Model \(index)", + subtitle: "Cost", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + let projects = projectSourceCounts.enumerated().map { index, sourceCount in + CostUsageProjectBreakdown( + name: "Project \(index)", + path: "/tmp/project-\(index)", + totalTokens: nil, + totalCostUSD: nil, + daily: [], + modelBreakdowns: nil, + sources: (0.. CodexConsumerProjection { + private static func codexProjection(snapshot: UsageSnapshot, now: Date) -> CodexConsumerProjection { CodexConsumerProjection.make( surface: .menuBar, context: CodexConsumerProjection.Context( @@ -19,12 +20,12 @@ enum IconRemainingResolver { rawDashboardError: nil, dashboardAttachmentAuthorized: false, dashboardRequiresLogin: false, - now: snapshot.updatedAt)) + now: now)) } - private static func codexVisibleWindows(snapshot: UsageSnapshot) -> [RateWindow] { - let projection = self.codexProjection(snapshot: snapshot) - return projection.visibleRateLanes.compactMap { projection.rateWindow(for: $0) } + private static func codexVisibleWindows(snapshot: UsageSnapshot, now: Date) -> [RateWindow] { + let projection = self.codexProjection(snapshot: snapshot, now: now) + return projection.visibleRateLanes.compactMap { projection.menuBarSelectableRateWindow(for: $0) } } private static func antigravityQuotaSummaryWindows( @@ -67,7 +68,8 @@ enum IconRemainingResolver { static func resolvedWindows( snapshot: UsageSnapshot, style: IconStyle, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: RateWindow?, secondary: RateWindow?) { if style == .perplexity { @@ -82,7 +84,7 @@ enum IconRemainingResolver { ?? (primary: nil, secondary: nil) } if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) + let windows = self.codexVisibleWindows(snapshot: snapshot, now: now) return ( primary: windows.first, secondary: windows.dropFirst().first) @@ -103,13 +105,15 @@ enum IconRemainingResolver { static func resolvedRemaining( snapshot: UsageSnapshot, style: IconStyle, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { let windows = self.resolvedWindows( snapshot: snapshot, style: style, - secondaryOverrideWindowID: secondaryOverrideWindowID) + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) return ( primary: windows.primary?.remainingPercent, secondary: windows.secondary?.remainingPercent) @@ -120,13 +124,15 @@ enum IconRemainingResolver { style: IconStyle, showUsed: Bool, renderingStyle: IconStyle? = nil, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { let windows = Self.resolvedWindows( snapshot: snapshot, style: style, - secondaryOverrideWindowID: secondaryOverrideWindowID) + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) var percents = ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index cde0dfed26..2577ae6bae 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -30,6 +30,9 @@ struct InlineUsageDashboardModel: Equatable { /// Provider branding color used to fill the mini usage bars. When nil the bars fall back to a /// neutral palette derived from `valueStyle`. var barColor: Color? + /// ISO 4217 currency code for cost dashboards. When non-nil, `MiniUsageBars` shows a max-cost scale label. + /// Nil for token/points dashboards. + var currencyCode: String? } extension UsageMenuCardView.Model { @@ -46,6 +49,25 @@ extension UsageMenuCardView.Model { return usage.displayLines } + if input.provider == .clawrouter, + let usage = input.snapshot?.clawRouterUsage + { + var notes = [ + "\(UsageFormatter.tokenCountString(usage.requestCount)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(usage.totalTokens)) \(L("tokens"))", + ] + if usage.errorCount > 0 { + notes.append("\(usage.successCount) succeeded · \(usage.errorCount) failed") + } + if !usage.providers.isEmpty { + let mix = usage.providers.prefix(5) + .map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" } + .joined(separator: " · ") + notes.append("Routed providers: \(mix)") + } + return notes + } + if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary @@ -165,7 +187,10 @@ extension UsageMenuCardView.Model { let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { - return self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage @@ -212,7 +237,10 @@ extension UsageMenuCardView.Model { let tokenSnapshot = input.tokenSnapshot, !tokenSnapshot.daily.isEmpty { - return Self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return Self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) } return nil } @@ -300,7 +328,8 @@ extension UsageMenuCardView.Model { private static func costHistoryInlineDashboard( provider: UsageProvider, - snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel + snapshot: CostUsageTokenSnapshot, + comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel { let historyDays = max(1, min(365, snapshot.historyDays)) let historyTitle = snapshot.historyLabel @@ -335,6 +364,11 @@ extension UsageMenuCardView.Model { let usesLatestPrimary = provider == .bedrock || provider == .mistral let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD var details: [String] = [] + if comparisonPeriodsEnabled { + details.append(contentsOf: snapshot.comparisonSummaries().map { + Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + }) + } if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } @@ -347,7 +381,7 @@ extension UsageMenuCardView.Model { details.append(L("cost_estimate_hint")) } let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: "\(providerName) \(periodLabel) cost trend", valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), kpis: [ @@ -367,6 +401,8 @@ extension UsageMenuCardView.Model { ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, detailLines: details) + model.currencyCode = snapshot.currencyCode + return model } private static func costHistoryTrailingKPIs( @@ -410,7 +446,7 @@ extension UsageMenuCardView.Model { if let topModel = usage.topModels.first { details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))") } - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: L("Claude Admin API 30 day spend trend"), valueStyle: .currencyUSD, kpis: [ @@ -427,6 +463,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) + model.currencyCode = "USD" + return model } private static func crossModelInlineDashboard(_ usage: CrossModelUsageSnapshot) -> InlineUsageDashboardModel? { @@ -443,7 +481,7 @@ extension UsageMenuCardView.Model { value: value, accessibilityValue: "\(label): \(usage.currencyString(value))") } - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: L("CrossModel API spend trend"), valueStyle: Self.costValueStyle(currencyCode: usage.currency), kpis: [ @@ -463,6 +501,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: []) + model.currencyCode = usage.currency + return model } private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { @@ -498,7 +538,7 @@ extension UsageMenuCardView.Model { case .unavailable: details.append(L("API key limit unavailable right now")) } - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: L("OpenRouter API key spend trend"), valueStyle: .currencyUSD, kpis: [ @@ -518,6 +558,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) + model.currencyCode = "USD" + return model } private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? { @@ -801,32 +843,48 @@ struct InlineUsageDashboardContent: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { - let maxValue = max(self.model.points.map(\.value).max() ?? 0, 1) - HStack(alignment: .bottom, spacing: 2) { - ForEach(self.model.points) { point in - RoundedRectangle(cornerRadius: 1.5, style: .continuous) - .fill(self.fill(for: point, maxValue: maxValue)) - .frame(maxWidth: .infinity) - .frame(height: self.height(for: point, maxValue: maxValue)) - .accessibilityLabel(point.accessibilityValue) + let scale = UsageChartScale(values: self.model.points.map(\.value)) + VStack(alignment: .trailing, spacing: 2) { + if let currencyCode = self.model.currencyCode, scale.maximum > 0 { + Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + } + GeometryReader { geometry in + HStack(alignment: .bottom, spacing: 2) { + ForEach(self.model.points) { point in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(self.fill(for: point, scale: scale)) + .frame(maxWidth: .infinity) + .frame(height: self.height(for: point, scale: scale, available: geometry.size.height)) + .accessibilityLabel(point.accessibilityValue) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .overlay(alignment: .bottomLeading) { + Rectangle() + .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) + .frame(height: 1) + } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .overlay(alignment: .bottomLeading) { - Rectangle() - .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) - .frame(height: 1) } } - private func height(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> CGFloat { - let ratio = point.value / maxValue + private func height( + for point: InlineUsageDashboardModel.Point, + scale: UsageChartScale, + available: CGFloat) -> CGFloat + { + let ratio = scale.fraction(for: point.value) guard ratio > 0 else { return 1 } - return CGFloat(max(3, min(58, ratio * 58))) + return max(3, CGFloat(ratio) * available) } - private func fill(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> Color { - let ratio = max(0.18, min(1, point.value / maxValue)) + private func fill(for point: InlineUsageDashboardModel.Point, scale: UsageChartScale) -> Color { + let ratio = max(0.18, scale.fraction(for: point.value)) if self.isHighlighted { return Color.white.opacity(0.55 + ratio * 0.35) } diff --git a/Sources/CodexBar/KeychainMigration.swift b/Sources/CodexBar/KeychainMigration.swift index 51bf840ca3..856d565fae 100644 --- a/Sources/CodexBar/KeychainMigration.swift +++ b/Sources/CodexBar/KeychainMigration.swift @@ -82,7 +82,7 @@ enum KeychainMigration { query[kSecAttrAccount as String] = account } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Item doesn't exist, nothing to migrate @@ -115,7 +115,7 @@ enum KeychainMigration { deleteQuery[kSecAttrAccount as String] = account } - let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + let deleteStatus = KeychainSecurity.delete(deleteQuery as CFDictionary) guard deleteStatus == errSecSuccess else { throw KeychainMigrationError.deleteFailed(deleteStatus) } @@ -131,7 +131,7 @@ enum KeychainMigration { addQuery[kSecAttrAccount as String] = account } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { throw KeychainMigrationError.addFailed(addStatus) } diff --git a/Sources/CodexBar/KimiK2TokenStore.swift b/Sources/CodexBar/KimiK2TokenStore.swift index ed3cf55aa3..b7cc5c1b03 100644 --- a/Sources/CodexBar/KimiK2TokenStore.swift +++ b/Sources/CodexBar/KimiK2TokenStore.swift @@ -50,7 +50,7 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw KimiK2TokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/KimiTokenStore.swift b/Sources/CodexBar/KimiTokenStore.swift index dddcb15986..50bb9553a3 100644 --- a/Sources/CodexBar/KimiTokenStore.swift +++ b/Sources/CodexBar/KimiTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw KimiTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 38c9d15736..d7fb7bde30 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -5,6 +5,22 @@ enum CodexBarLocalizationOverride { @TaskLocal static var appLanguage: String? } +enum AppLanguagePreferenceMigration { + private static let appleLanguagesKey = "AppleLanguages" + + static func clearLegacyOverrideIfOwned( + storedAppLanguage: String, + defaults: UserDefaults = .standard) + { + let language = storedAppLanguage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !language.isEmpty, + defaults.stringArray(forKey: self.appleLanguagesKey) == [language] + else { return } + + defaults.removeObject(forKey: self.appleLanguagesKey) + } +} + private func appLanguageDefaults() -> UserDefaults { if Bundle.main.bundleIdentifier != nil { return .standard @@ -52,8 +68,7 @@ func codexBarLocalizationSignature() -> String { 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? + private nonisolated(unsafe) static var localizedBundlesByLanguage: [String: Bundle] = [:] static func defaultResourceBundle(_ compute: () -> Bundle) -> Bundle { self.lock.lock() @@ -71,16 +86,14 @@ private enum LocalizationBundleCache { static func localizedBundle(forLanguage language: String, _ compute: () -> Bundle) -> Bundle { self.lock.lock() - if self.cachedLanguage == language, let cachedLocalizedBundle { - let hit = cachedLocalizedBundle + if let cachedLocalizedBundle = self.localizedBundlesByLanguage[language] { self.lock.unlock() - return hit + return cachedLocalizedBundle } self.lock.unlock() let computed = compute() self.lock.lock() - self.cachedLanguage = language - cachedLocalizedBundle = computed + self.localizedBundlesByLanguage[language] = computed self.lock.unlock() return computed } @@ -88,8 +101,7 @@ private enum LocalizationBundleCache { static func reset() { self.lock.lock() self.resourceBundle = nil - self.cachedLanguage = nil - self.cachedLocalizedBundle = nil + self.localizedBundlesByLanguage = [:] self.lock.unlock() } } @@ -132,7 +144,11 @@ private func localizedBundle() -> Bundle { // 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) { + return localizedBundle(forLanguage: language) +} + +private func localizedBundle(forLanguage language: String) -> Bundle { + LocalizationBundleCache.localizedBundle(forLanguage: language) { resolveLocalizedBundle(forLanguage: language) } } @@ -145,7 +161,11 @@ private func resolveLocalizedBundle(forLanguage language: String) -> Bundle { } } else { // System mode: follow macOS language preferences - if let preferred = resourceBundle.preferredLocalizations.first, + let localizations = resourceBundle.localizations.filter { $0 != "Base" } + let preferred = Bundle.preferredLocalizations( + from: localizations, + forPreferences: Locale.preferredLanguages).first + if let preferred, let bundle = lprojBundle(named: preferred, in: resourceBundle) { return bundle @@ -181,6 +201,12 @@ func L(_ key: String, _ arguments: CVarArg...) -> String { String(format: L(key), arguments: arguments) } +func L(_ key: String, language: String) -> String { + let resourceBundle = codexBarLocalizationResourceBundle() + let bundle = localizedBundle(forLanguage: language) + return codexBarLocalizedString(key, bundle: bundle, resourceBundle: resourceBundle) +} + func codexBarLocalizedLocale() -> Locale { let language = resolvedAppLanguage() guard !language.isEmpty else { return .current } @@ -213,13 +239,17 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } +func resetCodexBarLocalizationCache() { + LocalizationBundleCache.reset() +} + #if DEBUG func codexBarLocalizedBundleForTesting() -> Bundle { localizedBundle() } func resetCodexBarLocalizationCacheForTesting() { - LocalizationBundleCache.reset() + resetCodexBarLocalizationCache() } #endif diff --git a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift index 6e7c6cafd6..6109e558b7 100644 --- a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift +++ b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift @@ -27,19 +27,28 @@ enum MenuBarStatusItemDefaultsRepair { return itemName.hasPrefix(self.legacyAutosavePrefix) || self.isDefaultStatusItemName(itemName) } + static func visibilityDefault(defaults: UserDefaults, autosaveName: String) -> Bool? { + guard !autosaveName.isEmpty else { return nil } + return self.boolValue(defaults.object(forKey: self.visibilityPrefix + autosaveName)) + } + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { guard itemName.hasPrefix("Item-") else { return false } return itemName.dropFirst("Item-".count).allSatisfy(\.isNumber) } private static func isFalse(_ value: Any?) -> Bool { + self.boolValue(value) == false + } + + private static func boolValue(_ value: Any?) -> Bool? { switch value { case let number as NSNumber: - !number.boolValue + number.boolValue case let bool as Bool: - !bool + bool default: - false + nil } } } diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index 2d637008b3..767f3e092e 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -34,6 +34,18 @@ extension StatusItemVisibilitySnapshot: CustomStringConvertible { } } +struct StatusItemStartupVisibilityEvidence: Equatable, CustomStringConvertible { + let autosaveName: String + let expectsVisibility: Bool + let visibilityDefault: Bool? + let snapshot: StatusItemVisibilitySnapshot + + var description: String { + "name=\(self.autosaveName),expected=\(self.expectsVisibility)," + + "default=\(self.visibilityDefault.map(String.init) ?? "unset"),\(self.snapshot)" + } +} + @MainActor func isStatusItemBlocked(_ item: NSStatusItem) -> Bool { MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) @@ -113,14 +125,20 @@ enum MenuBarVisibilityWatcher { static func hasAnyStartupRecoveryCandidate( snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], - detectTahoeBlockedProxy: Bool = false) + detectTahoeBlockedStatusItem: Bool = false) -> Bool { if self.hasAnyBlockedVisibleSnapshot(snapshots) { return true } - guard detectTahoeBlockedProxy, + if detectTahoeBlockedStatusItem, + self.hasAnyTahoeHiddenNoProxyCandidate(evidence: evidence, windowSnapshots: windowSnapshots) + { + return true + } + guard detectTahoeBlockedStatusItem, self.hasAnyDisplacedVisibleSnapshot(snapshots), windowSnapshots.contains(where: \.isTahoeBlockedProxy) else { @@ -129,6 +147,24 @@ enum MenuBarVisibilityWatcher { return true } + static func hasAnyTahoeHiddenNoProxyCandidate( + evidence: [StatusItemStartupVisibilityEvidence], + windowSnapshots: [MenuBarStatusItemWindowSnapshot]) + -> Bool + { + evidence.contains { item in + // Tahoe can destroy the Control Center scene while leaving its enabled default behind. + // Requiring both app intent and that default avoids treating ordinary hidden items as blocked. + item.expectsVisibility + && item.visibilityDefault == true + && !item.snapshot.isVisible + && !item.snapshot.hasWindow + && !windowSnapshots.contains { + $0.name == item.autosaveName && $0.isOnscreen && $0.isWithinDisplayBounds + } + } + } + @MainActor static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { items.map { item in @@ -145,15 +181,17 @@ enum MenuBarVisibilityWatcher { appLaunchedAt: Date, now: Date = Date(), snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], - detectTahoeBlockedProxy: Bool = false) + detectTahoeBlockedStatusItem: Bool = false) -> Bool { guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false } return self.hasAnyStartupRecoveryCandidate( snapshots: snapshots, + evidence: evidence, windowSnapshots: windowSnapshots, - detectTahoeBlockedProxy: detectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: detectTahoeBlockedStatusItem) } static func shouldRefreshScreenChangePlacement( @@ -215,14 +253,16 @@ extension StatusItemController { } private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) { - let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let evidence = self.startupStatusItemVisibilityEvidence() + let snapshots = evidence.map(\.snapshot) let windowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, snapshots: snapshots, + evidence: evidence, windowSnapshots: windowSnapshots, - detectTahoeBlockedProxy: self.canDetectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { return } @@ -231,18 +271,21 @@ extension StatusItemController { "Status item failed to materialize or remained detached; recreating status items", metadata: [ "snapshots": snapshots.map(\.description).joined(separator: " | "), + "evidence": evidence.map(\.description).joined(separator: " | "), "windows": self.statusItemWindowDiagnosticsDescription(windowSnapshots), ]) self.recreateStatusItemsForVisibilityRecovery() - let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let recoveredEvidence = self.startupStatusItemVisibilityEvidence() + let recoveredSnapshots = recoveredEvidence.map(\.snapshot) let recoveredWindowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, snapshots: recoveredSnapshots, + evidence: recoveredEvidence, windowSnapshots: recoveredWindowSnapshots, - detectTahoeBlockedProxy: self.canDetectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { self.menuLogger.info( "Status item materialized after recreation", @@ -254,6 +297,7 @@ extension StatusItemController { "Status item still unavailable after recreation", metadata: [ "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "evidence": recoveredEvidence.map(\.description).joined(separator: " | "), "windows": self.statusItemWindowDiagnosticsDescription(recoveredWindowSnapshots), ]) guard #available(macOS 26.0, *), @@ -382,7 +426,20 @@ extension StatusItemController { [self.statusItem] + Array(self.statusItems.values) } - private var canDetectTahoeBlockedProxy: Bool { + private func startupStatusItemVisibilityEvidence() -> [StatusItemStartupVisibilityEvidence] { + self.startupVisibilityStatusItems.map { item in + let autosaveName = item.autosaveName ?? "" + return StatusItemStartupVisibilityEvidence( + autosaveName: autosaveName, + expectsVisibility: self.expectedVisibleStatusItemAutosaveNames.contains(autosaveName), + visibilityDefault: MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: self.settings.userDefaults, + autosaveName: autosaveName), + snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) + } + } + + private var canDetectTahoeBlockedStatusItem: Bool { if #available(macOS 26.0, *) { return true } diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 9e1962b85c..834e678008 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -19,8 +19,7 @@ extension UsageMenuCardView.Model { "creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")", MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText), MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText), - MenuCardHeightFingerprint.field("codexResetCredits", self.codexResetCreditsText), - MenuCardHeightFingerprint.field("codexResetCreditsDetail", self.codexResetCreditsDetailText), + "codexResetCredits=\(self.codexResetCredits?.heightFingerprint ?? "")", "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", "notes=\(notesFingerprint)", "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", @@ -90,7 +89,8 @@ extension UsageMenuCardView.Model.Metric { self.pacePercent == nil ? "pace=0" : "pace=1", self.paceOnTop ? "paceTop=1" : "paceTop=0", self.cardStyle ? "card=1" : "card=0", - "markers=\(self.warningMarkerPercents.count)", + "warningMarkers=\(self.warningMarkerPercents.count)", + "workdayMarkers=\(self.workdayMarkerPercents.count)", ]) } } @@ -112,6 +112,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { MenuCardHeightFingerprint.join([ MenuCardHeightFingerprint.field("session", self.sessionLine), MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")), MenuCardHeightFingerprint.field("hint", self.hintLine), MenuCardHeightFingerprint.field("error", self.errorLine), MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), @@ -119,6 +120,15 @@ extension UsageMenuCardView.Model.TokenUsageSection { } } +extension CodexResetCreditsPresentation { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("text", self.text), + MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText), + ]) + } +} + extension InlineUsageDashboardModel { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index abe15e2879..c5f0c18529 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -18,56 +18,6 @@ extension UsageMenuCardView.Model { .map { showUsed ? 100 - Double($0) : Double($0) } .filter { $0 > 0 && $0 < 100 } } - - /// Merges quota warning markers with optional work-day boundary markers. - /// Preserves original warning-marker ordering when workdayMarkers is empty, - /// sorts the combined set when workday markers are present. - static func mergedMarkerPercents( - warningMarkers: [Double], - workdayMarkers: [Double]) -> [Double] - { - let combined = warningMarkers + workdayMarkers - return workdayMarkers.isEmpty ? combined : combined.sorted() - } - - /// Combines quota warning markers with optional work-day boundary markers - /// into a single sorted array. Workday markers are only applied when - /// includeWorkdayMarkers is true and windowMinutes == 10080. - static func markerPercents( - thresholds: [Int]?, - showUsed: Bool, - workDays: Int?, - windowMinutes: Int?, - includeWorkdayMarkers: Bool) -> [Double] - { - let warningMarkers = Self.warningMarkerPercents(thresholds: thresholds, showUsed: showUsed) - let workdayMarkers = includeWorkdayMarkers - ? workDayMarkerPercents(workDays: workDays, windowMinutes: windowMinutes) - : [] - return Self.mergedMarkerPercents(warningMarkers: warningMarkers, workdayMarkers: workdayMarkers) - } - - static func weeklyMarkerPercents(input: Input, windowMinutes: Int?) -> [Double] { - UsageMenuCardView.Model.markerPercents( - thresholds: input.quotaWarningThresholds[.weekly], - showUsed: input.usageBarsShowUsed, - workDays: input.workDaysPerWeek, - windowMinutes: windowMinutes, - includeWorkdayMarkers: true) - } - - static func codexLaneMarkerPercents( - input: Input, - lane: CodexConsumerProjection.RateLane, - windowMinutes: Int?) -> [Double] - { - UsageMenuCardView.Model.markerPercents( - thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], - showUsed: input.usageBarsShowUsed, - workDays: input.workDaysPerWeek, - windowMinutes: windowMinutes, - includeWorkdayMarkers: lane == .weekly) - } } /// Returns boundary percentages for work day markers on a weekly progress bar. diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 781c15e8f9..dfe0960057 100644 --- a/Sources/CodexBar/MenuCardRefreshMonitor.swift +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -13,10 +13,18 @@ final class MenuCardRefreshMonitor { typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model? private let resolveModel: ModelResolver - var isManualRefreshInFlight = false - private var manualRefreshProvider: UsageProvider? + /// Set while an all-providers refresh is running; it freezes every provider's card. + private var globalManualRefreshInFlight = false + /// Providers with an individual manual refresh in flight. Concurrent entries are allowed so + /// refreshing one provider does not stall or unfreeze another. + private var manualRefreshProviders: Set = [] private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:] + /// True while any manual refresh (global or per-provider) is running. + var isManualRefreshInFlight: Bool { + self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty + } + init(resolveModel: @escaping ModelResolver) { self.resolveModel = resolveModel } @@ -25,19 +33,34 @@ final class MenuCardRefreshMonitor { frozenModels: [UsageProvider: UsageMenuCardView.Model], provider: UsageProvider? = nil) { - self.frozenManualRefreshModels = frozenModels - self.manualRefreshProvider = provider - self.isManualRefreshInFlight = true + if let provider { + self.frozenManualRefreshModels[provider] = frozenModels[provider] + self.manualRefreshProviders.insert(provider) + } else { + self.frozenManualRefreshModels = frozenModels + self.globalManualRefreshInFlight = true + } + } + + /// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh). + func endManualRefresh(for provider: UsageProvider? = nil) { + if let provider { + self.manualRefreshProviders.remove(provider) + self.frozenManualRefreshModels[provider] = nil + } else { + self.globalManualRefreshInFlight = false + self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + } } - func endManualRefresh() { - self.isManualRefreshInFlight = false - self.manualRefreshProvider = nil + func resetManualRefresh() { + self.globalManualRefreshInFlight = false + self.manualRefreshProviders.removeAll(keepingCapacity: true) self.frozenManualRefreshModels.removeAll(keepingCapacity: true) } func isManualRefreshInFlight(for provider: UsageProvider) -> Bool { - self.isManualRefreshInFlight && (self.manualRefreshProvider == nil || self.manualRefreshProvider == provider) + self.globalManualRefreshInFlight || self.manualRefreshProviders.contains(provider) } func model( diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index 68d6248e68..fa6b2d4da3 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -1,9 +1,87 @@ import CodexBarCore import SwiftUI -struct CodexResetCreditsContent: View { +struct CodexResetCreditPresentationItem: Equatable { + let expiryText: String + let compactExpiryText: String +} + +struct CodexResetCreditsPresentation: Equatable { let text: String - let detailText: String? + let items: [CodexResetCreditPresentationItem] + + var expirySummaryText: String { + let visibleItems = self.items.prefix(4).map(\.compactExpiryText) + let hiddenCount = self.items.count - visibleItems.count + let suffix = hiddenCount > 0 ? ["+\(hiddenCount)"] : [] + return (visibleItems + suffix).joined(separator: " · ") + } + + var helpText: String { + self.items.enumerated().map { index, item in + "\(index + 1). \(item.expiryText)" + }.joined(separator: "\n") + } + + var accessibilityLabel: String { + [L("Limit Reset Credits"), self.text, self.helpText] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + static func make( + snapshot: CodexRateLimitResetCreditsSnapshot, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> CodexResetCreditsPresentation? + { + let inventory = snapshot.availableInventory(at: now) + guard !inventory.credits.isEmpty else { return nil } + let items = inventory.credits.map { credit in + Self.presentationItem(for: credit, resetStyle: resetStyle, now: now) + } + return CodexResetCreditsPresentation( + text: Self.availableText(count: inventory.count), + items: items) + } + + private static func availableText(count: Int) -> String { + count == 1 ? L("1 available") : String(format: L("%d available"), count) + } + + private static func presentationItem( + for credit: CodexRateLimitResetCredit, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> CodexResetCreditPresentationItem + { + guard let expiresAt = credit.expiresAt else { + return CodexResetCreditPresentationItem(expiryText: L("No expiry"), compactExpiryText: L("No expiry")) + } + let formattedTime = Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now) + let compactExpiryText = resetStyle == .countdown && formattedTime.hasPrefix("in ") + ? String(formattedTime.dropFirst(3)) + : formattedTime + return CodexResetCreditPresentationItem( + expiryText: String(format: L("Expires %@"), formattedTime), + compactExpiryText: compactExpiryText) + } + + private static func formattedTime( + _ expiresAt: Date, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> String + { + switch resetStyle { + case .absolute: + return UsageFormatter.resetDescription(from: expiresAt, now: now) + case .countdown: + let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now) + return countdown == "now" ? L("now") : countdown + } + } +} + +struct CodexResetCreditsContent: View { + let presentation: CodexResetCreditsPresentation @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { @@ -12,61 +90,43 @@ struct CodexResetCreditsContent: View { .font(.body) .fontWeight(.medium) .lineLimit(1) - HStack(alignment: .firstTextBaseline) { - Text(self.text) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.presentation.text) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) .layoutPriority(1) - Spacer() - if let detailText, !detailText.isEmpty { - Text(detailText) - .font(.footnote) + Spacer(minLength: 8) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(self.presentation.expirySummaryText) + .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) + .minimumScaleFactor(0.8) } + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .accessibilityHidden(true) } } .frame(maxWidth: .infinity, alignment: .leading) + .help(self.presentation.helpText) .accessibilityElement(children: .combine) - .accessibilityLabel([ - L("Limit Reset Credits"), - self.text, - self.detailText, - ].compactMap(\.self).joined(separator: ", ")) + .accessibilityLabel(self.presentation.accessibilityLabel) } } extension UsageMenuCardView.Model { - static func codexResetCreditsText(input: Input) -> String? { + static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? { guard input.provider == .codex, - let resetCredits = input.snapshot?.codexResetCredits, - resetCredits.availableCount > 0 + let resetCredits = input.snapshot?.codexResetCredits else { return nil } - let count = resetCredits.availableCount - if count == 1 { - return L("1 available") - } - return String(format: L("%d available"), count) - } - - static func codexResetCreditsDetailText(input: Input) -> String? { - guard input.provider == .codex, - let resetCredits = input.snapshot?.codexResetCredits, - let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt - else { - return nil - } - let timeText: String - switch input.resetTimeDisplayStyle { - case .absolute: - timeText = UsageFormatter.resetDescription(from: expiresAt, now: input.now) - case .countdown: - let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: input.now) - timeText = countdown == "now" ? L("now") : countdown - } - return String(format: L("Next expires %@"), timeText) + return CodexResetCreditsPresentation.make( + snapshot: resetCredits, + resetStyle: input.resetTimeDisplayStyle, + now: input.now) } } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 239cddd37a..b7d5fdbf37 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -18,6 +18,15 @@ extension UsageMenuCardView.Model.ProviderCostSection { } extension UsageMenuCardView.Model { + static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?) -> ProviderCostSection? { + guard let usage else { return nil } + return ProviderCostSection( + title: L("Extra usage"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(usage.balanceDetail)", + percentLine: usage.periodUsageTotal.map { "\(L("Usage")): \(UsageFormatter.usdString($0))" }) + } + static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool { snapshot?.primary == nil && snapshot?.secondary == nil && @@ -38,6 +47,7 @@ extension UsageMenuCardView.Model { error: String?) -> String? { guard metadata.supportsCredits else { return nil } + if metadata.id == .codex, credits == nil, error == nil { return nil } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, let ampCredits = self.ampCreditsLine(ampUsage) @@ -92,6 +102,7 @@ extension UsageMenuCardView.Model { static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { @@ -134,11 +145,29 @@ extension UsageMenuCardView.Model { return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, + comparisonLines: comparisonPeriodsEnabled + ? snapshot.comparisonSummaries().map { + Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + } + : [], hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } + static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { + let label = Self.costHistoryWindowLabel(days: summary.days) + let cost = summary.totalCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: currencyCode) + } ?? "—" + guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) + } + static func tokenUsageHint(provider: UsageProvider) -> String? { switch provider { case .codex: @@ -255,7 +284,7 @@ extension UsageMenuCardView.Model { guard let cost else { return nil } guard provider != .synthetic else { return nil } - if provider == .factory, cost.period == "Extra usage balance" { + if provider == .factory || provider == .devin, cost.period == "Extra usage balance" { let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) return ProviderCostSection( title: L("Extra usage"), @@ -296,13 +325,26 @@ extension UsageMenuCardView.Model { return nil } + if provider == .clawrouter, cost.limit <= 0 { + let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + return ProviderCostSection( + title: "ClawRouter spend", + percentUsed: nil, + spendLine: "\(L("This month")): \(spend)", + percentLine: nil) + } + guard cost.limit > 0 else { return nil } let used: String let limit: String let title: String - if cost.currencyCode == "Quota" { + if provider == .clawrouter { + title = "Monthly budget" + used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + } else if cost.currencyCode == "Quota" { title = L("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index d410770619..22e6882289 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -39,6 +39,9 @@ extension UsageMenuCardView.Model { warningMarkerPercents: service.isUnlimited ? [] : Self.miniMaxWarningMarkerPercents(service: service, input: input), + workdayMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWorkdayMarkerPercents(service: service, input: input), cardStyle: false) } } @@ -50,15 +53,19 @@ extension UsageMenuCardView.Model { thresholds: input.quotaWarningThresholds[.session], showUsed: true) case .weekly: - markerPercents( + warningMarkerPercents( thresholds: input.quotaWarningThresholds[.weekly], - showUsed: true, - workDays: input.workDaysPerWeek, - windowMinutes: self.miniMaxWindowMinutes(for: service.windowType), - includeWorkdayMarkers: true) + showUsed: true) } } + private static func miniMaxWorkdayMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + guard self.miniMaxQuotaWarningWindow(for: service) == .weekly else { return [] } + return workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: self.miniMaxWindowMinutes(for: service.windowType)) + } + private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow { service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 1b4a1e0b65..3ae74955d5 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -44,6 +44,7 @@ extension UsageMenuCardView.Model { pacePercent: metric.pacePercent, paceOnTop: metric.paceOnTop, warningMarkerPercents: metric.warningMarkerPercents, + workdayMarkerPercents: metric.workdayMarkerPercents, cardStyle: metric.cardStyle) } } @@ -65,14 +66,14 @@ extension UsageMenuCardView.Model { !self.usageNotes.isEmpty || self.openAIAPIUsage != nil || self.inlineUsageDashboard != nil || - self.codexResetCreditsText != nil || + self.codexResetCredits != nil || self.placeholder != nil } var usesStackedDetailLayout: Bool { !self.metrics.isEmpty || self.creditsText != nil || - self.codexResetCreditsText != nil || + self.codexResetCredits != nil || self.providerCost != nil || self.tokenUsage != nil } @@ -107,8 +108,7 @@ extension UsageMenuCardView.Model { candidateText: candidate.creditsText, candidateRemaining: candidate.creditsRemaining), self.creditsHintText == candidate.creditsHintText, - self.codexResetCreditsText == candidate.codexResetCreditsText, - self.codexResetCreditsDetailText == candidate.codexResetCreditsDetailText, + self.codexResetCredits == candidate.codexResetCredits, self.placeholder == candidate.placeholder, Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard), Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost), @@ -201,7 +201,8 @@ extension UsageMenuCardView.Model { true case let (current?, candidate?): current.hintLine == candidate.hintLine && - current.errorLine == candidate.errorLine + current.errorLine == candidate.errorLine && + current.comparisonLines.count == candidate.comparisonLines.count default: false } @@ -625,7 +626,7 @@ extension UsageMenuCardView.Model { window: RateWindow, input: Input) -> PaceDetail? { - guard provider == .codex else { return nil } + guard provider == .codex || provider == .antigravity else { return nil } switch window.windowMinutes { case 300: return self.sessionPaceDetail( @@ -650,6 +651,35 @@ extension UsageMenuCardView.Model { } } + private static func antigravityMetricPaceDetail( + window: RateWindow, + input: Input) -> PaceDetail? + { + guard input.provider == .antigravity else { return nil } + switch window.windowMinutes { + case nil, 300: + return self.sessionPaceDetail( + provider: input.provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case 10080: + let pace = Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) + return Self.weeklyPaceDetail( + provider: input.provider, + window: window, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + default: + return nil + } + } + static func antigravityMetric( id: String, title: String, @@ -673,6 +703,7 @@ extension UsageMenuCardView.Model { paceOnTop: true) } let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent + let paceDetail = Self.antigravityMetricPaceDetail(window: window, input: input) return Metric( id: id, title: title, @@ -680,10 +711,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: Self.resetText(for: window, 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) } static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index f31333e59c..9e45c32874 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -14,6 +14,8 @@ extension UsageMenuCardView.Model { let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? let account: AccountInfo + let accountIsAuthoritative: Bool + let planOverride: String? let isRefreshing: Bool let lastError: String? let limitsAvailability: UsageLimitsAvailability? @@ -22,6 +24,7 @@ extension UsageMenuCardView.Model { let tokenCostUsageEnabled: Bool let tokenCostInlineDashboardEnabled: Bool let tokenCostMenuSectionEnabled: Bool + let costComparisonPeriodsEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool let copilotBudgetExtrasEnabled: Bool let sourceLabel: String? @@ -45,6 +48,8 @@ extension UsageMenuCardView.Model { tokenSnapshot: CostUsageTokenSnapshot?, tokenError: String?, account: AccountInfo, + accountIsAuthoritative: Bool = false, + planOverride: String? = nil, isRefreshing: Bool, lastError: String?, limitsAvailability: UsageLimitsAvailability? = nil, @@ -53,6 +58,7 @@ extension UsageMenuCardView.Model { tokenCostUsageEnabled: Bool, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, + costComparisonPeriodsEnabled: Bool = false, showOptionalCreditsAndExtraUsage: Bool, copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, @@ -75,6 +81,8 @@ extension UsageMenuCardView.Model { self.tokenSnapshot = tokenSnapshot self.tokenError = tokenError self.account = account + self.accountIsAuthoritative = accountIsAuthoritative + self.planOverride = planOverride self.isRefreshing = isRefreshing self.lastError = lastError self.limitsAvailability = limitsAvailability @@ -83,6 +91,7 @@ extension UsageMenuCardView.Model { self.tokenCostUsageEnabled = tokenCostUsageEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled + self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled self.sourceLabel = sourceLabel diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c2c57a5a84..c50f99fbfc 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -37,6 +37,7 @@ struct UsageMenuCardView: View { let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] + let workdayMarkerPercents: [Double] let cardStyle: Bool init( @@ -52,6 +53,7 @@ struct UsageMenuCardView: View { pacePercent: Double?, paceOnTop: Bool, warningMarkerPercents: [Double] = [], + workdayMarkerPercents: [Double] = [], cardStyle: Bool = false) { self.id = id @@ -66,6 +68,7 @@ struct UsageMenuCardView: View { self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents + self.workdayMarkerPercents = workdayMarkerPercents self.cardStyle = cardStyle } @@ -83,9 +86,26 @@ struct UsageMenuCardView: View { struct TokenUsageSection { let sessionLine: String let monthLine: String + let comparisonLines: [String] let hintLine: String? let errorLine: String? let errorCopyText: String? + + init( + sessionLine: String, + monthLine: String, + comparisonLines: [String] = [], + hintLine: String?, + errorLine: String?, + errorCopyText: String?) + { + self.sessionLine = sessionLine + self.monthLine = monthLine + self.comparisonLines = comparisonLines + self.hintLine = hintLine + self.errorLine = errorLine + self.errorCopyText = errorCopyText + } } struct ProviderCostSection { @@ -112,8 +132,7 @@ struct UsageMenuCardView: View { var creditsProgressPercent: Double?, creditsScaleText: String? let creditsHintText: String? let creditsHintCopyText: String? - var codexResetCreditsText: String? - var codexResetCreditsDetailText: String? + var codexResetCredits: CodexResetCreditsPresentation? let providerCost: ProviderCostSection? let tokenUsage: TokenUsageSection? let placeholder: String? @@ -123,6 +142,7 @@ struct UsageMenuCardView: View { let model: Model var layoutModel: Model? let width: CGFloat + var planAction: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.menuCardRefreshMonitor) private var refreshMonitor @@ -136,7 +156,9 @@ struct UsageMenuCardView: View { var body: some View { let liveModel = self.liveModel VStack(alignment: .leading, spacing: 0) { - UsageMenuCardHeaderView(model: self.layoutModel ?? self.model) + UsageMenuCardHeaderView( + model: self.layoutModel ?? self.model, + planAction: self.planAction) if Self.hasDetails(for: liveModel) { Divider() @@ -200,6 +222,11 @@ struct UsageMenuCardView: View { Text(tokenUsage.monthLine) .font(.footnote) .lineLimit(1) + ForEach(tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(.footnote) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -256,6 +283,7 @@ struct UsageMenuCardView: View { private struct UsageMenuCardHeaderView: View { let model: UsageMenuCardView.Model + var planAction: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.menuCardRefreshMonitor) private var refreshMonitor @@ -312,10 +340,20 @@ private struct UsageMenuCardHeaderView: View { .accessibilityHidden(!showsCopyButton) } if let plan = self.model.planText { - Text(plan) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) + Group { + if let planAction { + Button(action: planAction) { + Text(plan) + } + .buttonStyle(.plain) + .accessibilityLabel(plan) + } else { + Text(plan) + } + } + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) } } } @@ -441,7 +479,8 @@ private struct MetricRow: View { accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop, - warningMarkerPercents: self.metric.warningMarkerPercents) + warningMarkerPercents: self.metric.warningMarkerPercents, + workdayMarkerPercents: self.metric.workdayMarkerPercents) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) @@ -514,7 +553,7 @@ struct UsageMenuCardHeaderSectionView: View { var body: some View { VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerContentSpacing) { - UsageMenuCardHeaderView(model: self.model) + UsageMenuCardHeaderView(model: self.model, planAction: nil) if self.showDivider { Divider() @@ -549,20 +588,18 @@ private struct UsageMenuCardUsageContentView: View { title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } - if let resetCredits = self.model.codexResetCreditsText { + if let resetCredits = self.model.codexResetCredits { if !self.model.metrics.isEmpty { Divider() } - CodexResetCreditsContent( - text: resetCredits, - detailText: self.model.codexResetCreditsDetailText) + CodexResetCreditsContent(presentation: resetCredits) } if let dashboard = self.model.inlineUsageDashboard { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder, self.model.metrics.isEmpty, - self.model.codexResetCreditsText == nil + self.model.codexResetCredits == nil { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) @@ -720,6 +757,11 @@ struct UsageMenuCardCostSectionView: View { Text(tokenUsage.monthLine) .font(.caption) .lineLimit(1) + ForEach(tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(.caption) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -790,6 +832,7 @@ extension UsageMenuCardView.Model { for: input.provider, snapshot: input.snapshot, account: input.account, + override: input.planOverride, metadata: input.metadata) let metrics = Self.redactedMetrics( Self.metrics(input: input), @@ -818,9 +861,14 @@ extension UsageMenuCardView.Model { let isRequiredOpenCodeZenBalance = Self.isRequiredOpenCodeZenBalance(input.snapshot) let hidesOptionalProviderCost = ((input.provider == .claude && !isClaudeAdminAPI) || input.provider == .factory || + input.provider == .devin || (input.provider == .opencodego && !isRequiredOpenCodeZenBalance)) && !input.showOptionalCreditsAndExtraUsage - let providerCost: ProviderCostSection? = if hidesOptionalProviderCost || + let providerCost: ProviderCostSection? = if input.provider == .sakana { + input.showOptionalCreditsAndExtraUsage + ? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo) + : nil + } else if hidesOptionalProviderCost || (input.provider == .openai && openAIAPIUsage != nil) { nil @@ -831,6 +879,7 @@ extension UsageMenuCardView.Model { let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostMenuSectionEnabled, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, error: input.tokenError) let subtitle = Self.subtitle( @@ -859,8 +908,7 @@ extension UsageMenuCardView.Model { creditsScaleText: creditsScaleText, creditsHintText: codexCreditLimitDetail ?? redacted.creditsHintText, creditsHintCopyText: codexCreditLimitDetail ?? redacted.creditsHintCopyText, - codexResetCreditsText: Self.codexResetCreditsText(input: input), - codexResetCreditsDetailText: Self.codexResetCreditsDetailText(input: input), + codexResetCredits: Self.codexResetCredits(input: input), providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, @@ -938,10 +986,11 @@ extension UsageMenuCardView.Model { for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, - metadata: ProviderMetadata) -> String + metadata: ProviderMetadata, + accountIsAuthoritative: Bool) -> String { if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } - if metadata.usesAccountFallback, + if metadata.usesAccountFallback || accountIsAuthoritative, let email = account.email, !email.isEmpty { return email @@ -953,8 +1002,12 @@ extension UsageMenuCardView.Model { for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, + override: String?, metadata: ProviderMetadata) -> String? { + if let override, !override.isEmpty { + return override + } if provider == .kiro, let plan = kiroPlan(snapshot: snapshot) { @@ -1072,7 +1125,8 @@ extension UsageMenuCardView.Model { for: input.provider, snapshot: input.snapshot, account: input.account, - metadata: input.metadata), + metadata: input.metadata, + accountIsAuthoritative: input.accountIsAuthoritative), isEnabled: input.hidePersonalInfo) let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) ?? subtitle.text @@ -1115,6 +1169,20 @@ extension UsageMenuCardView.Model { let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) + if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits { + metrics.append(Metric( + id: "mistral-balance", + title: L("Balance"), + percent: 0, + percentStyle: percentStyle, + statusText: credits.formattedAvailableAmount, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } if input.provider == .codex, let codexProjection = input.codexProjection { metrics.append(contentsOf: Self.codexRateMetrics( input: input, @@ -1186,16 +1254,16 @@ extension UsageMenuCardView.Model { snapshot: snapshot, input: input, percentStyle: percentStyle)) - if input.provider == .kilo, + if input.provider == .kilo || input.provider == .kimi, metrics.contains(where: { $0.id == "primary" }), metrics.contains(where: { $0.id == "secondary" }) { metrics.sort { lhs, rhs in - let kiloOrder: [String: Int] = [ + let primarySecondaryOrder: [String: Int] = [ "secondary": 0, "primary": 1, ] - return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) + return (primarySecondaryOrder[lhs.id] ?? Int.max) < (primarySecondaryOrder[rhs.id] ?? Int.max) } } @@ -1252,7 +1320,7 @@ extension UsageMenuCardView.Model { primaryDetailLeft = detail } if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek - || input.provider == .qoder || input.provider == .litellm, + || input.provider == .qoder || input.provider == .mistral || input.provider == .litellm, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1269,15 +1337,16 @@ extension UsageMenuCardView.Model { let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) primaryDetailLeft = String(format: L("%@ of %@ credits left"), remaining, total) } - if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .mistral || input - .provider == .manus, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .manus, + let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail if input.provider == .manus { primaryResetText = nil } } - if [.warp, .kilo, .mimo, .deepseek, .qoder, .litellm].contains(input.provider), primary.resetsAt == nil { + if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm].contains(input.provider), + primary.resetsAt == nil + { primaryResetText = nil } // Abacus: show credits as detail, compute pace on the primary monthly window @@ -1343,8 +1412,9 @@ extension UsageMenuCardView.Model { primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } - let primaryStatusText = input.provider == .deepseek ? primaryDetailText : nil - if input.provider == .deepseek { + let usesBalanceStatusText = input.provider == .deepseek + let primaryStatusText = usesBalanceStatusText ? primaryDetailText : nil + if usesBalanceStatusText { primaryDetailText = nil } return Metric( @@ -1469,7 +1539,12 @@ extension UsageMenuCardView.Model { detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.weeklyMarkerPercents(input: input, windowMinutes: weekly.windowMinutes)) + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed), + workdayMarkerPercents: workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: weekly.windowMinutes)) } private static func codexRateMetrics( @@ -1514,10 +1589,14 @@ extension UsageMenuCardView.Model { detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.codexLaneMarkerPercents( - input: input, - lane: lane, - windowMinutes: window.windowMinutes)) + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed), + workdayMarkerPercents: lane == .weekly + ? workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: window.windowMinutes) + : []) } } } diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 3d9b5feb29..2a1c33fb46 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -71,6 +71,10 @@ struct MenuContent: View { } } .buttonStyle(.plain) + case let .unavailable(title, tooltip): + Text(title) + .foregroundStyle(.secondary) + .help(tooltip ?? "") case let .submenu(title, systemImageName, submenuItems): VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -142,6 +146,8 @@ struct MenuContent: View { self.actions.quit() case let .copyError(message): self.actions.copyError(message) + case .focusAgentSession: + return } } } @@ -231,19 +237,23 @@ struct StatusIconView: View { } private var icon: NSImage { + let now = Date() let snapshot = self.store.snapshot(for: self.provider) let remaining = snapshot.map { - IconRemainingResolver.resolvedRemaining(snapshot: $0, style: self.store.style(for: self.provider)) + IconRemainingResolver.resolvedRemaining( + snapshot: $0, + style: self.store.style(for: self.provider), + now: now) } let creditsProjection = self.store.codexConsumerProjectionIfNeeded( for: self.provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let creditsRemaining = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil return IconRenderer.makeIcon( primaryRemaining: remaining?.primary, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 2441f11915..9f8cccd2e7 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -24,8 +24,16 @@ struct MenuDescriptor { enum Entry { case text(String, TextStyle) case action(String, MenuAction) + case unavailable(String, String?) case submenu(String, String?, [SubmenuItem]) case divider + + var isActionable: Bool { + switch self { + case .action, .submenu, .unavailable: true + case .text, .divider: false + } + } } enum MenuActionSystemImage: String { @@ -68,6 +76,7 @@ struct MenuDescriptor { case about case quit case copyError(String) + case focusAgentSession(AgentSession, remoteHost: String?) } var sections: [Section] @@ -80,7 +89,11 @@ struct MenuDescriptor { managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? = nil, codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, updateReady: Bool, - includeContextualActions: Bool = true) -> MenuDescriptor + includeContextualActions: Bool = true, + agentSessionsEnabled: Bool = false, + localAgentSessions: [AgentSession] = [], + remoteAgentHosts: [RemoteSessionHostResult] = [], + now: Date = Date()) -> MenuDescriptor { var sections: [Section] = [] @@ -129,11 +142,71 @@ struct MenuDescriptor { sections.append(actions) } } + if agentSessionsEnabled { + sections.append(Self.agentSessionsSection( + localSessions: localAgentSessions, + remoteHosts: remoteAgentHosts, + now: now)) + } sections.append(Self.metaSection(updateReady: updateReady)) return MenuDescriptor(sections: sections) } + static func agentSessionsSection( + localSessions: [AgentSession], + remoteHosts: [RemoteSessionHostResult], + now: Date = Date()) -> Section + { + let totalCount = localSessions.count + remoteHosts.reduce(0) { $0 + $1.sessions.count } + var entries: [Entry] = [.text("Agent Sessions (\(totalCount))", .headline)] + + for session in localSessions { + entries.append(.action( + self.agentSessionRowTitle(session, now: now), + .focusAgentSession(session, remoteHost: nil))) + } + for remoteHost in remoteHosts { + if let error = remoteHost.error { + entries.append(.unavailable("\(remoteHost.host) — unreachable", error)) + continue + } + entries.append(.text("\(remoteHost.host) — \(remoteHost.sessions.count)", .secondary)) + for session in remoteHost.sessions { + entries.append(.action( + self.agentSessionRowTitle(session, now: now), + .focusAgentSession(session, remoteHost: remoteHost.host))) + } + } + if totalCount == 0 { + entries.append(.unavailable("No agent sessions found", nil)) + } + return Section(entries: entries) + } + + private static func agentSessionRowTitle(_ session: AgentSession, now: Date) -> String { + let state = session.state == .active ? "●" : "○" + let providerGlyph = session.provider == .codex ? "⌘" : "✦" + let project = session.projectName ?? "Unknown project" + return "\(state) \(providerGlyph) \(project) — \(session.provider.rawValue) · " + + "\(session.source.rawValue) · \(self.agentSessionAge(session, now: now))" + } + + private static func agentSessionAge(_ session: AgentSession, now: Date) -> String { + guard let activity = session.lastActivityAt ?? session.startedAt else { return "now" } + let seconds = max(0, Int(now.timeIntervalSince(activity))) + if seconds < 60 { + return "\(seconds)s" + } + if seconds < 3600 { + return "\(seconds / 60)m" + } + if seconds < 86400 { + return "\(seconds / 3600)h" + } + return "\(seconds / 86400)d" + } + private static func usageSection( for provider: UsageProvider, store: UsageStore, @@ -238,7 +311,10 @@ struct MenuDescriptor { resetOverride: opusResetOverride) } - Self.appendProviderUsageSummaries(entries: &entries, snapshot: snap) + Self.appendProviderUsageSummaries( + entries: &entries, + snapshot: snap, + showOptionalUsage: settings.showOptionalCreditsAndExtraUsage) if snap.rateLimitsUnavailable(for: provider) { entries.append(.text(L("Limits not available"), .secondary)) } @@ -264,7 +340,8 @@ struct MenuDescriptor { private static func appendProviderUsageSummaries( entries: inout [Entry], - snapshot: UsageSnapshot) + snapshot: UsageSnapshot, + showOptionalUsage: Bool) { if let cost = snapshot.providerCost { if cost.currencyCode == "Quota" { @@ -282,6 +359,18 @@ struct MenuDescriptor { if let openRouterUsage = snapshot.openRouterUsage { Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage) } + if let clawRouterUsage = snapshot.clawRouterUsage { + entries.append(.text( + "\(UsageFormatter.tokenCountString(clawRouterUsage.requestCount)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(clawRouterUsage.totalTokens)) \(L("tokens"))", + .secondary)) + if !clawRouterUsage.providers.isEmpty { + let mix = clawRouterUsage.providers.prefix(5) + .map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" } + .joined(separator: " · ") + entries.append(.text("Routed providers: \(mix)", .secondary)) + } + } if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty { Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage) } @@ -291,6 +380,18 @@ struct MenuDescriptor { if let mimoUsage = snapshot.mimoUsage { entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary)) } + // Sakana pay-as-you-go is optional data gated by "Show optional credits and extra usage". + // Gate the render on the setting too, not just the fetch: toggling the setting off only + // rebuilds the menu, it does not immediately refetch, so a previously-populated + // sakanaPayAsYouGo would otherwise linger in the cached snapshot until the next refresh. + if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo { + entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary)) + if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal { + entries.append(.text( + "\(L("Usage")): \(UsageFormatter.usdString(periodUsageTotal))", + .secondary)) + } + } } private static func appendOpenAIAPIUsageSummary( @@ -794,6 +895,8 @@ extension MenuDescriptor.MenuAction { case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue + case .focusAgentSession: + nil } } } diff --git a/Sources/CodexBar/MiniMaxAPITokenStore.swift b/Sources/CodexBar/MiniMaxAPITokenStore.swift index e4d281b925..af079bbaf7 100644 --- a/Sources/CodexBar/MiniMaxAPITokenStore.swift +++ b/Sources/CodexBar/MiniMaxAPITokenStore.swift @@ -50,7 +50,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxAPITokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/MiniMaxCookieStore.swift b/Sources/CodexBar/MiniMaxCookieStore.swift index e36bbe71ad..49e714b993 100644 --- a/Sources/CodexBar/MiniMaxCookieStore.swift +++ b/Sources/CodexBar/MiniMaxCookieStore.swift @@ -50,7 +50,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -96,7 +96,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -109,7 +109,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxCookieStoreError.keychainStatus(addStatus) @@ -123,7 +123,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 166cae5edd..90d6301e06 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -80,6 +80,7 @@ struct AboutPane: View { } .formStyle(.grouped) .toggleStyle(.switch) + .scrollContentBackground(.hidden) .onAppear { guard !self.didLoadUpdaterState else { return } // Align Sparkle's flag with the persisted preference on first load. diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 4afc6370f3..cff5b0040b 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -65,6 +65,7 @@ struct AdvancedPane: View { } .formStyle(.grouped) .toggleStyle(.switch) + .scrollContentBackground(.hidden) } } diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 38a2b466f8..9649237e57 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -102,6 +102,7 @@ struct DisplayPane: View { } .formStyle(.grouped) .toggleStyle(.switch) + .scrollContentBackground(.hidden) .onAppear { self.reconcileOverviewSelection() } @@ -241,6 +242,12 @@ struct CostSummarySettingsSection: View { } CostHistoryDaysEditor(settings: self.settings) + + Toggle(isOn: self.$settings.costComparisonPeriodsEnabled) { + SettingsRowLabel( + L("cost_comparison_periods_title"), + subtitle: L("cost_comparison_periods_subtitle")) + } } } header: { Text(L("section_cost_summary")) diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index f4aaafdcc0..eee0e36a8d 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -19,10 +19,12 @@ enum AppLanguage: String, CaseIterable, Identifiable { case dutch = "nl" case turkish = "tr" case ukrainian = "uk" + case russian = "ru" case indonesian = "id" case polish = "pl" case persian = "fa" case thai = "th" + case galician = "gl" case catalan = "ca" case swedish = "sv" @@ -31,29 +33,44 @@ enum AppLanguage: String, CaseIterable, Identifiable { } var label: String { + L(self.labelKey, language: self.labelLanguage) + } + + private var labelLanguage: String { + switch self { + case .system, .english: + "en" + default: + self.rawValue + } + } + + private var labelKey: String { switch self { - case .system: L("language_system") - case .english: L("language_english") - case .chineseSimplified: L("language_chinese_simplified") - case .chineseTraditional: L("language_chinese_traditional") - case .japanese: L("language_japanese") - case .spanish: L("language_spanish") - case .portugueseBrazilian: L("language_portuguese_brazilian") - case .korean: L("language_korean") - case .german: L("language_german") - case .french: L("language_french") - case .arabic: L("language_arabic") - case .italian: L("language_italian") - case .vietnamese: L("language_vietnamese") - case .dutch: L("language_dutch") - case .turkish: L("language_turkish") - case .ukrainian: L("language_ukrainian") - case .indonesian: L("language_indonesian") - case .polish: L("language_polish") - case .persian: L("language_persian") - case .thai: L("language_thai") - case .catalan: L("language_catalan") - case .swedish: L("language_swedish") + case .system: "language_system" + case .english: "language_english" + case .chineseSimplified: "language_chinese_simplified" + case .chineseTraditional: "language_chinese_traditional" + case .japanese: "language_japanese" + case .spanish: "language_spanish" + case .portugueseBrazilian: "language_portuguese_brazilian" + case .korean: "language_korean" + case .german: "language_german" + case .french: "language_french" + case .arabic: "language_arabic" + case .italian: "language_italian" + case .vietnamese: "language_vietnamese" + case .dutch: "language_dutch" + case .turkish: "language_turkish" + case .ukrainian: "language_ukrainian" + case .russian: "language_russian" + case .indonesian: "language_indonesian" + case .polish: "language_polish" + case .persian: "language_persian" + case .thai: "language_thai" + case .galician: "language_galician" + case .catalan: "language_catalan" + case .swedish: "language_swedish" } } } @@ -65,27 +82,30 @@ struct GeneralPane: View { var body: some View { Form { Section { - Picker(selection: self.$settings.appLanguage) { - ForEach(AppLanguage.allCases) { option in - Text(option.label).tag(option.rawValue) - } - } label: { - SettingsRowLabel(L("language_title"), subtitle: L("language_subtitle")) - } + SettingsMenuPicker( + selection: self.$settings.appLanguage, + options: GeneralSettingsMenuOptions.languages, + label: { + SettingsRowLabel(L("language_title"), subtitle: L("language_subtitle")) + }, + optionLabel: { rawValue in + Text(verbatim: AppLanguage(rawValue: rawValue)?.label ?? rawValue) + }) - Picker(selection: self.$settings.terminalApp) { - ForEach(TerminalApp.pickerOptions(selected: self.settings.terminalApp)) { option in + SettingsMenuPicker( + selection: self.$settings.terminalApp, + options: GeneralSettingsMenuOptions.terminalApps(selected: self.settings.terminalApp), + label: { + SettingsRowLabel(L("terminal_app_title"), subtitle: L("terminal_app_subtitle")) + }, + optionLabel: { option in HStack(spacing: 6) { if let icon = option.pickerIcon { Image(nsImage: icon) } Text(option.label) } - .tag(option) - } - } label: { - SettingsRowLabel(L("terminal_app_title"), subtitle: L("terminal_app_subtitle")) - } + }) Toggle(L("start_at_login_title"), isOn: self.$settings.launchAtLogin) } header: { @@ -93,11 +113,11 @@ struct GeneralPane: View { } Section { - Picker(L("refresh_cadence_title"), selection: self.$settings.refreshFrequency) { - ForEach(RefreshFrequency.allCases) { option in - Text(option.label).tag(option) - } - } + SettingsMenuPicker( + selection: self.$settings.refreshFrequency, + options: GeneralSettingsMenuOptions.refreshFrequencies, + label: { Text(L("refresh_cadence_title")) }, + optionLabel: { option in Text(option.label) }) Toggle(L("refresh_on_open_title"), isOn: self.$settings.refreshAllProvidersOnMenuOpen) @@ -134,6 +154,19 @@ struct GeneralPane: View { Text(L("section_notifications")) } + Section { + Toggle("Enable Agent Sessions", isOn: self.$settings.agentSessionsEnabled) + + TextField("Manual SSH hosts", text: self.$settings.agentSessionsManualHosts) + .disabled(!self.settings.agentSessionsEnabled) + } header: { + Text("Sessions") + } footer: { + Text( + "Macs on your tailnet are discovered automatically. Local sessions refresh every " + + "30 seconds; remote hosts every 60 seconds and when the menu opens.") + } + Section { LabeledContent(L("open_menu_shortcut_title")) { OpenMenuShortcutRecorder() @@ -151,5 +184,6 @@ struct GeneralPane: View { } .formStyle(.grouped) .toggleStyle(.switch) + .scrollContentBackground(.hidden) } } diff --git a/Sources/CodexBar/PreferencesMenuPicker.swift b/Sources/CodexBar/PreferencesMenuPicker.swift new file mode 100644 index 0000000000..80ef276237 --- /dev/null +++ b/Sources/CodexBar/PreferencesMenuPicker.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// Menu-backed settings selector that avoids disabled `Picker` items on macOS 27 when built with the macOS 26 SDK. +struct SettingsMenuPicker: View { + @Binding private var selection: Value + private let options: [Value] + private let label: () -> Label + private let optionLabel: (Value) -> OptionLabel + + init( + selection: Binding, + options: [Value], + @ViewBuilder label: @escaping () -> Label, + @ViewBuilder optionLabel: @escaping (Value) -> OptionLabel) + { + self._selection = selection + self.options = options + self.label = label + self.optionLabel = optionLabel + } + + var body: some View { + LabeledContent { + Menu { + ForEach(self.options, id: \.self) { option in + Button { + self.selection = option + } label: { + HStack { + if self.selection == option { + Image(systemName: "checkmark") + } + self.optionLabel(option) + } + } + } + } label: { + self.optionLabel(self.selection) + .foregroundStyle(.primary) + } + .menuStyle(.button) + .buttonStyle(.borderless) + .fixedSize() + } label: { + self.label() + } + } +} + +enum GeneralSettingsMenuOptions { + static let languages = AppLanguage.allCases.map(\.rawValue) + static let refreshFrequencies = RefreshFrequency.allCases + + static func terminalApps(selected: TerminalApp) -> [TerminalApp] { + TerminalApp.pickerOptions(selected: selected) + } + + static func terminalApps( + selected: TerminalApp, + applicationURL: (String) -> URL?) -> [TerminalApp] + { + TerminalApp.pickerOptions(selected: selected, applicationURL: applicationURL) + } +} diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index ff84d554cf..5c5936f578 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -13,6 +13,7 @@ struct ProviderDetailView: View { @Binding var isEnabled: Bool let subtitle: String let model: UsageMenuCardView.Model + let openAIWebDiagnostic: String? let settingsPickers: [ProviderSettingsPickerDescriptor] let settingsToggles: [ProviderSettingsToggleDescriptor] let settingsFields: [ProviderSettingsFieldDescriptor] @@ -32,6 +33,7 @@ struct ProviderDetailView: View { isEnabled: Binding, subtitle: String, model: UsageMenuCardView.Model, + openAIWebDiagnostic: String?, settingsPickers: [ProviderSettingsPickerDescriptor], settingsToggles: [ProviderSettingsToggleDescriptor], settingsFields: [ProviderSettingsFieldDescriptor], @@ -50,6 +52,7 @@ struct ProviderDetailView: View { self._isEnabled = isEnabled self.subtitle = subtitle self.model = model + self.openAIWebDiagnostic = openAIWebDiagnostic self.settingsPickers = settingsPickers self.settingsToggles = settingsToggles self.settingsFields = settingsFields @@ -122,6 +125,7 @@ struct ProviderDetailView: View { ProviderMetricsInlineView( provider: self.provider, model: self.model, + openAIWebDiagnostic: self.openAIWebDiagnostic, isEnabled: self.isEnabled) } header: { Text(L("Usage")) @@ -183,6 +187,7 @@ struct ProviderDetailView: View { } } .formStyle(.grouped) + .scrollContentBackground(.hidden) } } @@ -325,16 +330,43 @@ private struct ProviderDetailInfoRow: View { struct ProviderMetricsInlineView: View { let provider: UsageProvider let model: UsageMenuCardView.Model + let openAIWebDiagnostic: String? let isEnabled: Bool + struct InfoRow: Identifiable, Equatable { + enum ID: Hashable { + case credits + case openAIWeb + } + + let id: ID + let label: String + let value: String + } + + static func infoRows( + for model: UsageMenuCardView.Model, + openAIWebDiagnostic: String?) -> [InfoRow] + { + var rows: [InfoRow] = [] + if let credits = model.creditsText { + rows.append(InfoRow(id: .credits, label: L("Credits"), value: credits)) + } + if let diagnostic = openAIWebDiagnostic { + rows.append(InfoRow(id: .openAIWeb, label: L("OpenAI web extras"), value: diagnostic)) + } + return rows + } + var body: some View { let hasMetrics = !self.model.metrics.isEmpty let hasUsageNotes = !self.model.usageNotes.isEmpty - let hasCredits = self.model.creditsText != nil + let infoRows = Self.infoRows(for: self.model, openAIWebDiagnostic: self.openAIWebDiagnostic) let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil + let hasResetCredits = self.model.codexResetCredits != nil - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, infoRows.isEmpty, !hasTokenUsage, !hasResetCredits { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -358,8 +390,12 @@ struct ProviderMetricsInlineView: View { } } - if let credits = self.model.creditsText { - ProviderDetailInfoRow(label: L("Credits"), value: credits) + ForEach(infoRows) { row in + ProviderDetailInfoRow(label: row.label, value: row.value) + } + + if let resetCredits = self.model.codexResetCredits { + ProviderCodexResetCreditsInlineRow(presentation: resetCredits) } if let providerCost = self.model.providerCost { @@ -419,7 +455,8 @@ private struct ProviderMetricInlineRow: View { accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop, - warningMarkerPercents: self.metric.warningMarkerPercents) + warningMarkerPercents: self.metric.warningMarkerPercents, + workdayMarkerPercents: self.metric.workdayMarkerPercents) .frame(maxWidth: .infinity) let hasLeftDetail = self.metric.detailLeftText?.isEmpty == false @@ -463,6 +500,37 @@ private struct ProviderMetricInlineRow: View { } } +private struct ProviderCodexResetCreditsInlineRow: View { + let presentation: CodexResetCreditsPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(L("Limit Reset Credits")) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(self.presentation.text) + .font(.footnote) + .foregroundStyle(.secondary) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(self.presentation.expirySummaryText) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityHidden(true) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.presentation.accessibilityLabel) + } +} + private struct ProviderMetricInlineTextRow: View { let title: String let value: String diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index f91aaaab7f..c6d9bef39f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -75,6 +75,10 @@ extension ProvidersPane { self.menuCardModel(for: provider) } + func _test_openAIWebDiagnostic(for provider: UsageProvider) -> String? { + self.openAIWebDiagnostic(for: provider) + } + func _test_providerErrorDisplay(for provider: UsageProvider) -> ProviderErrorDisplay? { self.providerErrorDisplay(provider) } @@ -171,6 +175,7 @@ enum ProvidersPaneTestHarness { isEnabled: enabledBinding, subtitle: "Subtitle", model: model, + openAIWebDiagnostic: pane._test_openAIWebDiagnostic(for: .codex), settingsPickers: [descriptors.picker], settingsToggles: [descriptors.toggle], settingsFields: [descriptors.fieldPlain, descriptors.fieldSecure], diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index b3c589e77a..a4a83ba41f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -47,6 +47,7 @@ struct ProvidersPane: View { isEnabled: self.binding(for: self.provider), subtitle: self.providerSubtitle(self.provider), model: self.menuCardModel(for: self.provider), + openAIWebDiagnostic: self.openAIWebDiagnostic(for: self.provider), settingsPickers: self.extraSettingsPickers(for: self.provider), settingsToggles: self.extraSettingsToggles(for: self.provider), settingsFields: self.extraSettingsFields(for: self.provider), @@ -699,6 +700,14 @@ struct ProvidersPane: View { return UsageMenuCardView.Model.make(input) } + func openAIWebDiagnostic(for provider: UsageProvider) -> String? { + guard provider == .codex else { return nil } + let diagnostic = self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .liveCard)?.userFacingErrors.dashboard + return PersonalInfoRedactor.redactEmails(in: diagnostic, isEnabled: self.settings.hidePersonalInfo) + } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { guard self.settings.quotaWarningMarkersVisible else { return [] } guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } diff --git a/Sources/CodexBar/PreferencesSidebar.swift b/Sources/CodexBar/PreferencesSidebar.swift index 98e4d379a0..0e34e95aeb 100644 --- a/Sources/CodexBar/PreferencesSidebar.swift +++ b/Sources/CodexBar/PreferencesSidebar.swift @@ -16,16 +16,18 @@ struct SettingsSidebarView: View { SettingsSidebarSearchField(searchText: self.$searchText) SettingsSidebarSortToggle(isOn: self.sortAlphabeticallyBinding) } - .padding(.horizontal, 10) - .padding(.top, 10) - .padding(.bottom, 4) + .padding(.horizontal, 8) + .padding(.top, 16) + .padding(.bottom, 8) List(selection: self.selectionBinding) { self.appPanesSection self.providersSection } .listStyle(.sidebar) + .scrollContentBackground(.hidden) } + .padding(.horizontal, 8) } private var appPanesSection: some View { diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 82a559c36f..3fe51e53c0 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -11,12 +11,12 @@ enum SettingsPane: Hashable { case debug case provider(UsageProvider) - static let windowWidth: CGFloat = 920 - static let windowHeight: CGFloat = 640 - static let windowMinWidth: CGFloat = 780 - static let windowMinHeight: CGFloat = 520 - static let sidebarWidth: CGFloat = 224 - static let sidebarMinWidth: CGFloat = 224 + static let windowWidth: CGFloat = 880 + static let windowHeight: CGFloat = 620 + static let windowMinWidth: CGFloat = 800 + static let windowMinHeight: CGFloat = 540 + static let sidebarWidth: CGFloat = 260 + static let detailMaxWidth: CGFloat = 780 var title: String { switch self { @@ -41,7 +41,6 @@ struct PreferencesView: View { let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator let runProviderLoginFlow: @MainActor (UsageProvider) async -> Void @Environment(\.colorScheme) private var colorScheme - @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn init( settings: SettingsStore, @@ -66,20 +65,37 @@ struct PreferencesView: View { } var body: some View { - NavigationSplitView(columnVisibility: self.columnVisibilityBinding) { - SettingsSidebarView(settings: self.settings, store: self.store, selection: self.$selection.pane) - .frame( - minWidth: SettingsPane.sidebarMinWidth, - idealWidth: SettingsPane.sidebarWidth, - maxWidth: SettingsPane.sidebarWidth) - .navigationSplitViewColumnWidth( - min: SettingsPane.sidebarMinWidth, - ideal: SettingsPane.sidebarWidth, - max: SettingsPane.sidebarWidth) - .toolbar(removing: .sidebarToggle) - } detail: { + HStack(spacing: 0) { + ZStack { + SettingsSidebarMaterial() + .blur(radius: 20) + self.sidebarWashColor + + SettingsSidebarView(settings: self.settings, store: self.store, selection: self.$selection.pane) + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: Color.black.opacity(0.08), radius: 3, x: 0, y: 1) + .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 6) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.22), lineWidth: 0.75)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke( + self.colorScheme == .dark ? Color.white.opacity(0.27) : Color.white.opacity(0.72), + lineWidth: 0.85)) + .frame(width: SettingsPane.sidebarWidth) + .padding(.leading, 12) + .padding(.top, 0) + .padding(.bottom, 12) + .padding(.trailing, 4) + self.detailView - .navigationTitle(self.selection.pane.title) + .frame( + maxWidth: SettingsPane.detailMaxWidth, + maxHeight: .infinity, + alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .frame( minWidth: SettingsPane.windowMinWidth, @@ -90,12 +106,11 @@ struct PreferencesView: View { maxHeight: .infinity) .id(self.settings.appLanguage) .background { - SettingsWindowAppearanceBridge(colorScheme: self.colorScheme) + SettingsWindowAppearanceBridge(colorScheme: self.colorScheme, windowTitle: self.selection.pane.title) .allowsHitTesting(false) } .onAppear { self.ensureValidSelection() - self.columnVisibility = .doubleColumn } .onChange(of: self.settings.debugMenuEnabled) { _, _ in self.ensureValidSelection() @@ -127,14 +142,10 @@ struct PreferencesView: View { } } - private var columnVisibilityBinding: Binding { - Binding( - get: { self.columnVisibility }, - set: { self.columnVisibility = Self.visibleColumnVisibility(for: $0) }) - } - - static func visibleColumnVisibility(for _: NavigationSplitViewVisibility) -> NavigationSplitViewVisibility { - .doubleColumn + private var sidebarWashColor: Color { + self.colorScheme == .dark + ? Color.black.opacity(0.60) + : Color.white.opacity(0.60) } private func ensureValidSelection() { @@ -162,24 +173,6 @@ enum SettingsWindowSizing { frame.size = repairedSize window.setFrame(frame, display: true) } - - self.enforceSidebarWidth(in: window) - } - - private static func enforceSidebarWidth(in window: NSWindow) { - // SwiftUI's split-view identifier is private and has changed across macOS releases. - // The Settings navigation split is the widest vertical two-pane split in this window. - guard let splitView = window.contentView?.descendantSplitViews - .filter({ $0.isVertical && $0.subviews.count == 2 }) - .max(by: { $0.bounds.width < $1.bounds.width }) - else { - return - } - - let sidebar = splitView.subviews[0] - guard sidebar.frame.width < SettingsPane.sidebarWidth else { return } - splitView.setPosition(SettingsPane.sidebarWidth, ofDividerAt: 0) - splitView.adjustSubviews() } } @@ -215,23 +208,17 @@ enum SettingsWindowAppearance { } } -extension NSView { - fileprivate var descendantSplitViews: [NSSplitView] { - let current = (self as? NSSplitView).map { [$0] } ?? [] - return current + self.subviews.flatMap(\.descendantSplitViews) - } -} - @MainActor struct SettingsWindowAppearanceBridge: NSViewRepresentable { let colorScheme: ColorScheme + let windowTitle: String func makeNSView(context: Context) -> SettingsWindowAppearanceView { SettingsWindowAppearanceView() } func updateNSView(_ nsView: SettingsWindowAppearanceView, context: Context) { - nsView.refreshWindowAppearance(for: self.colorScheme) + nsView.refreshWindowAppearance(for: self.colorScheme, windowTitle: self.windowTitle) } } @@ -239,6 +226,7 @@ struct SettingsWindowAppearanceBridge: NSViewRepresentable { final class SettingsWindowAppearanceView: NSView { private let scheduleReset: SettingsWindowAppearance.ResetScheduler private var colorScheme: ColorScheme? + private var windowTitle: String? init(scheduleReset: @escaping SettingsWindowAppearance.ResetScheduler = SettingsWindowAppearance.scheduleReset) { self.scheduleReset = scheduleReset @@ -250,19 +238,97 @@ final class SettingsWindowAppearanceView: NSView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + NotificationCenter.default.removeObserver(self, name: NSWindow.didUpdateNotification, object: nil) + if let window { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowDidUpdate(_:)), + name: NSWindow.didUpdateNotification, + object: window) + } + self.configureWindowStyle() self.refreshWindowAppearance() } - func refreshWindowAppearance(for colorScheme: ColorScheme) { - guard self.colorScheme != colorScheme else { return } + @objc private func windowDidUpdate(_ notification: Notification) { + self.configureWindowStyle() + } + + func refreshWindowAppearance(for colorScheme: ColorScheme, windowTitle: String? = nil) { + let colorSchemeChanged = self.colorScheme != colorScheme + let windowTitleChanged = self.windowTitle != windowTitle + guard colorSchemeChanged || windowTitleChanged else { return } self.colorScheme = colorScheme - self.refreshWindowAppearance() + self.windowTitle = windowTitle + + guard let window else { return } + self.configureWindowStyle() + if windowTitleChanged, let windowTitle { + window.title = windowTitle + } + if colorSchemeChanged { + SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset) + } } private func refreshWindowAppearance() { guard let window else { return } + self.configureWindowStyle() + if let windowTitle { + window.title = windowTitle + } SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset) } + + override func layout() { + super.layout() + self.configureWindowStyle() + } + + private func configureWindowStyle() { + guard let window else { return } + if !window.styleMask.contains(.resizable) { + window.styleMask.insert(.resizable) + } + if !window.titlebarAppearsTransparent { + window.titlebarAppearsTransparent = true + } + if window.titleVisibility != .visible { + window.titleVisibility = .visible + } + if window.titlebarSeparatorStyle != .none { + window.titlebarSeparatorStyle = .none + } + if window.toolbar != nil { + window.toolbar = nil + } + if window.styleMask.contains(.fullSizeContentView) { + window.styleMask.remove(.fullSizeContentView) + } + } +} + +@MainActor +private struct SettingsSidebarMaterial: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + self.configure(view) + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + self.configure(nsView) + } + + private func configure(_ view: NSVisualEffectView) { + view.material = .sidebar + view.blendingMode = .behindWindow + view.state = .followsWindowActiveState + } } diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift index 5fd00d90c9..d3f0e5c030 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift @@ -17,6 +17,7 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { func observeSettings(_ settings: SettingsStore) { _ = settings.alibabaTokenPlanCookieSource _ = settings.alibabaTokenPlanCookieHeader + _ = settings.alibabaTokenPlanAPIRegion } @MainActor @@ -36,27 +37,47 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { - ProviderCookieSourceUI.subtitle( + let host = context.settings.alibabaTokenPlanAPIRegion.dashboardURL.host ?? "the selected console" + return ProviderCookieSourceUI.subtitle( source: context.settings.alibabaTokenPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies from Bailian.", - manual: "Paste a Cookie header from bailian.console.aliyun.com.", + auto: "Automatic imports browser cookies from Model Studio/Bailian.", + manual: "Paste a Cookie header from \(host).", off: "Alibaba Token Plan cookies are disabled.") } + let regionBinding = Binding( + get: { context.settings.alibabaTokenPlanAPIRegion.rawValue }, + set: { raw in + context.settings.alibabaTokenPlanAPIRegion = AlibabaTokenPlanAPIRegion(rawValue: raw) ?? .international + }) + let regionOptions = AlibabaTokenPlanAPIRegion.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + return [ ProviderSettingsPickerDescriptor( id: "alibaba-token-plan-cookie-source", title: "Cookie source", - subtitle: "Automatic imports browser cookies from Bailian.", + subtitle: "Automatic imports browser cookies from Model Studio/Bailian.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { - ProviderCookieSourceUI.cachedTrailingText(provider: .alibabatokenplan) + ProviderCookieSourceUI.cachedTrailingText( + provider: .alibabatokenplan, + scope: context.settings.alibabaTokenPlanAPIRegion.cookieCacheScope) }), + ProviderSettingsPickerDescriptor( + id: "alibaba-token-plan-region", + title: "Gateway region", + subtitle: "Use international or China mainland console gateways for quota fetches.", + binding: regionBinding, + options: regionOptions, + isVisible: nil, + onChange: nil), ] } @@ -77,7 +98,9 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { style: .link, isVisible: nil, perform: { - NSWorkspace.shared.open(AlibabaTokenPlanUsageFetcher.dashboardURL) + NSWorkspace.shared.open( + AlibabaTokenPlanUsageFetcher.dashboardURL( + region: context.settings.alibabaTokenPlanAPIRegion)) }), ], isVisible: { diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift index 6f2ed187b3..b0d067c576 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift @@ -22,9 +22,22 @@ extension SettingsStore { } } + var alibabaTokenPlanAPIRegion: AlibabaTokenPlanAPIRegion { + get { + let raw = self.configSnapshot.providerConfig(for: .alibabatokenplan)?.sanitizedRegion + return AlibabaTokenPlanAPIRegion(rawValue: raw ?? "") ?? .chinaMainland + } + set { + self.updateProviderConfig(provider: .alibabatokenplan) { entry in + entry.region = newValue.rawValue + } + } + } + func alibabaTokenPlanSettingsSnapshot() -> ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings { ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( cookieSource: self.alibabaTokenPlanCookieSource, - manualCookieHeader: self.alibabaTokenPlanCookieHeader) + manualCookieHeader: self.alibabaTokenPlanCookieHeader, + apiRegion: self.alibabaTokenPlanAPIRegion) } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 80c808f67b..d79cc66ad2 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -25,6 +25,8 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudeSwapEnabled + _ = settings.claudeSwapExecutablePath } @MainActor @@ -46,6 +48,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { } } + func makeRuntime() -> (any ProviderRuntime)? { + ClaudeProviderRuntime() + } + @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { context.settings.claudeUsageDataSource.rawValue @@ -67,7 +73,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { let subtitle = if context.settings.debugDisableKeychainAccess { "Inactive while \"Disable Keychain access\" is enabled in Advanced." } else { - "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." + "Never allow Claude OAuth credential reads to show macOS Keychain prompts." } let promptFreeBinding = Binding( @@ -77,6 +83,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let claudeSwapBinding = Binding( + get: { context.settings.claudeSwapEnabled }, + set: { context.settings.claudeSwapEnabled = $0 }) + return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", @@ -90,9 +100,42 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-swap-accounts", + title: "Read accounts from claude-swap", + subtitle: "Shows usage and lets you switch accounts through `cswap`. " + + "Credentials stay managed by claude-swap; CodexBar never reads them.", + binding: claudeSwapBinding, + statusText: { Self.claudeSwapStatusText(store: context.store, settings: context.settings) }, + actions: [], + isVisible: nil, + isEnabled: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } + @MainActor + private static func claudeSwapStatusText(store: UsageStore, settings: SettingsStore) -> String? { + guard settings.claudeSwapEnabled else { return nil } + if settings.claudeSwapExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Set the cswap executable path below." + } + var parts: [String] = [] + if let version = store.claudeSwapDetectedVersion { + parts.append("claude-swap \(version)") + } + if let error = store.claudeSwapLastError { + parts.append(error) + } else if let refreshedAt = store.claudeSwapLastRefreshAt { + let accounts = store.claudeSwapAccountSnapshots.count + let accountsText = accounts == 1 ? "1 account" : "\(accounts) accounts" + parts.append("\(accountsText), updated \(refreshedAt.relativeDescription())") + } + return parts.isEmpty ? nil : parts.joined(separator: " — ") + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( @@ -141,8 +184,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { if context.settings.debugDisableKeychainAccess { return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." } - return "Controls Claude OAuth Keychain prompts when the standard reader is active. Choosing " + - "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." + return "Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." } return [ @@ -162,11 +204,11 @@ struct ClaudeProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "claude-keychain-prompt-policy", title: "Keychain prompt policy", - subtitle: "Applies only to the Security.framework OAuth keychain reader.", + subtitle: "Controls when Claude OAuth may ask macOS for Keychain access.", dynamicSubtitle: keychainPromptPolicySubtitle, binding: keychainPromptPolicyBinding, options: keychainPromptPolicyOptions, - isVisible: { context.settings.claudeOAuthKeychainReadStrategy == .securityFramework }, + isVisible: nil, isEnabled: { !context.settings.debugDisableKeychainAccess }, onChange: nil), ProviderSettingsPickerDescriptor( @@ -197,6 +239,16 @@ struct ClaudeProviderImplementation: ProviderImplementation { actions: [], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "claude-swap-executable-path", + title: "claude-swap executable", + subtitle: "Path to the cswap executable (github.com/realiti4/claude-swap).", + kind: .plain, + placeholder: "~/.local/bin/cswap", + binding: context.stringBinding(\.claudeSwapExecutablePath), + actions: [], + isVisible: { context.settings.claudeSwapEnabled }, + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift new file mode 100644 index 0000000000..bddad8ac38 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift @@ -0,0 +1,42 @@ +import CodexBarCore + +@MainActor +final class ClaudeProviderRuntime: ProviderRuntime { + let id: UsageProvider = .claude + private var lastSwapConfiguration: Configuration? + + func start(context: ProviderRuntimeContext) { + self.reconcileSwapConfiguration(context: context) + } + + func stop(context: ProviderRuntimeContext) { + self.lastSwapConfiguration = nil + context.store.clearClaudeSwapAccountState() + } + + func settingsDidChange(context: ProviderRuntimeContext) { + self.reconcileSwapConfiguration(context: context) + } + + private func reconcileSwapConfiguration(context: ProviderRuntimeContext) { + let configuration = Configuration( + providerEnabled: context.store.isEnabled(.claude), + enabled: context.settings.claudeSwapEnabled, + executablePath: context.settings.claudeSwapExecutablePath) + guard configuration != self.lastSwapConfiguration else { return } + self.lastSwapConfiguration = configuration + + // Cancel before clearing so an old executable can never repopulate the menu. + context.store.clearClaudeSwapAccountState() + guard configuration.providerEnabled, configuration.enabled, !configuration.executablePath.isEmpty else { + return + } + context.store.scheduleClaudeSwapAccountRefresh() + } + + private struct Configuration: Equatable { + let providerEnabled: Bool + let enabled: Bool + let executablePath: String + } +} diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index 0819b75033..112b8ad704 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -56,6 +56,29 @@ extension SettingsStore { self.logSecretUpdate(provider: .claude, field: "apiKey", value: newValue) } } + + var claudeSwapEnabled: Bool { + get { self.configSnapshot.providerConfig(for: .claude)?.claudeSwapEnabled ?? false } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.claudeSwapEnabled = newValue + } + self.logProviderModeChange(provider: .claude, field: "claudeSwapEnabled", value: String(newValue)) + } + } + + var claudeSwapExecutablePath: String { + get { self.configSnapshot.providerConfig(for: .claude)?.sanitizedClaudeSwapExecutablePath ?? "" } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.claudeSwapExecutablePath = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange( + provider: .claude, + field: "claudeSwapExecutablePath", + value: newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "cleared" : "set") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift new file mode 100644 index 0000000000..a2a827dc56 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation + +/// External credential transactions must run to completion; configuration changes hide their state but do not +/// cancel the subprocess halfway through a claude-swap transaction. +struct ClaudeSwapTransientState { + var lastError: String? + var lastErrorAccountID: ProviderAccountIdentity? + var switchingAccountID: ProviderAccountIdentity? + var task: Task? + var versionProbedPath: String? +} + +extension UsageStore { + /// True when the opt-in claude-swap adapter should run alongside the + /// ambient Claude refresh. Listing is read-only; explicit account activation + /// stays external-process-owned and never exposes credentials to CodexBar. + func shouldFetchClaudeSwapAccounts() -> Bool { + self.isEnabled(.claude) && self.settings.claudeSwapEnabled && + !self.settings.claudeSwapExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func clearClaudeSwapAccountState() { + let hadState = !self.claudeSwapAccountSnapshots.isEmpty || + self.claudeSwapLastRefreshAt != nil || self.claudeSwapLastError != nil || + self.claudeSwapTransientState.lastError != nil || + self.claudeSwapTransientState.lastErrorAccountID != nil || + self.claudeSwapTransientState.switchingAccountID != nil + self.claudeSwapRefreshTask?.cancel() + self.claudeSwapRefreshTask = nil + self.claudeSwapAccountSnapshots = [] + self.claudeSwapLastRefreshAt = nil + self.claudeSwapLastError = nil + self.claudeSwapTransientState.lastError = nil + self.claudeSwapTransientState.lastErrorAccountID = nil + self.claudeSwapTransientState.switchingAccountID = nil + if hadState { + self.claudeSwapRevision &+= 1 + } + } + + /// Runs the optional adapter independently so it cannot delay the ambient Claude card. + func scheduleClaudeSwapAccountRefresh(generation: UInt64? = nil) { + self.claudeSwapRefreshTask?.cancel() + guard self.shouldFetchClaudeSwapAccounts() else { + self.clearClaudeSwapAccountState() + return + } + + self.claudeSwapRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.refreshClaudeSwapAccounts(generation: generation) + } + } + + func refreshClaudeSwapAccounts(generation: UInt64? = nil) async { + let executablePath = self.settings.claudeSwapExecutablePath + await self.probeClaudeSwapVersionIfNeeded(executablePath: executablePath) + + do { + let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath) + let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list) + guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { + return + } + self.claudeSwapAccountSnapshots = snapshots + self.claudeSwapLastRefreshAt = Date() + self.claudeSwapLastError = nil + self.claudeSwapRevision &+= 1 + } catch is CancellationError { + return + } catch { + guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { + return + } + // Retain the last successful snapshots as stale data; the settings + // pane surfaces the adapter error and last refresh time. + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + if self.claudeSwapLastError != message { + self.claudeSwapLastError = message + self.claudeSwapRevision &+= 1 + } + } + } + + /// Activates one account through the configured claude-swap executable. + /// The numeric slot comes from the already validated list payload; requests + /// are serialized so two credential transactions can never overlap. + func switchClaudeSwapAccount(_ accountID: ProviderAccountIdentity) { + guard self.claudeSwapTransientState.task == nil, + self.shouldFetchClaudeSwapAccounts(), + accountID.source == ClaudeSwapAccountProjection.sourceName, + let account = self.claudeSwapAccountSnapshots.first(where: { $0.id == accountID }), + account.canActivate, + let accountNumber = Int(accountID.opaqueID), + accountNumber > 0 + else { + return + } + + let executablePath = self.settings.claudeSwapExecutablePath + self.claudeSwapTransientState.switchingAccountID = accountID + self.claudeSwapTransientState.lastError = nil + self.claudeSwapTransientState.lastErrorAccountID = nil + self.claudeSwapRevision &+= 1 + + self.claudeSwapTransientState.task = Task { @MainActor [weak self] in + var switchError: String? + do { + _ = try await ClaudeSwapAccountReader.switchAccount( + executablePath: executablePath, + accountNumber: accountNumber) + } catch { + switchError = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + + guard let self else { return } + if self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) { + // Claude Code owns the ambient credential, so reconcile both + // the provider snapshot and the adapter's active-row marker. + await self.refreshProvider(.claude) + } + let configurationIsCurrent = self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) + self.claudeSwapTransientState.task = nil + self.claudeSwapTransientState.switchingAccountID = nil + if configurationIsCurrent { + self.claudeSwapTransientState.lastError = switchError + self.claudeSwapTransientState.lastErrorAccountID = switchError == nil ? nil : accountID + } + self.claudeSwapRevision &+= 1 + } + } + + private func probeClaudeSwapVersionIfNeeded(executablePath: String) async { + guard self.claudeSwapTransientState.versionProbedPath != executablePath else { return } + let version = await ClaudeSwapAccountReader.readVersion(executablePath: executablePath) + guard self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) else { return } + self.claudeSwapTransientState.versionProbedPath = executablePath + self.claudeSwapDetectedVersion = version + } + + private func isCurrentClaudeSwapRefresh(executablePath: String, generation: UInt64?) -> Bool { + self.isCurrentProviderRefreshGeneration(.claude, generation: generation) && + self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) + } + + private func isCurrentClaudeSwapConfiguration(executablePath: String) -> Bool { + self.isEnabled(.claude) && self.settings.claudeSwapEnabled && + self.settings.claudeSwapExecutablePath == executablePath + } +} diff --git a/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift b/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift new file mode 100644 index 0000000000..bd164220a2 --- /dev/null +++ b/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation + +struct ClawRouterProviderImplementation: ProviderImplementation { + let id: UsageProvider = .clawrouter + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.clawRouterAPIKey + _ = settings.clawRouterBaseURL + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + ProviderTokenResolver.clawRouterToken(environment: context.environment) != nil + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "clawrouter-api-key", + title: "API key", + subtitle: "Stored in the CodexBar config file. Reads monthly budget and routed usage from /v1/usage.", + kind: .secure, + placeholder: "ClawRouter key…", + binding: context.stringBinding(\.clawRouterAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "clawrouter-base-url", + title: "Base URL", + subtitle: "Optional. Defaults to the hosted ClawRouter service.", + kind: .plain, + placeholder: ClawRouterSettingsReader.defaultBaseURL.absoluteString, + binding: context.stringBinding(\.clawRouterBaseURL), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift b/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift new file mode 100644 index 0000000000..bfbe412447 --- /dev/null +++ b/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var clawRouterAPIKey: String { + get { self.configSnapshot.providerConfig(for: .clawrouter)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .clawrouter) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .clawrouter, field: "apiKey", value: newValue) + } + } + + var clawRouterBaseURL: String { + get { self.configSnapshot.providerConfig(for: .clawrouter)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .clawrouter) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 527b836826..ff2429c78d 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -238,6 +238,7 @@ struct CodexConsumerProjection { private let rateWindowsByLane: [RateLane: RateWindow] private let codeReviewRemainingPercent: Double? private let codeReviewLimit: RateWindow? + private let evaluationTime: Date static func make(surface: Surface, context: Context) -> CodexConsumerProjection { let allowsLiveAdjuncts = surface != .overrideCard @@ -290,20 +291,64 @@ struct CodexConsumerProjection { credits: creditsProjection, menuBarFallback: self.menuBarFallback( creditsRemaining: creditsProjection?.remaining, - rateWindowsByLane: rateWindowsByLane), + rateWindowsByLane: rateWindowsByLane, + evaluationTime: context.now), userFacingErrors: userFacingErrors, canShowBuyCredits: canShowBuyCredits, hasUsageBreakdown: hasUsageBreakdown, hasCreditsHistory: hasCreditsHistory, rateWindowsByLane: rateWindowsByLane, codeReviewRemainingPercent: dashboardVisibility == .attached ? dashboard?.codeReviewRemainingPercent : nil, - codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil) + codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil, + evaluationTime: context.now) } func rateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindowsByLane[lane] else { return nil } + switch lane { + case .session: + return Self.sessionDisplayWindow( + session: window, + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + case .weekly: + return window + } + } + + func sourceRateWindow(for lane: RateLane) -> RateWindow? { self.rateWindowsByLane[lane] } + func menuBarSelectableRateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindow(for: lane) else { return nil } + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt <= self.evaluationTime + else { + return window + } + return nil + } + + var nextMenuBarStateChangeAt: Date? { + self.rateWindowsByLane.values.compactMap { window in + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt > self.evaluationTime + else { + return nil + } + return resetAt + }.min() + } + + var hasBindingWeeklyCap: Bool { + Self.weeklyCapsSession( + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + } + func remainingPercent(for metric: SupplementalMetric) -> Double? { switch metric { case .codeReview: @@ -399,18 +444,72 @@ struct CodexConsumerProjection { return (lane, window) } + /// When Codex's weekly lane is exhausted, it is the binding cap: session quota cannot be used until + /// the weekly window resets, even if the API still reports room in the 5-hour bucket. + private static func weeklyCapsSession(weekly: RateWindow?, evaluationTime: Date) -> Bool { + guard let weekly else { return false } + guard weekly.remainingPercent <= 0 else { return false } + return weekly.resetsAt.map { $0 > evaluationTime } ?? true + } + + private static func sessionDisplayWindow( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> RateWindow + { + guard self.weeklyCapsSession(weekly: weekly, evaluationTime: evaluationTime) else { + return session + } + let reset = self.bindingReset( + session: session, + weekly: weekly, + evaluationTime: evaluationTime) + return RateWindow( + usedPercent: max(session.usedPercent, 100), + windowMinutes: session.windowMinutes, + resetsAt: reset.date, + resetDescription: reset.description, + nextRegenPercent: session.nextRegenPercent, + isSyntheticPlaceholder: session.isSyntheticPlaceholder) + } + + private static func bindingReset( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> (date: Date?, description: String?) + { + guard let weekly else { return (nil, nil) } + let sessionIsExhausted = session.remainingPercent <= 0 && + (session.resetsAt.map { $0 > evaluationTime } ?? true) + guard sessionIsExhausted else { + return (weekly.resetsAt, weekly.resetDescription) + } + guard let sessionReset = session.resetsAt, let weeklyReset = weekly.resetsAt else { + return (nil, nil) + } + if sessionReset > weeklyReset { + return (sessionReset, session.resetDescription) + } + return (weeklyReset, weekly.resetDescription) + } + private static func menuBarFallback( creditsRemaining: Double?, - rateWindowsByLane: [RateLane: RateWindow]) -> MenuBarFallback + rateWindowsByLane: [RateLane: RateWindow], + evaluationTime: Date) -> MenuBarFallback { guard let creditsRemaining, creditsRemaining > 0 else { return .none } - let hasExhaustedLane = rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + let hasExhaustedLane = rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > evaluationTime } ?? true) + } let hasNoRateWindows = rateWindowsByLane.isEmpty return (hasExhaustedLane || hasNoRateWindows) ? .creditsBalance : .none } var hasExhaustedRateLane: Bool { - self.rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + self.rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true) + } } } @@ -459,4 +558,37 @@ extension UsageStore { guard projection.menuBarFallback == .creditsBalance else { return nil } return projection.credits?.remaining } + + func codexMenuBarMetricWindow(snapshot: UsageSnapshot, now: Date = Date()) -> RateWindow? { + let projection = self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + let windows = projection.visibleRateLanes.compactMap { + projection.menuBarSelectableRateWindow(for: $0) + } + let first = windows.first + let second = windows.dropFirst().first + + switch self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) { + case .secondary, .tertiary: + return second ?? first + case .extraUsage: + return first + case .average: + guard self.settings.menuBarMetricSupportsAverage(for: .codex), + let primary = first, + let secondary = second + else { + return first + } + let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 + return RateWindow( + usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + case .primaryAndSecondary: + return windows.prefix(2).max(by: { $0.usedPercent < $1.usedPercent }) + case .automatic, .primary, .monthlyPlan: + return first + } + } } diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift index fd667dfd0a..3d2ae23419 100644 --- a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -118,4 +118,15 @@ struct DevinProviderImplementation: ProviderImplementation { } return URL(string: urlString) ?? URL(string: "https://app.devin.ai")! } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard context.settings.showOptionalCreditsAndExtraUsage, + let cost = context.snapshot?.providerCost, + cost.period == "Extra usage balance" + else { return } + + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + entries.append(.text(L("Extra usage balance: %@", balance), .primary)) + } } diff --git a/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift b/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift index b9caa5144a..ecbfb94d40 100644 --- a/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift @@ -1,13 +1,45 @@ import CodexBarCore import Foundation +import SwiftUI struct GeminiProviderImplementation: ProviderImplementation { let id: UsageProvider = .gemini let supportsLoginFlow: Bool = true + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + guard Self.showsAntigravityMigrationAction(context: context) else { return [] } + return [ + ProviderSettingsActionsDescriptor( + id: "gemini-antigravity-migration", + title: "Gemini CLI migration", + subtitle: GeminiConsumerTierMigration.deprecationError, + actions: [ + ProviderSettingsActionDescriptor( + id: "gemini-enable-antigravity", + title: "Enable Antigravity provider", + style: .bordered, + isVisible: nil, + perform: { + context.settings.setProviderEnabled( + provider: .antigravity, + metadata: ProviderDescriptorRegistry.descriptor(for: .antigravity).metadata, + enabled: true) + await context.store.refreshProvider(.antigravity, allowDisabled: true) + }), + ], + isVisible: nil), + ] + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runGeminiLoginFlow() return false } + + @MainActor + private static func showsAntigravityMigrationAction(context: ProviderSettingsContext) -> Bool { + context.store.geminiObservedConsumerTierDeprecation + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift index 9088ff8408..f86c260b1c 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift @@ -5,8 +5,8 @@ enum ProviderCookieSourceUI { "Keychain access is disabled in Advanced, so browser cookie import is unavailable." @MainActor - static func cachedTrailingText(provider: UsageProvider) -> String? { - guard let entry = CookieHeaderCache.loadForDisplay(provider: provider) else { return nil } + static func cachedTrailingText(provider: UsageProvider, scope: CookieHeaderCache.Scope? = nil) -> String? { + guard let entry = CookieHeaderCache.loadForDisplay(provider: provider, scope: scope) else { return nil } return self.cachedTrailingText(entry: entry) } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 4433bef1b6..7a0d5faa8a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -69,6 +69,7 @@ enum ProviderImplementationRegistry { case .poe: PoeProviderImplementation() case .chutes: ChutesProviderImplementation() case .crossmodel: CrossModelProviderImplementation() + case .clawrouter: ClawRouterProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg new file mode 100644 index 0000000000..f8718f87e9 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-poe.svg b/Sources/CodexBar/Resources/ProviderIcon-poe.svg index e45e85cd5e..5e654565f3 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-poe.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-poe.svg @@ -1 +1 @@ -Poe \ No newline at end of file +Poe diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c432223f13..788e792f41 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_swedish" = "السويديا"; "language_french" = "الفرنسية"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "ياباني"; "language_korean" = "الكورية"; "language_turkish" = "تركجه"; @@ -430,6 +431,8 @@ "cost_history_window_title" = "نافذة التاريخ"; "cost_history_window_help" = "يحدد عدد أيام سجلات الاستخدام المحلية التي تظهر في القائمة."; "cost_history_days_title" = "نافذة التاريخ: %d أيام"; +"cost_comparison_periods_title" = "إظهار فترات مقارنة أقصر"; +"cost_comparison_periods_subtitle" = "أضف إجماليات 7 و30 و90 يومًا عندما تقع ضمن نافذة التاريخ المحددة. تعيد هذه الإجماليات استخدام الفحص المحلي نفسه."; "cost_auto_refresh_info" = "تحديث تلقائي: كل ساعة · وقت الاستراحة: 10m"; "refresh_cadence_title" = "وتيرة التحديث"; "refresh_cadence_subtitle" = "كم مرة CodexBar استطلاعات في الخلفية."; @@ -1124,10 +1127,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "أرصدة إعادة تعيين الحد"; "1 available" = "1 متاح"; "%d available" = "%d متاح"; "Next expires %@" = "تنتهي صلاحية التالية %@"; +"Expires %@" = "تنتهي الصلاحية %@"; +"No expiry" = "لا انتهاء صلاحية"; "byte_unit_byte" = "بايت"; "byte_unit_bytes" = "بايتات"; "byte_unit_kilobyte" = "كيلوبايت"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index be79824538..54770860a1 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -407,6 +407,7 @@ "language_german" = "Deutsch"; "language_french" = "Francès"; "language_ukrainian" = "Ucraïnès"; +"language_russian" = "Русский"; "language_japanese" = "Japonès"; "language_korean" = "Coreà"; "language_italian" = "Italiano"; @@ -426,6 +427,8 @@ "cost_history_window_help" = "Defineix quants dies de registres d'ús locals apareixen al menú."; "cost_history_days_title" = "Finestra d'historial: %d dies"; "cost_auto_refresh_info" = "Actualització automàtica: cada hora · Temps d'espera: 10 min"; +"cost_comparison_periods_title" = "Mostra períodes de comparació més curts"; +"cost_comparison_periods_subtitle" = "Afegeix totals de 7, 30 i 90 dies quan càpiguen dins l'interval d'historial seleccionat. Aquests totals reutilitzen la mateixa exploració local."; "refresh_cadence_title" = "Freqüència d'actualització"; "refresh_cadence_subtitle" = "Amb quina freqüència el CodexBar consulta els proveïdors en segon pla."; "manual_refresh_hint" = "L'actualització automàtica està desactivada; feu servir l'ordre Actualitza del menú."; @@ -968,10 +971,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Crèdits de restabliment del límit"; "1 available" = "1 disponible"; "%d available" = "%d disponibles"; "Next expires %@" = "El següent caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sense caducitat"; "Other (%d items)" = "Altres (%d elements)"; "Expand" = "Amplia"; "Collapse" = "Redueix"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index db947e66c3..1750dbb461 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -407,6 +407,7 @@ "language_dutch" = "Niederländisch"; "language_french" = "Französisch"; "language_ukrainian" = "Ukrainisch"; +"language_russian" = "Русский"; "language_vietnamese" = "Vietnamesisch"; "language_korean" = "Koreanisch"; "language_turkish" = "Türkçe"; @@ -428,6 +429,8 @@ "cost_history_window_help" = "Legt fest, wie viele Tage lokaler Nutzungsprotokolle im Menü erscheinen."; "cost_history_days_title" = "Verlaufsfenster: %d Tage"; "cost_auto_refresh_info" = "Auto-Aktualisierung: stündlich · Timeout: 10 Min."; +"cost_comparison_periods_title" = "Kürzere Vergleichszeiträume anzeigen"; +"cost_comparison_periods_subtitle" = "Fügt Summen für 7, 30 und 90 Tage hinzu, wenn sie in den ausgewählten Verlaufszeitraum passen. Diese Summen verwenden denselben lokalen Scan."; "refresh_cadence_title" = "Aktualisierungsintervall"; "refresh_cadence_subtitle" = "Wie oft CodexBar Anbieter im Hintergrund abfragt."; "manual_refresh_hint" = "Auto-Aktualisierung ist aus; nutze im Menü den Befehl „Aktualisieren“."; @@ -1114,10 +1117,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Credits zum Zurücksetzen des Limits"; "1 available" = "1 verfügbar"; "%d available" = "%d verfügbar"; "Next expires %@" = "Nächster Ablauf %@"; +"Expires %@" = "Läuft %@ ab"; +"No expiry" = "Kein Ablaufdatum"; "Other (%d items)" = "Andere (%d Elemente)"; "Expand" = "Aufklappen"; "Collapse" = "Zuklappen"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 9f775253ba..4b5b472f22 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_swedish" = "Svenska"; "language_french" = "French"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "Japanese"; "language_korean" = "Korean"; "language_turkish" = "Türkçe"; @@ -430,6 +431,8 @@ "cost_history_window_title" = "History window"; "cost_history_window_help" = "Sets how many days of local usage logs appear in the menu."; "cost_history_days_title" = "History window: %d days"; +"cost_comparison_periods_title" = "Show shorter comparison periods"; +"cost_comparison_periods_subtitle" = "Add 7, 30, and 90-day totals when they fit inside the selected history window. These totals reuse the same local scan."; "cost_auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m"; "refresh_cadence_title" = "Refresh cadence"; "refresh_cadence_subtitle" = "How often CodexBar polls providers in the background."; @@ -498,7 +501,7 @@ "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_mistral" = "Choose Mistral API spend or Monthly Plan usage for 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)"; @@ -1125,10 +1128,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Limit Reset Credits"; "1 available" = "1 available"; "%d available" = "%d available"; "Next expires %@" = "Next expires %@"; +"Expires %@" = "Expires %@"; +"No expiry" = "No expiry"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 68810e6c06..6df1177e20 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -412,6 +412,7 @@ "language_german" = "Deutsch"; "language_french" = "Francés"; "language_ukrainian" = "Ucraniano"; +"language_russian" = "Русский"; "language_japanese" = "Japonés"; "language_korean" = "Coreano"; "language_italian" = "Italiano"; @@ -431,6 +432,8 @@ "cost_history_window_help" = "Define cuántos días de registros de uso locales aparecen 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"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación más cortos"; +"cost_comparison_periods_subtitle" = "Añade totales de 7, 30 y 90 días cuando quepan en el intervalo de historial seleccionado. Estos totales reutilizan el mismo análisis local."; "refresh_cadence_title" = "Frecuencia de actualización"; "refresh_cadence_subtitle" = "Con qué frecuencia CodexBar consulta a los proveedores en segundo plano."; "manual_refresh_hint" = "La actualización automática está desactivada; usa el comando Actualizar del menú."; @@ -974,10 +977,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Créditos para restablecer límites"; "1 available" = "1 disponible"; "%d available" = "%d disponibles"; "Next expires %@" = "El siguiente caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sin caducidad"; "Other (%d items)" = "Otros (%d elementos)"; "Expand" = "Expandir"; "Collapse" = "Contraer"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 0a21f04ce9..e62f320240 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_swedish" = "سوئنسکا"; "language_french" = "فرانسوی"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "ژاپنی"; "language_korean" = "کره ای"; "language_turkish" = "ترک چه"; @@ -430,6 +431,8 @@ "cost_history_window_title" = "پنجره تاریخچه"; "cost_history_window_help" = "تعیین می‌کند چند روز از گزارش‌های استفاده محلی در منو نشان داده شود."; "cost_history_days_title" = "پنجره تاریخچه: %d روز"; +"cost_comparison_periods_title" = "نمایش دوره‌های مقایسه کوتاه‌تر"; +"cost_comparison_periods_subtitle" = "وقتی در پنجره تاریخچه انتخاب‌شده جا می‌گیرند، مجموع‌های ۷، ۳۰ و ۹۰ روزه را اضافه کنید. این مجموع‌ها از همان اسکن محلی استفاده می‌کنند."; "cost_auto_refresh_info" = "تازه سازی خودکار: ساعتی · زمان استراحت: 10m"; "refresh_cadence_title" = "کادانس تازه سازی"; "refresh_cadence_subtitle" = "چند وقت یکبار CodexBar ارائه دهندگان نظرسنجی در پس زمینه انجام می دهند."; @@ -1124,10 +1127,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "اعتبارهای بازنشانی محدودیت"; "1 available" = "۱ مورد موجود"; "%d available" = "%d مورد موجود"; "Next expires %@" = "مورد بعدی در %@ منقضی می‌شود"; +"Expires %@" = "انقضا %@"; +"No expiry" = "بدون انقضا"; "byte_unit_byte" = "بایت"; "byte_unit_bytes" = "بایت"; "byte_unit_kilobyte" = "کیلوبایت"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 72b62eddcf..5dfecb3073 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -408,6 +408,7 @@ "language_french" = "Français"; "language_dutch" = "Néerlandais"; "language_ukrainian" = "Ukrainien"; +"language_russian" = "Русский"; "language_japanese" = "Japonais"; "language_korean" = "Coréen"; "language_italian" = "Italiano"; @@ -430,6 +431,8 @@ "cost_history_window_help" = "Définit le nombre de jours de journaux d'utilisation locaux affichés 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"; +"cost_comparison_periods_title" = "Afficher des périodes de comparaison plus courtes"; +"cost_comparison_periods_subtitle" = "Ajoute les totaux sur 7, 30 et 90 jours lorsqu'ils tiennent dans la période d'historique sélectionnée. Ces totaux réutilisent la même analyse locale."; "refresh_cadence_title" = "Fréquence d'actualisation"; "refresh_cadence_subtitle" = "Définit la fréquence à laquelle CodexBar interroge les fournisseurs en arrière-plan."; "manual_refresh_hint" = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; @@ -1114,10 +1117,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Crédits de réinitialisation de limite"; "1 available" = "1 disponible"; "%d available" = "%d disponibles"; "Next expires %@" = "Prochaine expiration %@"; +"Expires %@" = "Expire %@"; +"No expiry" = "Sans expiration"; "Other (%d items)" = "Autres (%d éléments)"; "Expand" = "Développer"; "Collapse" = "Réduire"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings new file mode 100644 index 0000000000..c716d497ee --- /dev/null +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -0,0 +1,1151 @@ +/* Galician localization for CodexBar */ + +" providers" = " provedores"; +"(System)" = "(Sistema)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir "; +"API key" = "Chave de API"; +"API region" = "Rexión da API"; +"API token" = "Token de API"; +"API tokens" = "Tokens de API"; +"About" = "Acerca de"; +"Account" = "Conta"; +"Accounts" = "Contas"; +"Accounts subtitle" = "Subtítulo de contas"; +"Active" = "Activo"; +"Add" = "Engadir"; +"Add Workspace" = "Engadir espazo de traballo"; +"Advanced" = "Avanzado"; +"All" = "Todo"; +"Always allow prompts" = "Permitir sempre as solicitudes"; +"Animation pattern" = "Patrón de animación"; +"Antigravity login is managed in the app" = "O inicio de sesión de Antigravity xestiónase na aplicación"; +"Applies only to the Security.framework OAuth keychain reader." = "Só se aplica ao lector de Chaveiro OAuth de Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Recorre automaticamente á seguinte fonte se a preferida falla."; +"Auto uses API first, then falls back to CLI on auth failures." = "Usa automaticamente a API primeiro e recorre á CLI se falla a autenticación."; +"Auto-detect" = "Detección automática"; +"Auto-refresh is off; use the menu's Refresh command." = "A actualización automática está desactivada; usa a orde Actualizar do menú."; +"Auto-refresh: hourly · Timeout: 10m" = "Actualización automática: cada hora · Tempo de espera: 10 m"; +"Automatic" = "Automático"; +"Automatic imports browser cookies and WorkOS tokens." = "O modo automático importa cookies do navegador e tokens de WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "O modo automático importa cookies do navegador e tokens de almacenamento local."; +"Automatic imports browser cookies for dashboard extras." = "O modo automático importa cookies do navegador para os extras do panel."; +"Automatic imports browser cookies for the web API." = "O modo automático importa cookies do navegador para a API web."; +"Automatic imports browser cookies from Model Studio/Bailian." = "O modo automático importa cookies do navegador desde Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "O modo automático importa cookies do navegador desde admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "O modo automático importa cookies do navegador desde opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "O modo automático importa cookies do navegador ou sesións gardadas."; +"Automatic imports browser cookies." = "O modo automático importa cookies do navegador."; +"Automatically imports browser session cookie." = "Importa automaticamente a cookie de sesión do navegador."; +"Automatically opens CodexBar when you start your Mac." = "Abre CodexBar automaticamente ao iniciar o teu Mac."; +"Automation" = "Automatización"; +"Average (\\(label1) + \\(label2))" = "Media (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Media (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Evitar as solicitudes do Chaveiro"; +"Balance" = "Saldo"; +"Battery Saver" = "Aforro de batería"; +"Bordered" = "Con bordo"; +"Build" = "Compilación"; +"Built \\(buildTimestamp)" = "Compilado o \\(buildTimestamp)"; +"Buy Credits..." = "Mercar créditos..."; +"Buy Credits…" = "Mercar créditos…"; +"CLI paths" = "Rutas da CLI"; +"CLI sessions" = "Sesións da CLI"; +"Caches" = "Cachés"; +"Cancel" = "Cancelar"; +"Check for Updates…" = "Buscar actualizacións…"; +"Check for updates automatically" = "Buscar actualizacións automaticamente"; +"Check if you like your agents having some fun up there." = "Actívao se che gusta que os teus axentes se divirtan aí arriba."; +"Check provider status" = "Comprobar o estado do provedor"; +"Choose Codex workspace" = "Escoller espazo de traballo de Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Escolle o servidor de MiniMax (global .io ou China continental .com)."; +"Choose up to " = "Escolle ata "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Escolle ata \\(Self.maxOverviewProviders) provedores"; +"Choose up to \\(count) providers" = "Escolle ata \\(count) provedores"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Escolle que amosar na barra de menús (Ritmo amosa o uso fronte ao previsto)."; +"Choose which Codex account CodexBar should follow." = "Escolle que conta de Codex debe seguir CodexBar."; +"Choose which window drives the menu bar percent." = "Escolle que xanela determina a porcentaxe da barra de menús."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Non se atopou a CLI de Claude"; +"Claude binary" = "Binario de Claude"; +"Claude cookies" = "Cookies de Claude"; +"Claude login failed" = "O inicio de sesión de Claude fallou"; +"Claude login timed out" = "O inicio de sesión de Claude esgotou o tempo de espera"; +"Close" = "Pechar"; +"Codex CLI not found" = "Non se atopou a CLI de Codex"; +"Codex account login already running" = "O inicio de sesión da conta de Codex xa está en curso"; +"Codex binary" = "Binario de Codex"; +"Codex login failed" = "O inicio de sesión de Codex fallou"; +"Codex login timed out" = "O inicio de sesión de Codex esgotou o tempo de espera"; +"CodexBar Lifecycle Keepalive" = "Mantemento do ciclo de vida de CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar de menús non pode amosar a súa icona"; +"CodexBar could not read managed account storage. " = "CodexBar de menús non puido ler o almacenamento de contas xestionadas. "; +"Configure…" = "Configurar…"; +"Connected" = "Conectado"; +"Controls how much detail is logged." = "Controla canto detalle se rexistra."; +"Cookie header" = "Cabeceira de cookie"; +"Cookie source" = "Orixe da cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nou pega unha captura de cURL do panel de Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nou pega o valor de __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nou pega o valor do token kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Custo"; +"Could not add Codex account" = "Non se puido engadir a conta de Codex"; +"Could not open Terminal for Gemini" = "Non se puido abrir o Terminal para Gemini"; +"Could not start claude /login" = "Non se puido iniciar claude /login"; +"Could not start codex login" = "Non se puido iniciar codex login"; +"Could not switch system account" = "Non se puido cambiar a conta do sistema"; +"Credits" = "Créditos"; +"Individual credits" = "Créditos individuais"; +"Workspace" = "Espazo de traballo"; +"Credits history" = "Historial de créditos"; +"Cursor login failed" = "O inicio de sesión de Cursor fallou"; +"Custom" = "Personalizado"; +"Custom Path" = "Ruta personalizada"; +"Daily Routines" = "Rutinas diarias"; +"Debug" = "Depuración"; +"Default" = "Por defecto"; +"Disable Keychain access" = "Desactivar o acceso ao Chaveiro"; +"Disabled" = "Desactivado"; +"Dismiss" = "Descartar"; +"Disconnected" = "Desconectado"; +"Display" = "Pantalla"; +"Display mode" = "Modo de visualización"; +"Display reset times as absolute clock values instead of countdowns." = "Amosa as horas de reinicio como valores de reloxo absolutos en vez de contas atrás."; +"Done" = "Feito"; +"Effective PATH" = "PATH efectivo"; +"Email" = "Correo electrónico"; +"Enable Merge Icons to configure Overview tab providers." = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; +"Enable file logging" = "Activar o rexistro en ficheiro"; +"Enabled" = "Activado"; +"Error" = "Erro"; +"Error simulation" = "Simulación de erros"; +"Expose troubleshooting tools in the Debug tab." = "Amosar ferramentas de diagnose na lapela Depuración."; +"Failed" = "Fallou"; +"False" = "Falso"; +"Fetch strategy attempts" = "Intentos de estratexia de obtención"; +"Fetching" = "Obtendo"; +"Field" = "Campo"; +"Field subtitle" = "Subtítulo do campo"; +"Finish the current managed account change before switching the system account." = "Remata o cambio de conta xestionada actual antes de cambiar a conta do sistema."; +"Force animation on next refresh" = "Forzar a animación na seguinte actualización"; +"Gateway region" = "Rexión da pasarela"; +"Gemini CLI not found" = "Non se atopou a CLI de Gemini"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, amosando incidencias na icona e no menú."; +"General" = "Xeral"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Inicio de sesión de GitHub Copilot"; +"GitHub Login" = "Inicio de sesión de GitHub"; +"Hide details" = "Ocultar os detalles"; +"Hide personal information" = "Ocultar a información persoal"; +"Historical tracking" = "Seguimento histórico"; +"How often CodexBar polls providers in the background." = "Con que frecuencia CodexBar consulta os provedores en segundo plano."; +"Inactive" = "Inactivo"; +"Install CLI" = "Instalar a CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instala a CLI de Claude (npm i -g @anthropic-ai/claude-code) e téntao de novo."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instala a CLI de Codex (npm i -g @openai/codex) e téntao de novo."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instala a CLI de Gemini (npm i -g @google/gemini-cli) e téntao de novo."; +"JetBrains AI is ready" = "JetBrains AI está listo"; +"JetBrains IDE" = "IDE de JetBrains"; +"Keep CLI sessions alive" = "Manter activas as sesións de CLI"; +"Keyboard shortcut" = "Atallo de teclado"; +"Keychain access" = "Acceso ao Chaveiro"; +"Keychain prompt policy" = "Política de solicitudes do Chaveiro"; +"Last \\(name) fetch failed:" = "A última obtención de \\(name) fallou:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "A última obtención de \\(self.store.metadata(for: self.provider).displayName) fallou:"; +"Last attempt" = "Último intento"; +"Link" = "Enlace"; +"Loading animations" = "Animacións de carga"; +"Loading…" = "Cargando…"; +"Local" = "Local"; +"Logging" = "Rexistro"; +"Login failed" = "O inicio de sesión fallou"; +"Login shell PATH (startup capture)" = "PATH do shell de inicio de sesión (captura no arranque)"; +"Login timed out" = "O inicio de sesión esgotou o tempo de espera"; +"MCP details" = "Detalles do MCP"; +"Managed Codex accounts unavailable" = "Contas xestionadas de Codex non dispoñibles"; +"Managed account storage is unreadable. Live account access is still available, " = "O almacenamento de contas xestionadas non se pode ler. O acceso ás contas activas aínda está dispoñible, "; +"Manual" = "Manual"; +"May your tokens never run out—keep agent limits in view." = "Que os teus tokens nunca se esgoten: mantén á vista os límites dos teus axentes."; +"Menu bar" = "Barra de menús"; +"Menu bar auto-shows the provider closest to its rate limit." = "A barra de menús amosa automaticamente o provedor máis próximo ao seu límite."; +"Menu bar metric" = "Métrica da barra de menús"; +"Menu bar shows percent" = "A barra de menús amosa a porcentaxe"; +"Menu content" = "Contido do menú"; +"Merge Icons" = "Combinar iconas"; +"Never prompt" = "Non preguntar nunca"; +"No" = "Non"; +"No Codex accounts detected yet." = "Aínda non se detectaron contas de Codex."; +"No JetBrains IDE detected" = "Non se detectou ningún IDE de JetBrains"; +"No cost history data." = "Non hai datos de historial de custo."; +"No credits history data." = "Non hai datos de historial de créditos."; +"No data available" = "Non hai datos dispoñibles"; +"No data yet" = "Aínda non hai datos"; +"No enabled providers available for Overview." = "Non hai provedores activados dispoñibles para o Resumo."; +"No providers selected" = "Non se seleccionou ningún provedor"; +"No token accounts yet." = "Aínda non hai contas con token."; +"No usage breakdown data." = "Non hai datos de desglose de uso."; +"None" = "Ningún"; +"Notifications" = "Notificacións"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa cando a cota de sesión de 5 horas chega ao 0% e cando volve estar "; +"OK" = "Aceptar"; +"Obscure email addresses in the menu bar and menu UI." = "Ocultar os enderezos de correo na barra de menús e na interface do menú."; +"Off" = "Desactivado"; +"Offline" = "Sen conexión"; +"On" = "Activado"; +"Online" = "En liña"; +"Only on user action" = "Só en accións do usuario"; +"Open" = "Abrir"; +"Open API Keys" = "Abrir as chaves de API"; +"Open Amp Settings" = "Abrir os axustes de Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Abre Antigravity para iniciar sesión e logo actualiza CodexBar."; +"Open Browser" = "Abrir o navegador"; +"Open Coding Plan" = "Abrir o plan de programación"; +"Open Console" = "Abrir a Consola"; +"Open Dashboard" = "Abrir o panel"; +"Open Mistral Admin" = "Abrir a administración de Mistral"; +"Open Menu Bar Settings" = "Abrir os axustes da barra de menús"; +"Open Ollama Settings" = "Abrir os axustes de Ollama"; +"Open Terminal" = "Abrir o Terminal"; +"Open Usage Page" = "Abrir a páxina de uso"; +"Open Warp API Key Guide" = "Abrir a guía da chave de API de Warp"; +"Open menu" = "Abrir o menú"; +"Open token file" = "Abrir o ficheiro 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." = "Substitución opcional se falla a busca do espazo de traballo."; +"Options" = "Opcións"; +"Override auto-detection with a custom IDE base path" = "Substituír a detección automática por unha ruta base de IDE personalizada"; +"Overview" = "Resumo"; +"Overview rows always follow provider order." = "As filas de Resumo sempre seguen a orde dos provedores."; +"Overview tab providers" = "Provedores da lapela Resumo"; +"Paste API key…" = "Pegar chave de API…"; +"Paste API token…" = "Pegar token de API…"; +"Paste key…" = "Pegar chave…"; +"Paste sessionKey or OAuth token…" = "Pegar sessionKey ou token de OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Pega a cabeceira Cookie dunha solicitude a admin.mistral.ai. "; +"Paste token…" = "Pegar token…"; +"Personal" = "Persoal"; +"Picker" = "Selector"; +"Picker subtitle" = "Subtítulo do selector"; +"Placeholder" = "Texto de marcador"; +"Plan" = "Plan"; +"Play full-screen confetti when weekly usage resets." = "Amosar confeti a pantalla completa cando se reinicie o uso semanal."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta as páxinas de estado de OpenAI/Claude e Google Workspace para "; +"Prevents any Keychain access while enabled." = "Evita calquera acceso ao Chaveiro mentres estea activado."; +"Primary (API key limit)" = "Principal (límite da chave de API)"; +"Primary (\\(label))" = "Principal (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Principal (\\(metadata.sessionLabel))"; +"Probe logs" = "Rexistros de sondaxe"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "As barras de progreso énchense a medida que consomes a cota (en vez de amosar o que queda)."; +"Provider" = "Provedor"; +"Providers" = "Provedores"; +"Quit CodexBar" = "Saír de CodexBar"; +"Random (default)" = "Aleatorio (por defecto)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Le os rexistros de uso locais. Amosa o custo de hoxe + a xanela de historial seleccionada no menú."; +"Refresh" = "Actualizar"; +"Refresh cadence" = "Frecuencia de actualización"; +"Remote" = "Remoto"; +"Remove" = "Eliminar"; +"Remove Codex account?" = "Queres eliminar a conta de Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Queres eliminar \\(account.email) de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Queres eliminar \\(email) de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"Remove selected account" = "Eliminar a conta seleccionada"; +"Replace critter bars with provider branding icons and a percentage." = "Substitúe as barras de progreso por iconas de marca do provedor e unha porcentaxe."; +"Replay selected animation" = "Reproducir a animación seleccionada"; +"Requires authentication via GitHub Device Flow." = "Require autenticación mediante o fluxo de dispositivo de GitHub."; +"Resets: \\(reset)" = "Reiníciase: \\(reset)"; +"reset_tomorrow_format" = "mañá, %@"; +"Rolling five-hour limit" = "Límite móbil de cinco horas"; +"Search hourly" = "Buscas por hora"; +"Secondary (\\(label))" = "Secundario (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secundario (\\(metadata.weeklyLabel))"; +"Select a provider" = "Selecciona un provedor"; +"Select the IDE to monitor" = "Selecciona o IDE que desexas monitorizar"; +"Session quota notifications" = "Notificacións de cota de sesión"; +"Session tokens" = "Tokens de sesión"; +"Settings" = "Axustes"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Amosa as seccións de Créditos de Codex e Uso adicional de Claude no menú."; +"Show Debug Settings" = "Amosar os axustes de depuración"; +"Show all token accounts" = "Amosar todas as contas con token"; +"Show cost summary" = "Amosar o resumo de custos"; +"Show credits + extra usage" = "Amosar créditos + uso adicional"; +"Show details" = "Amosar detalles"; +"Show most-used provider" = "Amosar o provedor máis usado"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Amosa as iconas de provedor no selector (se non, amosa unha liña de progreso semanal)."; +"Show reset time as clock" = "Amosar a hora de reinicio como reloxo"; +"Show usage as used" = "Amosar o uso como consumido"; +"Sign in via button below" = "Inicia sesión co botón de abaixo"; +"Skip teardown between probes (debug-only)." = "Omitir o peche entre sondaxes (só depuración)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apilar as contas con token no menú (se non, amosar unha barra de cambio de conta)."; +"Start at Login" = "Abrir ao iniciar a sesión"; +"Status" = "Estado"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Garda as cookies sessionKey de Claude ou tokens de acceso OAuth."; +"Store multiple Abacus AI Cookie headers." = "Garda varias cabeceiras de Cookie de Abacus AI."; +"Store multiple Augment Cookie headers." = "Garda varias cabeceiras de Cookie de Augment."; +"Store multiple Cursor Cookie headers." = "Garda varias cabeceiras de Cookie de Cursor."; +"Store multiple Factory Cookie headers." = "Garda varias cabeceiras de Cookie de Factory."; +"Store multiple MiniMax Cookie headers." = "Garda varias cabeceiras de Cookie de MiniMax."; +"Store multiple Mistral Cookie headers." = "Garda varias cabeceiras de Cookie de Mistral."; +"Store multiple Ollama Cookie headers." = "Garda varias cabeceiras de Cookie de Ollama."; +"Store multiple OpenCode Cookie headers." = "Garda varias cabeceiras de Cookie de OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Garda varias cabeceiras de Cookie de OpenCode Go."; +"Stored in the CodexBar config file." = "Gardado no ficheiro de configuración de CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Gardado en ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Gardado en ~/.codexbar/config.json. Xera un en kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Gardado en ~/.codexbar/config.json. Pega a chave do panel de Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Gardado en ~/.codexbar/config.json. Pega a túa chave de API do plan de programación desde Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Gardado en ~/.codexbar/config.json. Pega a túa chave de API de MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Gardado en ~/.codexbar/config.json. Tamén podes proporcionar KILO_API_KEY ou "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Garda o historial de uso local de Codex (8 semanas) para personalizar as predicións de Ritmo."; +"Surprise me" = "Sorpréndeme"; +"Switcher shows icons" = "O selector amosa iconas"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Crea un enlace simbólico de CodexBarCLI a /usr/local/bin e /opt/homebrew/bin como codexbar."; +"System" = "Sistema"; +"Temporarily shows the loading animation after the next refresh." = "Amosa temporalmente a animación de carga despois da seguinte actualización."; +"Tertiary (\\(label))" = "Terciario (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Terciario (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "A conta de Codex por defecto neste Mac."; +"Toggle" = "Alternar"; +"Toggle subtitle" = "Subtítulo do alternador"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Abre o menú da barra de menús desde calquera lugar."; +"True" = "Verdadeiro"; +"Twitter" = "Twitter"; +"Unsupported" = "Non soportado"; +"Update Channel" = "Canle de actualizacións"; +"Updated" = "Actualizado"; +"Updates unavailable in this build." = "Actualizacións non dispoñibles nesta compilación."; +"Usage" = "Uso"; +"Usage breakdown" = "Desglose de uso"; +"Usage history (30 days)" = "Historial de uso (30 días)"; +"Usage source" = "Orixe de uso"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usa BigModel para os endpoints de China continental (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Usa unha única icona na barra de menús cun selector de provedor."; +"Use international or China mainland console gateways for quota fetches." = "Usa pasarelas de consola internacionais ou de China continental para a obtención de cotas."; +"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." = "Agarda a que remate o inicio de sesión xestionado de Codex actual antes de engadir outra conta."; +"Waiting for Authentication..." = "Agardando pola autenticación..."; +"Website" = "Sitio web"; +"Weekly limit confetti" = "Confeti do límite semanal"; +"Weekly token limit" = "Límite semanal de tokens"; +"Weekly usage" = "Uso semanal"; +"Weekly usage unavailable for this account." = "O uso semanal non está dispoñible para esta conta."; +"Window: \\(window)" = "Xanela: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Escribe os rexistros en \\(self.fileLogPath) para depuración."; +"Yes" = "Si"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): obtendo…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): último intento \\(when)"; +"\\(name): no data yet" = "\\(name): aínda sen datos"; +"\\(name): unsupported" = "\\(name): non soportado"; +"all browsers" = "todos os navegadores"; +"available again." = "dispoñible de novo."; +"built_format" = "Compilación %@"; +"copilot_complete_in_browser" = "Completa o inicio de sesión no teu navegador."; +"copilot_device_code" = "Código de dispositivo copiado ao portapapeis: %1$@\n\nVerifícao en: %2$@"; +"copilot_device_code_copied" = "Código de dispositivo copiado."; +"copilot_verify_at" = "Verifícao en %@"; +"copilot_waiting_text" = "Completa o inicio de sesión no teu navegador.\nEsta xanela pecharase automaticamente cando remate o inicio de sesión."; +"copilot_window_closes_auto" = "Esta xanela pecharase automaticamente cando remate o inicio de sesión."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: obtendo… %2$@"; +"cost_status_last_attempt" = "%1$@: último intento %2$@"; +"cost_status_no_data" = "%@: aínda sen datos"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: non soportado"; +"credits_remaining" = "Créditos restantes: %@"; +"cursor_on_demand" = "Baixo demanda: %@"; +"cursor_on_demand_with_limit" = "Baixo demanda: %1$@ / %2$@"; +"extra_usage_format" = "Uso adicional: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Detectado: %@. Usa o asistente de IA unha vez para xerar os datos de cota e logo actualiza CodexBar."; +"jetbrains_detected_select" = "Detectado: %@. Selecciona o teu IDE preferido na configuración e logo actualiza CodexBar."; +"last_fetch_failed_with_provider" = "A última obtención de %@ fallou:"; +"last_spend" = "Último gasto: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Reiníciase: %@"; +"mcp_window" = "Xanela: %@"; +"metric_average" = "Media (%1$@ + %2$@)"; +"metric_primary" = "Principal (%@)"; +"metric_secondary" = "Secundario (%@)"; +"metric_tertiary" = "Terciario (%@)"; +"multiple_workspaces_found" = "CodexBar atopou varios espazos de traballo para %@. Escolle o que queiras engadir."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Escolle ata %@ provedores"; +"remove_account_message" = "Queres eliminar %@ de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"version_format" = "Versión %@"; +"vertex_ai_login_instructions" = "Para facer un seguimento do uso de Vertex AI, autentícate con Google Cloud.\n\n1. Abre o Terminal\n2. Executa: gcloud auth application-default login\n3. Segue as indicacións do navegador para iniciar sesión\n4. Define o teu proxecto: gcloud config set project ID_PROXECTO\n\nQueres abrir o Terminal agora?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID está definido, pero só opencode, opencodego e deepgram admiten workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licenza MIT."; + +/* General Pane */ +"section_system" = "Sistema"; +"section_usage" = "Uso"; +"section_automation" = "Automatización"; +"language_title" = "Idioma"; +"language_subtitle" = "Cambia o idioma da interface. Cómpre reiniciar a aplicación para que se aplique por completo."; +"language_system" = "Sistema"; +"language_english" = "Inglés"; +"language_spanish" = "Castelán"; +"language_catalan" = "Catalán"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Portugués (Brasil)"; +"language_swedish" = "Sueco"; +"language_dutch" = "Neerlandés"; +"language_german" = "Alemán"; +"language_french" = "Francés"; +"language_ukrainian" = "Ucraíno"; +"language_russian" = "Русский"; +"language_japanese" = "Xaponés"; +"language_korean" = "Coreano"; +"language_italian" = "Italiano"; +"language_polish" = "Polaco"; +"terminal_app_title" = "Terminal predeterminado"; +"terminal_app_subtitle" = "Terminal usado pola acción Abrir terminal"; +"start_at_login_title" = "Abrir ao iniciar a sesión"; +"start_at_login_subtitle" = "Abre CodexBar automaticamente ao iniciar o teu Mac."; +"show_cost_summary" = "Amosar o resumo de custos"; +"show_cost_summary_subtitle" = "Le os rexistros de uso locais. Amosa o custo de hoxe + a xanela de historial seleccionada no menú."; +"cost_summary_style_title" = "Estilo de visualización"; +"cost_summary_style_inline" = "Só integrado"; +"cost_summary_style_submenu" = "Só submenú"; +"cost_summary_style_both" = "Ambos"; +"cost_summary_style_inline_help" = "Amosa o resumo de custos directamente no menú principal."; +"cost_summary_style_submenu_help" = "Amosa no seu lugar o submenú Custo detallado."; +"cost_summary_style_both_help" = "Amosa o resumo do menú principal e o submenú Custo detallado."; +"cost_history_window_title" = "Xanela do historial"; +"cost_history_window_help" = "Define cantos días de rexistros de uso locais aparecen no menú."; +"cost_history_days_title" = "Xanela de historial: %d días"; +"cost_auto_refresh_info" = "Actualización automática: cada hora · Tempo de espera: 10 m"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación máis curtos"; +"cost_comparison_periods_subtitle" = "Engade totais de 7, 30 e 90 días cando caiban na xanela de historial seleccionada. Estes totais reutilizan a mesma análise local."; +"refresh_cadence_title" = "Frecuencia de actualización"; +"refresh_cadence_subtitle" = "Con que frecuencia CodexBar consulta os provedores en segundo plano."; +"manual_refresh_hint" = "A actualización automática está desactivada; usa a orde Actualizar do menú."; +"check_provider_status_title" = "Comprobar o estado do provedor"; +"check_provider_status_subtitle" = "Consulta as páxinas de estado de OpenAI/Claude e Google Workspace para Gemini/Antigravity, amosando incidencias na icona e no menú."; +"session_quota_notifications_title" = "Notificacións de cota de sesión"; +"session_quota_notifications_subtitle" = "Avisa cando a cota de sesión de 5 horas chega ao 0% e cando volve estar dispoñible."; +"quota_warning_notifications_title" = "Notificacións de aviso de cota"; +"quota_warning_notifications_subtitle" = "Avisa cando a cota restante de sesión ou semanal supera os limiares configurados."; +"quota_warnings_title" = "Avisos de cota"; +"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 ao"; +"quota_warning_global_threshold_subtitle" = "Porcentaxes restantes para as xanelas de sesión e semanal, a menos que un provedor as substitúa."; +"quota_warning_sound" = "Reproducir o son de notificación"; +"quota_warning_provider_inherits" = "Usa a configuración global de aviso de cota a menos que se personalice unha xanela aquí."; +"quota_warning_customize_thresholds" = "Personalizar os limiares de %@"; +"quota_warning_enable_warnings" = "Activar os avisos de %@"; +"quota_warning_window_warn_at" = "%@ avisa ao"; +"quota_warning_off" = "Desactivado"; +"quota_warning_inherited" = "Herdado: %@"; +"quota_warning_depleted_only" = "só esgotado"; +"quota_warning_upper" = "Superior"; +"quota_warning_lower" = "Inferior"; +"apply" = "Aplicar"; +"quit_app" = "Saír de CodexBar"; + +/* Tab titles */ +"tab_general" = "Xeral"; +"tab_providers" = "Provedores"; +"tab_display" = "Pantalla"; +"tab_advanced" = "Avanzado"; +"tab_about" = "Acerca de"; +"tab_debug" = "Depuración"; + +/* Providers Pane */ +"select_a_provider" = "Selecciona un provedor"; +"cancel" = "Cancelar"; +"last_fetch_failed" = "a última obtención fallou"; +"usage_not_fetched_yet" = "aínda non se obtivo o uso"; +"managed_account_storage_unreadable" = "O almacenamento de contas xestionadas non se pode ler. O acceso ás contas activas aínda está dispoñible, pero as accións de engadir, reautenticar e eliminar contas xestionadas están desactivadas ata que se poida recuperar o almacén."; +"remove_codex_account_title" = "Queres eliminar a conta de Codex?"; +"remove" = "Eliminar"; +"managed_login_already_running" = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir ou reautenticar outra conta."; +"managed_login_failed" = "O inicio de sesión xestionado de Codex non se completou. Comproba que `codex --version` funciona no Terminal. Se macOS bloqueou ou moveu `codex` ao Lixo, elimina as instalacións duplicadas obsoletas, executa `npm install -g --include=optional @openai/codex@latest` e téntao de novo."; +"codex_login_output" = "Saída de codex login:"; +"managed_login_missing_email" = "O inicio de sesión de Codex completouse, pero non había ningún correo electrónico de conta dispoñible. Téntao de novo despois de confirmar que iniciaches sesión por completo na conta."; +"workspace_selection_cancelled" = "CodexBar atopou varios espazos de traballo, pero non se seleccionou ningún."; +"unsafe_managed_home" = "CodexBar rexeitou modificar unha ruta de directorio xestionada inesperada: %@"; +"menu_bar_metric_title" = "Métrica da barra de menús"; +"menu_bar_metric_subtitle" = "Escolle que xanela determina a porcentaxe da barra de menús."; +"menu_bar_metric_subtitle_deepseek" = "Amosa o saldo de DeepSeek na barra de menús."; +"menu_bar_metric_subtitle_moonshot" = "Amosa o saldo da API de Moonshot / Kimi na barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Amosa o gasto da API de Mistral do mes actual na barra de menús."; +"menu_bar_metric_subtitle_kimik2" = "Amosa os créditos da chave de API de Kimi K2 na barra de menús."; +"automatic" = "Automático"; +"primary_api_key_limit" = "Principal (límite da chave de API)"; + +/* Display Pane */ +"section_menu_bar" = "Barra de menús"; +"merge_icons_title" = "Combinar iconas"; +"merge_icons_subtitle" = "Usa unha única icona na barra de menús cun selector de provedor."; +"switcher_shows_icons_title" = "O selector amosa iconas"; +"switcher_shows_icons_subtitle" = "Amosa as iconas de provedor no selector (se non, amosa unha liña de progreso semanal)."; +"show_most_used_provider_title" = "Amosar o provedor máis usado"; +"show_most_used_provider_subtitle" = "A barra de menús amosa automaticamente o provedor máis próximo ao seu límite."; +"menu_bar_shows_percent_title" = "A barra de menús amosa a porcentaxe"; +"menu_bar_shows_percent_subtitle" = "Substitúe as barras de progreso por iconas de marca do provedor e unha porcentaxe."; +"hide_critters_title" = "Ocultar animaliños"; +"hide_critters_subtitle" = "Amosar barras de medición simples sen a cara nin os adornos."; +"display_mode_title" = "Modo de visualización"; +"display_mode_subtitle" = "Escolle que amosar na barra de menús (Ritmo amosa o uso fronte ao previsto)."; +"section_menu_content" = "Contido do menú"; +"show_usage_as_used_title" = "Amosar o uso como consumido"; +"show_usage_as_used_subtitle" = "As barras de progreso énchense a medida que consomes a cota (en vez de amosar o que queda)."; +"show_quota_warning_markers_title" = "Amosar os marcadores de aviso de cota"; +"show_quota_warning_markers_subtitle" = "Debuxa marcas de limiar nas barras de uso cando hai avisos de cota configurados."; +"weekly_progress_work_days_title" = "Días laborables do progreso semanal"; +"weekly_progress_work_days_subtitle" = "Define os días laborables para os marcadores das barras de uso semanal e os cálculos de ritmo."; +"show_reset_time_as_clock_title" = "Amosar a hora de reinicio como reloxo"; +"show_reset_time_as_clock_subtitle" = "Amosa as horas de reinicio como valores de reloxo absolutos en vez de contas atrás."; +"show_provider_changelog_links_title" = "Amosar as ligazóns ao rexistro de cambios do provedor"; +"show_provider_changelog_links_subtitle" = "Engade ao menú enlaces ás notas de versión dos provedores compatibles baseados en CLI."; +"show_credits_extra_usage_title" = "Amosar créditos + uso adicional"; +"show_credits_extra_usage_subtitle" = "Amosa as seccións de Créditos de Codex e Uso adicional de Claude no menú."; +"show_all_token_accounts_title" = "Amosar todas as contas con token"; +"show_all_token_accounts_subtitle" = "Apila as contas con token no menú (se non, amosa unha barra de cambio de conta)."; +"multi_account_layout_title" = "Disposición multiconta"; +"multi_account_layout_subtitle" = "Escolle o cambio de conta segmentado ou tarxetas de conta apiladas."; +"multi_account_layout_segmented" = "Segmentado"; +"multi_account_layout_stacked" = "Apilado"; +"overview_tab_providers_title" = "Provedores da lapela Resumo"; +"configure" = "Configurar…"; +"overview_enable_merge_icons_hint" = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; +"overview_no_providers_hint" = "Non hai provedores activados dispoñibles para o Resumo."; +"overview_rows_follow_order" = "As filas de Resumo sempre seguen a orde dos provedores."; +"overview_no_providers_selected" = "Non se seleccionou ningún provedor"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Atallo de teclado"; +"open_menu_shortcut_title" = "Abrir o menú"; +"open_menu_shortcut_subtitle" = "Abre o menú da barra de menús desde calquera lugar."; +"install_cli" = "Instalar a CLI"; +"install_cli_subtitle" = "Crea un enlace simbólico de CodexBarCLI a /usr/local/bin e /opt/homebrew/bin como codexbar."; +"cli_not_found" = "Non se atopou CodexBarCLI no paquete da aplicación."; +"no_writable_bin_dirs" = "Non se atoparon directorios bin con permisos de escritura."; +"show_debug_settings_title" = "Amosar os axustes de depuración"; +"show_debug_settings_subtitle" = "Amosa ferramentas de diagnose na lapela Depuración."; +"surprise_me_title" = "Sorpréndeme"; +"surprise_me_subtitle" = "Actívao se che gusta que os teus axentes se divirtan aí arriba."; +"weekly_limit_confetti_title" = "Confeti do límite semanal"; +"weekly_limit_confetti_subtitle" = "Amosa confeti a pantalla completa cando se reinicie o uso semanal."; +"hide_personal_info_title" = "Ocultar información persoal"; +"hide_personal_info_subtitle" = "Oculta os enderezos de correo na barra de menús e na interface do menú."; +"show_provider_storage_usage_title" = "Amosar o uso de almacenamento do provedor"; +"show_provider_storage_usage_subtitle" = "Amosa o uso do disco local nos menús. Analiza en segundo plano as rutas coñecidas do provedor."; +"section_keychain_access" = "Acceso ao Chaveiro"; +"keychain_access_caption" = "Desactiva todas as lecturas e escrituras do Chaveiro. A importación de cookies do navegador non estará dispoñible; pega as cabeceiras de Cookie manualmente en Provedores."; +"disable_keychain_access_title" = "Desactivar o acceso ao Chaveiro"; +"disable_keychain_access_subtitle" = "Evita calquera acceso ao Chaveiro mentres estea activado."; + +/* About Pane */ +"about_tagline" = "Que os teus tokens nunca se esgoten: mantén á vista os límites dos teus axentes."; +"link_github" = "GitHub"; +"link_website" = "Sitio web"; +"link_twitter" = "Twitter"; +"link_email" = "Correo electrónico"; +"check_updates_auto" = "Buscar actualizacións automaticamente"; +"update_channel" = "Canle de actualizacións"; +"check_for_updates" = "Buscar actualizacións…"; +"updates_unavailable" = "Actualizacións non dispoñibles nesta compilación."; +"copyright" = "© 2026 Peter Steinberger. Licenza MIT."; + +/* Debug Pane */ +"section_logging" = "Rexistro"; +"enable_file_logging" = "Activar o rexistro en ficheiro"; +"enable_file_logging_subtitle" = "Escribe os rexistros en %@ para depuración."; +"verbosity_title" = "Nivel de detalle"; +"verbosity_subtitle" = "Controla canto detalle se rexistra."; +"open_log_file" = "Abrir o ficheiro de rexistro"; +"force_animation_next_refresh" = "Forzar a animación na seguinte actualización"; +"force_animation_next_refresh_subtitle" = "Amosa temporalmente a animación de carga despois da seguinte actualización."; +"section_loading_animations" = "Animacións de carga"; +"loading_animations_caption" = "Escolle un patrón e reprodúceo na barra de menús. «Aleatorio» mantén o comportamento actual."; +"animation_random_default" = "Aleatorio (por defecto)"; +"replay_selected_animation" = "Reproducir a animación seleccionada"; +"blink_now" = "Parpadear agora"; +"section_probe_logs" = "Rexistros de sondaxe"; +"probe_logs_caption" = "Obtén a última saída de sondaxe para a depuración; Copiar conserva o texto completo."; +"fetch_log" = "Obter o rexistro"; +"copy" = "Copiar"; +"save_to_file" = "Gardar nun ficheiro"; +"load_parse_dump" = "Cargar o volcado de análise"; +"rerun_provider_autodetect" = "Volver a executar a autodetección de provedores"; +"loading" = "Cargando…"; +"no_log_yet_fetch" = "Aínda non hai rexistro. Obtén para cargalo."; +"section_fetch_strategy" = "Intentos de estratexia de obtención"; +"fetch_strategy_caption" = "Últimas decisións e erros do fluxo de obtención dun provedor."; +"section_openai_cookies" = "Cookies de OpenAI"; +"openai_cookies_caption" = "Rexistros de importación de cookies e extracción con WebKit do último intento de cookies de OpenAI."; +"no_log_yet" = "Aínda non hai rexistro. Actualiza as cookies de OpenAI en Provedores → Codex para executar unha importación."; +"section_caches" = "Cachés"; +"caches_caption" = "Borra os resultados de análise de custos na caché ou as cachés de cookies do navegador."; +"clear_cookie_cache" = "Borrar a caché de cookies"; +"clear_cost_cache" = "Borrar a caché de custos"; +"section_notifications" = "Notificacións"; +"notifications_caption" = "Lanza notificacións de proba para a xanela de sesión de 5 horas (esgotada/restaurada)."; +"post_depleted" = "Enviar esgotada"; +"post_restored" = "Enviar restaurada"; +"section_cli_sessions" = "Sesións da CLI"; +"cli_sessions_caption" = "Mantén activas as sesións da CLI de Codex/Claude despois dunha sondaxe. Por defecto péchanse cando se capturan os datos."; +"keep_cli_sessions_alive" = "Manter activas as sesións de CLI"; +"keep_cli_sessions_alive_subtitle" = "Omitir o peche entre sondaxes (só depuración)."; +"reset_cli_sessions" = "Reiniciar as sesións de CLI"; +"section_error_simulation" = "Simulación de erros"; +"error_simulation_caption" = "Inxecta unha mensaxe de erro falsa na tarxeta do menú para probar a disposición."; +"set_menu_error" = "Establecer o erro de menú"; +"clear_menu_error" = "Borrar o erro de menú"; +"set_cost_error" = "Establecer o erro de custo"; +"clear_cost_error" = "Borrar o erro de custo"; +"section_cli_paths" = "Rutas de CLI"; +"cli_paths_caption" = "Binario de Codex resolto e capas de PATH; captura do PATH de inicio de sesión no arranque (tempo de espera curto)."; +"codex_binary" = "Binario de Codex"; +"claude_binary" = "Binario de Claude"; +"effective_path" = "PATH efectivo"; +"unavailable" = "Non dispoñible"; +"login_shell_path" = "PATH do shell de inicio de sesión (captura no arranque)"; +"cleared" = "Borrado."; +"no_fetch_attempts" = "Aínda non hai intentos de obtención."; +"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 pode bloquear as aplicacións da barra de menús en Axustes do Sistema → Barra de menús → Permitir na barra de menús. CodexBar está a executarse, pero macOS pode estar ocultando a súa icona. Abre os axustes da barra de menús e activa CodexBar."; + +/* 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" = "Media"; +"metric_mistral_payg" = "Pago por uso"; +"metric_mistral_monthly_plan" = "Plan mensual"; + +/* Display modes */ +"display_mode_percent" = "Porcentaxe"; +"display_mode_pace" = "Ritmo"; +"display_mode_both" = "Ambos"; +"display_mode_reset_time" = "Tempo de restablecemento"; +"display_mode_percent_desc" = "Amosa a porcentaxe restante/usada (ex. 45 %)"; +"display_mode_pace_desc" = "Amosa o indicador de ritmo (ex. +5 %)"; +"display_mode_both_desc" = "Amosa a porcentaxe e o ritmo (ex. 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Amosa o tempo de restablecemento da métrica seleccionada (p. ex. ↻ 15:56)"; + +/* Provider status */ +"status_operational" = "Operativo"; +"status_degraded" = "Rendemento degradado"; +"status_partial_outage" = "Interrupción parcial"; +"status_major_outage" = "Interrupción grave"; +"status_critical_issue" = "Problema crítico"; +"status_maintenance" = "Mantemento"; +"status_unknown" = "Estado descoñecido"; + +/* 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" = "Non atopado"; + +/* Cost estimation */ +"cost_header_estimated" = "Custo (estimado)"; +"cost_estimate_hint" = "Estimado a partir de rexistros locais · pode diferir da túa factura"; + +/* Popup panels */ +"No usage configured." = "Non hai ningún uso configurado."; +"Quota" = "Cota"; +"tokens" = "tokens"; +"requests" = "solicitudes"; +"Latest" = "Máis recente"; +"Monthly" = "Mensual"; +"Sonnet" = "Sonnet"; +"Auth" = "Autenticación"; +"Overages" = "Excesos"; +"Activity" = "Actividade"; +"Copied" = "Copiado"; +"Copy error" = "Erro ao copiar"; +"Copy path" = "Copiar camiño"; +"Extra usage spent" = "Uso extra gastado"; +"Credits remaining" = "Créditos restantes"; +"Using CLI fallback" = "Usando a alternativa da CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "O saldo actualízase case en tempo real (cun atraso de ata 5 min)"; +"Daily billing data finalizes at 07:00 UTC" = "Os datos diarios de facturación péchanse ás 07:00 UTC"; +"%@ of %@ credits left" = "Quedan %@ de %@ créditos"; +"%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos extra"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restantes)"; +"%@/%@ left" = "Quedan %@/%@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Rexenérase %@"; +"used after next regen" = "usado tras a próxima rexeneración"; +"after next regen" = "tras a próxima rexeneración"; +"Near full" = "Case cheo"; +"Full in ~1 regen" = "Cheo en ~1 rexeneración"; +"Full in ~%.0f regens" = "Cheo en ~%.0f rexeneracións"; +"Overage usage" = "Uso excesivo"; +"Overage cost" = "Custo excedente"; +"credits" = "créditos"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Gasto da API"; +"Extra usage" = "Uso extra"; +"Quota usage" = "Uso da cota"; +"Your spend" = "O teu gasto"; +"%.0f%% used" = "%.0f%% usado"; +"Usage history (today)" = "Historial de uso (hoxe)"; +"Usage history (%d days)" = "Historial de uso (%d días)"; +"%d percent remaining" = "%d por cento restante"; +"Unknown" = "Descoñecido"; +"stale data" = "datos obsoletos"; +"No credits history data available." = "Non hai datos dispoñibles do historial de créditos."; +"Credits history chart" = "Gráfica do historial de créditos"; +"%d days of credits data" = "%d días de datos de créditos"; +"Usage breakdown chart" = "Gráfica da desagregación do uso"; +"%d days of usage data across %d services" = "%d días de datos de uso en %d servizos"; +"Cost history chart" = "Gráfica do historial de custos"; +"%d days of cost data" = "%d días de datos de custos"; +"Plan utilization chart" = "Gráfica de uso do plan"; +"%d utilization samples" = "%d mostras de uso"; +"Hourly Usage" = "Uso horario"; +"Usage remaining" = "Uso restante"; +"Usage used" = "Uso consumido"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Chave de API verificada. Ollama non expón os límites da cota de Cloud mediante a API."; +"Last 30 days: %@ tokens" = "Últimos 30 días: %@ tokens"; +"7d spend" = "Gasto en 7 d"; +"30d spend" = "Gasto en 30 d"; +"Cache read" = "Lectura da caché"; +"Claude Admin API 30 day spend trend" = "Tendencia de gasto de 30 días da API de administración de Claude"; +"OpenRouter API key spend trend" = "Tendencia de gasto da chave de 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 MiniMax en 30 días"; +"Today cash" = "Gasto de hoxe"; +"DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de DeepSeek en 30 días"; +"cache-hit input" = "entrada atopada na caché"; +"cache-miss input" = "entrada non atopada na caché"; +"output" = "saída"; +"Requests" = "Solicitudes"; +"Reported by OpenAI Admin API organization usage." = "Datos do uso da organización fornecidos pola API de administración de OpenAI."; +"Reported by Mistral billing usage." = "Datos fornecidos polo uso de facturación de Mistral."; +"Today" = "Hoxe"; +"Today tokens" = "Tokens de hoxe"; +"30d cost" = "Custo en 30 d"; +"30d tokens" = "Tokens en 30 d"; +"Latest tokens" = "Tokens máis recentes"; +"Top model" = "Modelo principal"; +"Storage" = "Almacenamento"; +"No data" = "Sen 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 en 24 h"; +"Latest hour" = "Última hora"; +"Peak hour" = "Hora punta"; +"Top method" = "Método principal"; +"30d cash" = "Gasto en 30 d"; +"30d billing history from MiniMax web session" = "Historial de facturación de 30 días da sesión web de MiniMax"; +"AWS Cost Explorer billing can lag." = "A facturación de AWS Cost Explorer pode levar atraso."; +"Rate limit: %d / %@" = "Límite de frecuencia: %d / %@"; +"Key remaining" = "Saldo restante da chave"; +"No limit set for the API key" = "Non hai ningún límite configurado para a chave de API"; +"API key limit unavailable right now" = "O límite da chave de API non está dispoñible neste momento"; +"Today: %@ · %@ tokens" = "Hoxe: %@ · %@ tokens"; +"Today: %@" = "Hoxe: %@"; +"Today: %@ tokens" = "Hoxe: %@ tokens"; +"This month: %@ tokens" = "Este mes: %@ tokens"; +"API key limit" = "Límite da chave de API"; +"Limits not available" = "Límites non dispoñibles"; +"No usage yet" = "Aínda non hai uso"; +"Not fetched yet" = "Aínda non se obtivo"; +"Code review" = "Revisión de código"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ agarda permiso"; +"%@ requests" = "%@ solicitudes"; +"%@: %@ credits" = "%@: %@ créditos"; +"30d requests" = "Solicitudes en 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." = "A chave de API verifica o acceso a Ollama Cloud; as cookies seguen amosando os límites da cota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID da chave de acceso de AWS. Tamén se pode definir con AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Rexión de AWS. Tamén se pode definir con AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Chave de acceso secreta de AWS. Tamén se pode definir con AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID da chave de acceso"; +"Add Account" = "Engadir conta"; +"Adding Account…" = "Engadindo conta…"; +"Antigravity login failed" = "Erro ao iniciar sesión en Antigravity"; +"Antigravity login timed out" = "O inicio de sesión en Antigravity esgotou o tempo"; +"Auth source" = "Fonte de autenticación"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente as cookies do navegador desde Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente datos da sesión de Windsurf desde o localStorage de Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador desde Bailian."; +"Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; +"Automatically imports browser session cookies." = "Importa automaticamente cookies da sesión do navegador."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nome da implantación de Azure OpenAI. Tamén se admite AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Chave de Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Punto de conexión do recurso de Azure OpenAI. Tamén se admite AZURE_OPENAI_ENDPOINT."; +"Base URL" = "URL base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL base da instancia de LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies do navegador"; +"Cap end" = "Fin do límite"; +"Cap start" = "Inicio do límite"; +"Capacity End" = "Fin da capacidade"; +"Capacity Start" = "Inicio da capacidade"; +"Changelog" = "Rexistro de cambios"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Escolle o servidor da API de 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." = "CodexBar non pode substituír unha conta do sistema que iniciou sesión usando só unha chave de API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar non atopou a autenticación gardada desa conta. Volve autenticala e téntao de novo."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar non puido ler o almacenamento das contas xestionadas. Recupera o almacén antes de engadir outra conta."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar non puido ler a autenticación gardada desa conta. Volve autenticala e téntao de novo."; +"CodexBar could not read the current system account on this Mac." = "CodexBar non puido ler a conta actual do sistema neste Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar non puido substituír a autenticación activa de Codex neste Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar non puido conservar con seguridade a conta actual do sistema antes de cambiala."; +"CodexBar could not save the current system account before switching." = "CodexBar non puido gardar a conta actual do sistema antes de cambiala."; +"CodexBar could not update managed account storage." = "CodexBar non puido actualizar o almacenamento das contas xestionadas."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar atopou outra conta xestionada que xa usa a conta actual do sistema. Resolve a conta duplicada antes de cambiar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS “%@” para descifrar as cookies do navegador e autenticar a túa conta. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o token OAuth de Claude Code para obter o teu uso de Claude. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Amp para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Augment para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Claude para obter o uso web de Claude. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Cursor para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Factory para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de GitHub Copilot para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa chave de API de Kimi K2 para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de autenticación de Kimi para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de API de MiniMax para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de MiniMax para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de OpenAI para obter os extras do panel de Codex. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de OpenCode para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa chave de API de Synthetic para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de API de z.ai para obter o uso. Preme Aceptar para continuar."; +"Could not open Cursor login in your browser." = "Non se puido abrir o inicio de sesión de Cursor no teu navegador."; +"Could not open browser for Antigravity" = "Non se puido abrir o navegador para Antigravity"; +"Credits used" = "Créditos utilizados"; +"Day" = "Día"; +"Deployment" = "Despregamento"; +"Drag to reorder" = "Arrastra para reordenar"; +"Sort providers alphabetically" = "Ordenar os provedores alfabeticamente"; +"Sort providers alphabetically (enabled first)" = "Ordenar os provedores alfabeticamente (activado primeiro)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordenados alfabeticamente (os activados primeiro): preme para usar a túa orde personalizada"; +"Endpoint" = "Punto de conexión"; +"Enterprise host" = "Servidor empresarial"; +"Extra usage balance: %@" = "Saldo de uso adicional: %@"; +"Keychain Access Required" = "Requírese acceso ao Chaveiro"; +"keychain_prompt_learn_more" = "Máis información…"; +"keychain_prompt_privacy_note" = "macOS, non CodexBar, xestiona a introdución do contrasinal de inicio de sesión do Mac. Podes desactivar o acceso ao Chaveiro en calquera momento en Axustes → Avanzado."; +"Kiro menu bar value" = "Valor de Kiro na barra de menús"; +"Label" = "Etiqueta"; +"No organizations loaded. Click Refresh after setting your API key." = "Non se cargou ningunha organización. Preme Actualizar despois de configurar a túa chave de API."; +"No output captured." = "Non se capturou ningunha saída."; +"No system account" = "Sen conta do sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Abrir Augment (pechar sesión e volver entrar)"; +"Open Codebuff Dashboard" = "Abrir o panel de Codebuff"; +"Open Command Code Settings" = "Abrir os axustes de Command Code"; +"Open Crof dashboard" = "Abrir o panel de Crof"; +"Open Manus" = "Abrir Manus"; +"Open MiMo Balance" = "Abrir o saldo de MiMo"; +"Open Moonshot Console" = "Abrir a consola de Moonshot"; +"Open Ollama API Keys" = "Abrir as chaves de API de Ollama"; +"Open StepFun Platform" = "Abrir a plataforma StepFun"; +"Open T3 Chat Settings" = "Abrir os axustes de T3 Chat"; +"Open Volcengine Ark Console" = "Abrir a consola de Volcengine Ark"; +"Open legacy provider docs" = "Abrir a documentación do provedor antigo"; +"Open projects" = "Abrir proxectos"; +"Open this URL manually to continue login:\n\n%@" = "Abre este URL manualmente para continuar o inicio de sesión:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organización opcional para contas vinculadas a varias organizacións de Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Aplícase á chave configurada da API de administración; as contas de token seleccionadas non herdan OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introduce o teu servidor de GitHub Enterprise, por exemplo octocorp.ghe.com. Deixa en branco para github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Déixao en branco para descubrir e agregar os proxectos visibles para a chave de API."; +"Org ID (optional)" = "ID da organización (opcional)"; +"Organizations" = "Organizacións"; +"Organization ID" = "ID da organización"; +"Password" = "Contrasinal"; +"%@ authentication is disabled." = "A autenticación %@ está desactivada."; +"%@ cookies are disabled." = "As cookies de %@ están desactivadas."; +"%@ web API access is disabled." = "O acceso á API web de %@ está desactivado."; +"Disable %@ dashboard cookie usage." = "Desactiva o uso das cookies do panel de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "O acceso ao chaveiro está desactivado en Avanzado, polo que a importación de cookies do navegador non está dispoñible."; +"Manually paste an %@ from a browser session." = "Pega manualmente un %@ dunha sesión do navegador."; +"Paste a Cookie header captured from %@." = "Pega unha cabeceira de cookie capturada de %@."; +"Paste a Cookie header from %@." = "Pega unha cabeceira de cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Pega unha cabeceira de cookies ou unha captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Pega unha cabeceira de cookie ou unha captura de cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Pega unha cabeceira de cookie ou autorización de %@."; +"Paste a full cookie header or the %@ value." = "Pega unha cabeceira de cookie completa ou o valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Pega unha cabeceira Cookie ou unha captura cURL completa desde os axustes de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Pega a cabeceira Cookie dunha solicitude a admin.mistral.ai. Debe conter unha cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Pega o Oasis-Token desde unha sesión de navegador iniciada en platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Pega o paquete %@ JSON de %@."; +"Paste the %@ value or a full Cookie header." = "Pega o valor %@ ou unha cabeceira completa da cookie."; +"Personal account" = "Conta persoal"; +"Project ID" = "ID do proxecto"; +"Re-auth" = "Volver autenticar"; +"Re-login at claude.ai" = "Volve iniciar sesión en claude.ai"; +"Re-authenticating…" = "Reautenticando..."; +"Refresh Session" = "Actualizar a sesión"; +"Refresh organizations" = "Actualizar as organizacións"; +"Region" = "Rexión"; +"Reload" = "Recargar"; +"Reorder" = "Reordenar"; +"Secret access key" = "Chave de acceso secreta"; +"Series" = "Serie"; +"Service" = "Servizo"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Amosa ou oculta os créditos de Kiro, a porcentaxe ou ambos xunto á icona da barra de menús."; +"Show usage for organizations you belong to. Personal account is always shown." = "Amosa o uso das organizacións ás que pertences. A conta persoal amósase sempre."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sesión en cursor.com no navegador e despois actualiza Cursor en CodexBar."; +"Simulated error text" = "Texto de erro simulado"; +"StepFun platform account (phone number or email)." = "Conta da plataforma StepFun (número de teléfono ou correo electrónico)."; +"Stored in ~/.codexbar/config.json." = "Gardado en ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Gardado en ~/.codexbar/config.json. Tamén se admite AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Gardado en ~/.codexbar/config.json. Para a API oficial de Kimi, usa Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave de API na consola de Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave nos axustes de Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave en console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave 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." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave en openrouter.ai/settings/keys e define alí un límite de gasto para activar o seguimento da cota da chave de API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Gardado en ~/.codexbar/config.json. En Warp, abre Settings > Platform > API Keys e crea unha chave."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Gardado en ~/.codexbar/config.json. As métricas requiren acceso a Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Gardado en ~/.codexbar/config.json. Prefírese OPENAI_ADMIN_KEY; OPENAI_API_KEY tamén funciona."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Gardado en ~/.codexbar/config.json. Require unha chave da API de administración de Anthropic."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Gardado en ~/.codexbar/config.json. Úsase 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`)." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer CODEBUFF_API_KEY ou deixar que CodexBar lea ~/.config/manicode/credentials.json (creado por `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie de T3 Chat"; +"Team mode" = "Modo de equipo"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Esa conta xa non está dispoñible en CodexBar. Actualiza a lista de contas e téntao de novo."; +"The browser login did not complete in time. Try Antigravity login again." = "O inicio de sesión do navegador non rematou a tempo. Tenta iniciar sesión en Antigravity de novo."; +"Timed out waiting for Cursor login. %@" = "Esgotouse o tempo de espera polo inicio de sesión de Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Esgotouse o tempo de espera polo inicio de sesión de Cursor. %@ Último erro: %@"; +"Today requests" = "Solicitudes de hoxe"; +"Total (30d): %@ credits" = "Total (30d): %@ créditos"; +"Username" = "Nome de usuario"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nome de usuario + contrasinal para iniciar sesión e obter un Oasis-Token automaticamente."; +"Uses username + password to login and obtain an %@ automatically." = "Usa nome de usuario + contrasinal para iniciar sesión e obter un %@ automaticamente."; +"Utilization End" = "Fin de utilización"; +"Utilization Start" = "Inicio de utilización"; +"Verbosity" = "Nivel de detalle"; +"Windsurf session JSON bundle" = "Paquete JSON da sesión de Windsurf"; +"Workspace ID" = "ID do espazo de traballo"; +"Your StepFun platform password. Used to login and obtain a session token." = "O teu contrasinal da plataforma StepFun. Usado para iniciar sesión e obter un token de sesión."; +"claude /login exited with status %d." = "claude /login saíu co estado %d."; +"codex login exited with status %d." = "codex login rematou co estado %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:…\n\nou pega unha captura de cURL desde o panel de control de Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie:…\n\nou pega o valor __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie:…\n\nou pega o valor do token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou pega só o valor session_id"; +"Clear" = "Limpar"; +"No matching providers" = "Non hai provedores coincidentes"; +"Search providers" = "Buscar provedores"; + +"language_vietnamese" = "Vietnamita"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Bahasa Indonesia"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Créditos para restablecer límites"; +"1 available" = "1 dispoñible"; +"%d available" = "%d dispoñibles"; +"Next expires %@" = "O seguinte caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sen caducidade"; +"Other (%d items)" = "Outros (%d elementos)"; +"Expand" = "Expandir"; +"Collapse" = "Contraer"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; +"Open Status Page" = "Abrir a páxina de estado"; +"%@ is unavailable in the current environment." = "%@ non está dispoñible no contorno actual."; +"%@ left" = "%@ restante"; +"%@ · %@" = "%@ · %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@%% used" = "%@: %@%% usado"; +"%d more items" = "%d elementos máis"; +"%d unreadable item(s) skipped" = "Omitíronse %d elementos ilexibles"; +"%d%% in deficit" = "%d%% en déficit"; +"%d%% in reserve" = "%d%% en reserva"; +"%dd" = "%d d"; +"1.5× headroom" = "1,5× de marxe"; +"About CodexBar" = "Acerca de CodexBar"; +"Add Account..." = "Engadir conta..."; +"Add Google Account" = "Engadir conta de Google"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Engade contas mediante o fluxo de dispositivo OAuth de GitHub no servidor seleccionado."; +"Admin API key" = "Chave de API de administración"; +"All Systems Operational" = "Todos os sistemas operativos"; +"Alternatively, set a custom path in Settings." = "Tamén podes definir unha ruta personalizada en Axustes."; +"Auto" = "Automático"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "O modo automático usa primeiro a API local do IDE e despois Google OAuth cando o IDE está pechado."; +"Cleanup ideas" = "Suxestións de limpeza"; +"Clearing removes archived Codex session history." = "Ao limpar elimínase o historial de sesións arquivadas de Codex."; +"Clearing removes cached large pastes or attached images." = "Ao limpar elimínanse as pegadas grandes e as imaxes anexas da caché."; +"Clearing removes checkpoint restore data for previous edits." = "Ao limpar elimínanse os datos de restauración dos puntos de control de edicións anteriores."; +"Clearing removes leftover runtime shell snapshot files." = "Ao limpar elimínanse os ficheiros de instantáneas de shell restantes do tempo de execución."; +"Clearing removes legacy per-session task lists." = "Ao limpar elimínanse as listas de tarefas antigas de cada sesión."; +"Clearing removes local diagnostic logs." = "Ao limpar elimínanse os rexistros de diagnóstico locais."; +"Clearing removes local edit checkpoint history." = "Ao limpar elimínase o historial local de puntos de control de edición."; +"Clearing removes local temporary provider data." = "Ao limpar elimínanse os datos temporais locais do provedor."; +"Clearing removes old plan-mode files." = "Ao limpar elimínanse os ficheiros antigos do modo de planificación."; +"Clearing removes past Codex session history." = "Ao limpar elimínase o historial de sesións anteriores de Codex."; +"Clearing removes past debug logs." = "Ao limpar elimínanse os rexistros de depuración anteriores."; +"Clearing removes past resume, continue, and rewind history." = "Ao limpar elimínase o historial anterior de retomar, continuar e rebobinar."; +"Clearing removes per-session environment metadata." = "Ao limpar elimínanse os metadatos de contorno de cada sesión."; +"Clearing removes provider-owned cached data." = "Ao limpar elimínanse os datos da caché propiedade do provedor."; +"Credits unavailable; keep Codex running to refresh." = "Os créditos non están dispoñibles; mantén Codex en execución para actualizalos."; +"CrossModel API spend trend" = "Tendencia de gasto da API de CrossModel"; +"Daily" = "Diario"; +"Disable" = "Desactivar"; +"Disabled — no recent data" = "Desactivado — sen datos recentes"; +"Enable" = "Activar"; +"Est. total (%@): %@" = "Total estimado (%@): %@"; +"Est. total (30d): %@" = "Total estimado (30 d): %@"; +"Estimated from local Codex logs for the selected account." = "Estimado a partir dos rexistros locais de Codex para a conta seleccionada."; +"Google OAuth" = "Google OAuth"; +"Google accounts" = "Contas de Google"; +"Hourly Tokens" = "Tokens por hora"; +"Hover a bar for details" = "Pasa o cursor sobre unha barra para ver os detalles"; +"Image Generation" = "Xeración de imaxes"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Instala un IDE de JetBrains co Asistente de IA activado e despois actualiza CodexBar."; +"Last %d day" = "Último %d día"; +"Last 30 days" = "Últimos 30 días"; +"Last 30 days:" = "Últimos 30 días:"; +"Last 30 days: %@" = "Últimos 30 días: %@"; +"Last 30 days: %@ · %@ tokens" = "Últimos 30 días: %@ · %@ tokens"; +"Lasts until reset" = "Dura ata o restablecemento"; +"Login with Google" = "Iniciar sesión con Google"; +"Manual cleanup: archived sessions" = "Limpeza manual: sesións arquivadas"; +"Manual cleanup: attachment cache" = "Limpeza manual: caché de anexos"; +"Manual cleanup: cache" = "Limpeza manual: caché"; +"Manual cleanup: debug logs" = "Limpeza manual: rexistros de depuración"; +"Manual cleanup: file checkpoints" = "Limpeza manual: puntos de control de ficheiros"; +"Manual cleanup: file history" = "Limpeza manual: historial de ficheiros"; +"Manual cleanup: legacy todos" = "Limpeza manual: tarefas antigas"; +"Manual cleanup: logs" = "Limpeza manual: rexistros"; +"Manual cleanup: past sessions" = "Limpeza manual: sesións anteriores"; +"Manual cleanup: saved plans" = "Limpeza manual: plans gardados"; +"Manual cleanup: session metadata" = "Limpeza manual: metadatos das sesións"; +"Manual cleanup: sessions" = "Limpeza manual: sesións"; +"Manual cleanup: shell snapshots" = "Limpeza manual: instantáneas de shell"; +"Manual cleanup: temporary data" = "Limpeza manual: datos temporais"; +"Missing DeepSeek API key." = "Falta a chave de API de DeepSeek."; +"Music Generation" = "Xeración de música"; +"No %@ utilization data yet." = "Aínda non hai datos de utilización de %@."; +"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)." = "Non se atopou ningunha sesión de Cursor. Inicia sesión en cursor.com desde 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 usas Safari, concede a CodexBar acceso completo ao disco en Axustes do Sistema ▸ Privacidade e seguridade. Tamén podes iniciar sesión en Cursor desde o menú de CodexBar (Engadir / cambiar conta)."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Non se detectou ningún IDE de JetBrains co Asistente de IA. Instala un IDE de JetBrains e activa o Asistente de IA."; +"No OpenCode session cookies found in browsers." = "Non se atoparon cookies de sesión de OpenCode nos navegadores."; +"No available fetch strategy for %@." = "Non hai ningunha estratexia de obtención dispoñible para %@."; +"No available fetch strategy for minimax." = "Non hai ningunha estratexia de obtención dispoñible para MiniMax."; +"No local data found" = "Non se atoparon datos locais"; +"No overview data available." = "Non hai datos de resumo dispoñibles."; +"No providers selected for Overview." = "Non hai provedores seleccionados para o Resumo."; +"No usage breakdown data available." = "Non hai datos de desglose de uso dispoñibles."; +"No utilization data yet." = "Aínda non hai datos de utilización."; +"On pace" = "Ao ritmo previsto"; +"Open Token Plan" = "Abrir o plan de tokens"; +"Open billing" = "Abrir a facturación"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "O token da API de OpenRouter non está configurado. Define a variable de contorno OPENROUTER_API_KEY ou configúrao en Axustes."; +"Pace: %@" = "Ritmo: %@"; +"Pace: %@ · %@" = "Ritmo: %@ · %@"; +"Plan Usage" = "Uso do plan"; +"Projected empty in %@" = "Prevese que se esgote en %@"; +"Projected empty now" = "Prevese que se esgote agora"; +"Quit" = "Saír"; +"Refreshing" = "Actualizando"; +"Request quota: %@ / %@" = "Cota de solicitudes: %@ / %@"; +"Resets %@" = "Restablécese %@"; +"Resets in %@" = "Restablécese en %@"; +"Resets now" = "Restablécese agora"; +"Runs out in %@" = "Esgótase en %@"; +"Runs out now" = "Esgótase agora"; +"Session" = "Sesión"; +"Settings..." = "Axustes..."; +"Sign in with Claude Code..." = "Iniciar sesión con Claude Code..."; +"Source" = "Orixe"; +"State" = "Estado"; +"Status Page" = "Páxina de estado"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Garda varias contas de Google OAuth de Antigravity para cambiar rapidamente."; +"Store multiple DeepSeek API keys." = "Garda varias chaves de API de DeepSeek."; +"Store multiple OpenAI API keys." = "Garda varias chaves de API de OpenAI."; +"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." = "Garda cada conta de Google coa sesión iniciada para cambiar rapidamente en Antigravity. Usa o OAuth de Antigravity.app cando está dispoñible ou ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET como substitución."; +"Switch Account..." = "Cambiar conta..."; +"Text Generation" = "Xeración de texto"; +"Text to Speech" = "Texto a voz"; +"Total: %@" = "Total: %@"; +"Unavailable" = "Non dispoñible"; +"Update ready, restart now?" = "A actualización está lista. Queres reiniciar agora?"; +"Updated %@" = "Actualizado %@"; +"Updated %@h ago" = "Actualizado hai %@ h"; +"Updated %@m ago" = "Actualizado hai %@ min"; +"Updated absolute %@" = "Actualizado %@"; +"Updated just now" = "Actualizado agora mesmo"; +"Updated relative %@" = "Actualizado %@"; +"Usage Dashboard" = "Panel de uso"; +"Weekly" = "Semanal"; +"just now" = "agora mesmo"; +"login_success_notification_body" = "Podes volver á aplicación; a autenticación rematou."; +"login_success_notification_title" = "Inicio de sesión en %@ correcto"; +"minimax_service_coding_plan_search" = "Busca do plan de programación"; +"minimax_service_coding_plan_vlm" = "VLM do plan de programación"; +"minimax_service_image_generation" = "Xeración de imaxes"; +"minimax_service_lyrics_generation" = "Xeración de letras"; +"minimax_service_music_generation" = "Xeración de música"; +"minimax_service_text_generation" = "Xeración de texto"; +"minimax_service_text_to_speech" = "Texto a voz"; +"minimax_usage_amount_format" = "Uso: %@ / %@"; +"minimax_used_percent_format" = "Usado %@"; +"not detected" = "non detectado"; +"providers_on_count" = "%d activados"; +"quota_warning_notification_body" = "Queda %1$@. Acadaches o limiar de aviso do %2$d%% para a cota %3$@."; +"quota_warning_notification_body_with_account" = "Conta %1$@. Queda %2$@. Acadaches o limiar de aviso do %3$d%% para a cota %4$@."; +"quota_warning_notification_title" = "Cota %2$@ de %1$@ baixa"; +"quota_warning_onscreen_alert" = "Amosar unha alerta de texto en pantalla"; +"refresh_adaptive" = "Adaptativa"; +"refresh_on_open_subtitle" = "Obtén o uso máis recente de cada provedor cada vez que abras o menú."; +"refresh_on_open_title" = "Actualizar ao abrir o menú"; +"section_command_line" = "Liña de ordes"; +"section_cost_summary" = "Resumo de custos"; +"section_diagnostics" = "Diagnóstico"; +"section_links" = "Ligazóns"; +"section_privacy" = "Privacidade"; +"section_updates" = "Actualizacións"; +"session_depleted_notification_body" = "Queda un 0%. Avisarémoste cando volva estar dispoñible."; +"session_depleted_notification_title" = "Sesión de %@ esgotada"; +"session_limit_confetti_subtitle" = "Amosa confeti a pantalla completa cando se restableza o uso da sesión."; +"session_limit_confetti_title" = "Confeti do límite da sesión"; +"session_restored_notification_body" = "A cota da sesión volve estar dispoñible."; +"session_restored_notification_title" = "Sesión de %@ restaurada"; +"today" = "hoxe"; +"usage_percent_suffix_left" = "restante"; +"usage_percent_suffix_used" = "usado"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Non se atopou o token da API de z.ai. Define apiKey en ~/.codexbar/config.json ou Z_AI_API_KEY."; +"≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index da64fd2ab6..368a6480d3 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_swedish" = "Svenska"; "language_french" = "Français"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "日本語"; "language_korean" = "한국어"; "language_turkish" = "Türkçe"; @@ -432,6 +433,8 @@ "cost_history_window_title" = "Jendela riwayat"; "cost_history_window_help" = "Menentukan berapa hari log penggunaan lokal yang ditampilkan di menu."; "cost_history_days_title" = "Jendela riwayat: %d hari"; +"cost_comparison_periods_title" = "Tampilkan periode perbandingan yang lebih singkat"; +"cost_comparison_periods_subtitle" = "Tambahkan total 7, 30, dan 90 hari jika termasuk dalam rentang riwayat yang dipilih. Total ini menggunakan kembali pemindaian lokal yang sama."; "cost_auto_refresh_info" = "Penyegaran otomatis: per jam · Batas waktu: 10m"; "refresh_cadence_title" = "Frekuensi penyegaran"; "refresh_cadence_subtitle" = "Seberapa sering CodexBar memeriksa penyedia di latar belakang."; @@ -1123,10 +1126,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Kredit pengaturan ulang batas"; "1 available" = "1 tersedia"; "%d available" = "%d tersedia"; "Next expires %@" = "Berikutnya kedaluwarsa %@"; +"Expires %@" = "Kedaluwarsa %@"; +"No expiry" = "Tidak ada kedaluwarsa"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6466b8dc65..32ca01b05b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -416,6 +416,7 @@ "language_french" = "Francese"; "language_dutch" = "Olandese"; "language_ukrainian" = "Ucraino"; +"language_russian" = "Русский"; "language_vietnamese" = "Vietnamita"; "language_indonesian" = "Indonesiano"; "start_at_login_title" = "Avvia all'accesso"; @@ -432,6 +433,8 @@ "cost_history_window_title" = "Finestra storica"; "cost_history_window_help" = "Imposta quanti giorni di log di utilizzo locali mostrare nel menu."; "cost_history_days_title" = "Finestra storica: %d giorni"; +"cost_comparison_periods_title" = "Mostra periodi di confronto più brevi"; +"cost_comparison_periods_subtitle" = "Aggiungi i totali di 7, 30 e 90 giorni quando rientrano nell'intervallo di cronologia selezionato. Questi totali riutilizzano la stessa scansione locale."; "cost_auto_refresh_info" = "Aggiornamento automatico: ogni ora · Timeout: 10 min"; "refresh_cadence_title" = "Frequenza aggiornamento"; "refresh_cadence_subtitle" = "Con quale frequenza CodexBar interroga i provider in background."; @@ -1123,10 +1126,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Crediti di reimpostazione limite"; "1 available" = "1 disponibile"; "%d available" = "%d disponibili"; "Next expires %@" = "La prossima scade %@"; +"Expires %@" = "Scade %@"; +"No expiry" = "Nessuna scadenza"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 9dbd00a893..44b6d8e5ea 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -408,6 +408,7 @@ "language_french" = "フランス語"; "language_german" = "ドイツ語"; "language_ukrainian" = "ウクライナ語"; +"language_russian" = "Русский"; "language_japanese" = "日本語"; "language_korean" = "韓国語"; "language_italian" = "イタリア語"; @@ -427,6 +428,8 @@ "cost_history_window_help" = "メニューに表示するローカル使用ログの日数を設定します。"; "cost_history_days_title" = "履歴期間: %d 日"; "cost_auto_refresh_info" = "自動更新: 1 時間ごと · タイムアウト: 10 分"; +"cost_comparison_periods_title" = "短い比較期間を表示"; +"cost_comparison_periods_subtitle" = "選択した履歴期間に収まる場合、7日、30日、90日の合計を追加します。これらの合計には同じローカルスキャンを再利用します。"; "refresh_cadence_title" = "更新間隔"; "refresh_cadence_subtitle" = "CodexBar がバックグラウンドでプロバイダをポーリングする頻度です。"; "manual_refresh_hint" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; @@ -1115,10 +1118,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "上限リセットクレジット"; "1 available" = "1件利用可能"; "%d available" = "%d件利用可能"; "Next expires %@" = "次回の有効期限:%@"; +"Expires %@" = "%@ に期限切れ"; +"No expiry" = "有効期限なし"; "Other (%d items)" = "その他(%d項目)"; "Expand" = "展開"; "Collapse" = "折りたたむ"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 03dc9fea39..1e603e1cd6 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_swedish" = "스웨덴어"; "language_french" = "프랑스어"; "language_ukrainian" = "우크라이나어"; +"language_russian" = "Русский"; "language_japanese" = "일본어"; "language_korean" = "한국어"; "language_italian" = "Italiano"; @@ -425,6 +426,8 @@ "cost_history_window_help" = "메뉴에 표시할 로컬 사용량 로그 일수를 설정합니다."; "cost_history_days_title" = "기록 범위: %d일"; "cost_auto_refresh_info" = "자동 새로 고침: 매시간 · 시간 초과: 10분"; +"cost_comparison_periods_title" = "더 짧은 비교 기간 표시"; +"cost_comparison_periods_subtitle" = "선택한 기록 범위에 포함되는 경우 7일, 30일, 90일 합계를 추가합니다. 이 합계에는 동일한 로컬 스캔을 재사용합니다."; "refresh_cadence_title" = "새로 고침 주기"; "refresh_cadence_subtitle" = "CodexBar가 백그라운드에서 공급자를 폴링하는 빈도입니다."; "manual_refresh_hint" = "자동 새로 고침이 꺼져 있습니다. 메뉴의 새로 고침 명령을 사용하세요."; @@ -1083,10 +1086,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "한도 재설정 크레딧"; "1 available" = "1회 사용 가능"; "%d available" = "%d회 사용 가능"; "Next expires %@" = "다음 만료: %@"; +"Expires %@" = "%@에 만료"; +"No expiry" = "만료 없음"; "Other (%d items)" = "기타(%d개 항목)"; "Expand" = "펼치기"; "Collapse" = "접기"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index edad9f3c79..fb53ca9c9e 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -407,6 +407,7 @@ "language_german" = "Duits"; "language_french" = "Frans"; "language_ukrainian" = "Oekraïens"; +"language_russian" = "Русский"; "language_japanese" = "Japans"; "language_korean" = "Koreaans"; "language_italian" = "Italiano"; @@ -430,6 +431,8 @@ "cost_history_window_help" = "Stelt in hoeveel dagen lokale gebruikslogboeken in het menu verschijnen."; "cost_history_days_title" = "Geschiedenisvenster: %d dagen"; "cost_auto_refresh_info" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; +"cost_comparison_periods_title" = "Kortere vergelijkingsperioden tonen"; +"cost_comparison_periods_subtitle" = "Voegt totalen voor 7, 30 en 90 dagen toe wanneer ze binnen het geselecteerde geschiedenisvenster vallen. Deze totalen gebruiken dezelfde lokale scan."; "refresh_cadence_title" = "Cadans vernieuwen"; "refresh_cadence_subtitle" = "Hoe vaak CodexBar providers op de achtergrond ondervraagt."; "manual_refresh_hint" = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; @@ -1114,10 +1117,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Limietresetcredits"; "1 available" = "1 beschikbaar"; "%d available" = "%d beschikbaar"; "Next expires %@" = "Volgende verloopt %@"; +"Expires %@" = "Verloopt %@"; +"No expiry" = "Geen vervaldatum"; "Other (%d items)" = "Overig (%d onderdelen)"; "Expand" = "Uitvouwen"; "Collapse" = "Invouwen"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index f9ec8acdf7..09c348868d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_french" = "Francuski"; "language_dutch" = "Niderlandzki"; "language_ukrainian" = "Ukraiński"; +"language_russian" = "Русский"; "language_vietnamese" = "Wietnamski"; "language_italian" = "Włoski"; "language_indonesian" = "Indonezyjski"; @@ -432,6 +433,8 @@ "cost_history_window_title" = "Zakres historii"; "cost_history_window_help" = "Ustawia, ile dni lokalnych dzienników użycia pokazać w menu."; "cost_history_days_title" = "Zakres historii: %d dni"; +"cost_comparison_periods_title" = "Pokaż krótsze okresy porównawcze"; +"cost_comparison_periods_subtitle" = "Dodaj sumy z 7, 30 i 90 dni, gdy mieszczą się w wybranym zakresie historii. Sumy te wykorzystują to samo skanowanie lokalne."; "cost_auto_refresh_info" = "Auto-odświeżanie: co godzinę · Limit czasu: 10 min"; "refresh_cadence_title" = "Częstotliwość odświeżania"; "refresh_cadence_subtitle" = "Jak często CodexBar odpyta dostawców w tle."; @@ -1123,10 +1126,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Kredyty resetowania limitu"; "1 available" = "1 dostępny"; "%d available" = "%d dostępne"; "Next expires %@" = "Następny wygasa %@"; +"Expires %@" = "Wygasa %@"; +"No expiry" = "Brak terminu ważności"; "byte_unit_byte" = "bajt"; "byte_unit_bytes" = "bajty"; "byte_unit_kilobyte" = "kilobajt"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index c528e5b38c..73c4a73438 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -408,6 +408,7 @@ "language_german" = "Deutsch"; "language_french" = "Francês"; "language_ukrainian" = "Ucraniano"; +"language_russian" = "Русский"; "language_japanese" = "Japonês"; "language_korean" = "Coreano"; "language_italian" = "Italiano"; @@ -427,6 +428,8 @@ "cost_history_window_help" = "Define quantos dias de logs de uso locais aparecem 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"; +"cost_comparison_periods_title" = "Mostrar períodos de comparação mais curtos"; +"cost_comparison_periods_subtitle" = "Adiciona totais de 7, 30 e 90 dias quando couberem na janela de histórico selecionada. Esses totais reutilizam a mesma varredura local."; "refresh_cadence_title" = "Cadência de atualização"; "refresh_cadence_subtitle" = "Frequência com que o CodexBar consulta provedores em segundo plano."; "manual_refresh_hint" = "A atualização automática está desativada; use Atualizar no menu."; @@ -1115,10 +1118,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Créditos de redefinição de limite"; "1 available" = "1 disponível"; "%d available" = "%d disponíveis"; "Next expires %@" = "Próximo expira %@"; +"Expires %@" = "Expira %@"; +"No expiry" = "Sem validade"; "Other (%d items)" = "Outros (%d itens)"; "Expand" = "Expandir"; "Collapse" = "Recolher"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..62afcfdc6c --- /dev/null +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,1156 @@ +/* Russian 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 из Keychain через Security.framework."; +"Alternatively, set a custom path in Settings." = "Или задайте собственный путь в настройках."; +"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." = "Автоматически импортирует 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." = "Автоматически открывает CodexBar при запуске 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)." = "Выберите, что показывать в строке меню: темп сравнивает фактическое использование с ожидаемым."; +"Choose which Codex account CodexBar should follow." = "Выберите аккаунт Codex, за которым должен следить CodexBar."; +"Choose which window drives the menu bar percent." = "Выберите окно, по которому рассчитывается процент в строке меню."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI не найден"; +"Claude binary" = "Бинарный файл Claude"; +"Claude cookies" = "Cookie 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" = "Поддержание жизненного цикла CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar не может показать значок в строке меню"; +"CodexBar could not read managed account storage. " = "CodexBar не удалось прочитать хранилище управляемых аккаунтов. "; +"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или вставьте снимок 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: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Стоимость"; +"Could not add Codex account" = "Не удалось добавить аккаунт Codex"; +"Could not open Terminal for Gemini" = "Не удалось открыть Terminal для Gemini."; +"Could not start claude /login" = "Не удалось запустить claude /login"; +"Could not start codex login" = "Не удалось запустить вход в Codex"; +"Could not switch system account" = "Не удалось переключить системный аккаунт"; +"Credits" = "Кредиты"; +"Individual credits" = "Индивидуальные кредиты"; +"Workspace" = "Рабочая область"; +"Credits history" = "История кредитов"; +"Cursor login failed" = "Не удалось войти в Cursor"; +"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" = "Эффективный PATH"; +"Email" = "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." = "Как часто CodexBar опрашивает провайдеров в фоновом режиме."; +"Inactive" = "Неактивный"; +"Install CLI" = "Установить CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Установите Claude CLI (npm i -g @anthropic-ai/claude-code) и повторите попытку."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Установите Codex CLI (npm i -g @openai/codex) и повторите попытку."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Установите Gemini CLI (npm i -g @google/gemini-cli) и повторите попытку."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Установите JetBrains IDE с включённым AI Assistant, затем обновите CodexBar."; +"JetBrains AI is ready" = "JetBrains AI готов"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Поддерживать CLI-сеансы активными"; +"Keyboard shortcut" = "Сочетание клавиш"; +"Keychain access" = "Доступ к Keychain"; +"Keychain prompt policy" = "Политика запросов Keychain"; +"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)" = "PATH login shell (снимок при запуске)"; +"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." = "Скрывать email-адреса в строке меню и интерфейсе меню."; +"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, войдите в аккаунт, затем обновите CodexBar."; +"Open Browser" = "Открыть браузер"; +"Open Coding Plan" = "Открыть Coding Plan"; +"Open Console" = "Открыть консоль"; +"Open Dashboard" = "Открыть дашборд"; +"Open Mistral Admin" = "Открыть Mistral Admin"; +"Open Menu Bar Settings" = "Открыть настройки строки меню"; +"Open Ollama Settings" = "Открыть настройки Ollama"; +"Open Terminal" = "Открыть Terminal"; +"Open Usage Page" = "Открыть страницу использования"; +"Open Warp API Key Guide" = "Открыть руководство по API-ключу Warp"; +"Open menu" = "Открыть меню"; +"Open token file" = "Открыть файл токена"; +"OpenAI cookies" = "Cookie OpenAI"; +"OpenAI web extras" = "Доп. данные OpenAI Web"; +"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" = "План"; +"Plan Usage" = "Использование плана"; +"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" = "Закрыть CodexBar"; +"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) из CodexBar? Его управляемый каталог Codex будет удалён."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Удалить \\(email) из CodexBar? Его управляемый каталог 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."; +"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 with Claude Code..." = "Войти через Claude Code…"; +"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 sessionKey Claude или OAuth-токены доступа."; +"Store multiple Abacus AI Cookie headers." = "Хранить несколько Cookie-заголовков Abacus AI."; +"Store multiple Augment Cookie headers." = "Хранить несколько Cookie-заголовков Augment."; +"Store multiple Cursor Cookie headers." = "Хранить несколько Cookie-заголовков Cursor."; +"Store multiple Factory Cookie headers." = "Хранить несколько Cookie-заголовков Factory."; +"Store multiple MiniMax Cookie headers." = "Хранить несколько Cookie-заголовков MiniMax."; +"Store multiple Mistral Cookie headers." = "Хранить несколько Cookie-заголовков Mistral."; +"Store multiple Ollama Cookie headers." = "Хранить несколько Cookie-заголовков Ollama."; +"Store multiple OpenCode Cookie headers." = "Хранить несколько Cookie-заголовков OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Хранить несколько Cookie-заголовков OpenCode Go."; +"Stored in the CodexBar config file." = "Хранится в конфигурационном файле CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Хранится в ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Хранится в ~/.codexbar/config.json. Создайте ключ на kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Хранится в ~/.codexbar/config.json. Вставьте ключ с дашборда Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Хранится в ~/.codexbar/config.json. Вставьте API-ключ Coding Plan из Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Хранится в ~/.codexbar/config.json. Вставьте API-ключ MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Хранится в ~/.codexbar/config.json. Также можно задать KILO_API_KEY или "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Сохраняет локальную историю использования Codex (8 недель) для персонализации прогнозов Pace."; +"Surprise me" = "Удиви меня"; +"Switcher shows icons" = "Переключатель показывает значки"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Создать symlink CodexBarCLI как codexbar в /usr/local/bin и /opt/homebrew/bin."; +"System" = "Система"; +"Temporarily shows the loading animation after the next refresh." = "Временно показывает анимацию загрузки после следующего обновления."; +"terminal_app_subtitle" = "Терминал для действия «Открыть Terminal»"; +"terminal_app_title" = "Терминал по умолчанию"; +"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)" = "История использования (30 дней)"; +"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" = "Обнаружено: %@. Используйте AI-помощник один раз, чтобы сгенерировать данные о квотах, затем обновите CodexBar."; +"jetbrains_detected_select" = "Обнаружено: %@. Выберите предпочитаемый IDE в настройках, затем обновите CodexBar."; +"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" = "CodexBar нашёл несколько рабочих областей для %@. Выберите рабочую область для добавления."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Выберите до %@ провайдеров"; +"remove_account_message" = "Удалить %@ из CodexBar? Его управляемый каталог Codex будет удалён."; +"version_format" = "Версия %@"; +"vertex_ai_login_instructions" = "Чтобы отслеживать использование Vertex AI, авторизуйтесь через Google Cloud.\n\n1. Откройте Terminal\n2. Запустите: gcloud auth application-default login\n3. Следуйте инструкциям в браузере\n4. Укажите проект: gcloud config set project PROJECT_ID\n\nОткрыть Terminal сейчас?"; +"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."; + +/* 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_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_swedish" = "Svenska"; +"language_french" = "Français"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "日本語"; +"language_korean" = "한국어"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; +"start_at_login_title" = "Запускать при входе"; +"start_at_login_subtitle" = "Автоматически открывает CodexBar при запуске Mac."; +"show_cost_summary" = "Показать сводку расходов"; +"show_cost_summary_subtitle" = "Читает локальные журналы использования. Показывает в меню сегодняшние расходы и выбранное окно истории."; +"cost_summary_style_title" = "Стиль отображения"; +"cost_summary_style_inline" = "Только в строке"; +"cost_summary_style_submenu" = "Только подменю"; +"cost_summary_style_both" = "Оба"; +"cost_summary_style_inline_help" = "Показывает сводку затрат прямо в главном меню."; +"cost_summary_style_submenu_help" = "Вместо этого отображается подробное подменю «Стоимость»."; +"cost_summary_style_both_help" = "Показывает как сводку главного меню, так и подробное подменю «Стоимость»."; +"cost_history_window_title" = "Окно истории"; +"cost_history_window_help" = "Задаёт, за сколько дней показывать локальные журналы использования в меню."; +"cost_history_days_title" = "Окно истории: %d дней"; +"cost_auto_refresh_info" = "Автоматическое обновление: каждый час · Тайм-аут: 10 минут"; +"cost_comparison_periods_title" = "Показывать более короткие периоды сравнения"; +"cost_comparison_periods_subtitle" = "Добавляет итоги за 7, 30 и 90 дней, если они входят в выбранный период истории. Для этих итогов используется то же локальное сканирование."; +"refresh_cadence_title" = "Частота обновления"; +"refresh_cadence_subtitle" = "Как часто CodexBar опрашивает провайдеров в фоновом режиме."; +"manual_refresh_hint" = "Автообновление отключено; используйте команду меню «Обновить»."; +"refresh_on_open_title" = "Обновить при открытии меню"; +"refresh_on_open_subtitle" = "Получайте последние данные об использовании для каждого провайдера каждый раз, когда вы открываете меню."; +"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_onscreen_alert" = "Показывать текстовое оповещение на экране"; +"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" = "Выйти из CodexBar"; + +/* 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` работает в Terminal. Если macOS заблокировала или переместила `codex` в Корзину, удалите старые дублирующиеся установки, запустите `npm install -g --include=optional @openai/codex@latest` и повторите попытку."; +"codex_login_output" = "вывод входа Codex:"; +"managed_login_missing_email" = "Вход в Codex выполнен, но email аккаунта недоступен. Повторите попытку после полного входа в аккаунт."; +"login_success_notification_title" = "%@ вход успешен"; +"login_success_notification_body" = "Можно вернуться в приложение; авторизация завершена."; +"workspace_selection_cancelled" = "CodexBar обнаружил несколько рабочих областей, но рабочая область не выбрана."; +"unsafe_managed_home" = "CodexBar отказался изменять неожиданный управляемый каталог: %@"; +"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-ключа)"; + +/* 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" = "Заменить декоративные индикаторы значками провайдеров и процентом."; +"hide_critters_title" = "Скрыть декоративные индикаторы"; +"hide_critters_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" = "Показывать все токен-аккаунты"; +"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" = "Провайдеры не выбраны"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Сочетание клавиш"; +"open_menu_shortcut_title" = "Открыть меню"; +"open_menu_shortcut_subtitle" = "Вызывать меню строки меню из любого места."; +"install_cli" = "Установить CLI"; +"install_cli_subtitle" = "Создать symlink CodexBarCLI как codexbar в /usr/local/bin и /opt/homebrew/bin."; +"cli_not_found" = "CodexBarCLI не найден в комплекте приложения."; +"no_writable_bin_dirs" = "Не найдено доступных для записи bin-каталогов."; +"show_debug_settings_title" = "Показать настройки отладки"; +"show_debug_settings_subtitle" = "Показывать инструменты диагностики на вкладке «Отладка»."; +"surprise_me_title" = "Удиви меня"; +"surprise_me_subtitle" = "Включите, если хотите немного оживить индикаторы агентов в строке меню."; +"session_limit_confetti_title" = "Конфетти при сбросе лимита сеанса"; +"session_limit_confetti_subtitle" = "Показывать полноэкранное конфетти при сбросе лимита сеанса."; +"weekly_limit_confetti_title" = "Конфетти при сбросе недельного лимита"; +"weekly_limit_confetti_subtitle" = "Показывать полноэкранное конфетти при сбросе недельного лимита."; +"hide_personal_info_title" = "Скрыть личную информацию"; +"hide_personal_info_subtitle" = "Скрывает email-адреса в строке меню и интерфейсе меню."; +"show_provider_storage_usage_title" = "Показать использование хранилища провайдера"; +"show_provider_storage_usage_subtitle" = "Показывать использование локального диска в меню. Сканирует известные пути, принадлежащие провайдеру, в фоновом режиме."; +"section_keychain_access" = "Доступ к Keychain"; +"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" = "Email"; +"check_updates_auto" = "Автоматическая проверка обновлений"; +"update_channel" = "Канал обновлений"; +"check_for_updates" = "Проверить наличие обновлений…"; +"updates_unavailable" = "Обновления недоступны в этой сборке."; +"copyright" = "© 2026 Peter Steinberger. Лицензия 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 login shell при запуске (короткий тайм-аут)."; +"codex_binary" = "Бинарный файл Codex"; +"claude_binary" = "Бинарный файл Claude"; +"effective_path" = "Эффективный PATH"; +"unavailable" = "Недоступно"; +"login_shell_path" = "PATH login shell (снимок при запуске)"; +"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 может блокировать приложения строки меню в разделе «Системные настройки» → «Строка меню» → «Разрешить в строке меню». CodexBar запущен, но macOS может скрывать его значок. Откройте настройки строки меню и включите CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Автоматически"; +"metric_pref_primary" = "Основной"; +"metric_pref_secondary" = "Вторичный"; +"metric_pref_tertiary" = "Третичный"; +"metric_pref_extra_usage" = "Дополнительное использование"; +"metric_pref_average" = "Среднее"; +"metric_mistral_payg" = "Оплата по мере использования"; +"metric_mistral_monthly_plan" = "Ежемесячный план"; + +/* Display modes */ +"display_mode_percent" = "Процент"; +"display_mode_pace" = "Темп"; +"display_mode_both" = "Оба"; +"display_mode_reset_time" = "Время сброса"; +"display_mode_percent_desc" = "Показывать оставшийся или израсходованный процент (например, 45%)"; +"display_mode_pace_desc" = "Показывать индикатор темпа (например, +5%)"; +"display_mode_both_desc" = "Показывать и процент, и темп (например, 45% · +5%)"; +"display_mode_reset_time_desc" = "Показывать время сброса для выбранного показателя (например, ↻ 15:56)."; + +/* Provider status */ +"status_operational" = "Работает"; +"status_degraded" = "Сниженная производительность"; +"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 мин."; +"refresh_adaptive" = "Адаптивно"; + +/* 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." = "API-токен OpenRouter не настроен. Задайте переменную окружения OPENROUTER_API_KEY или настройте его в настройках."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "API-токен z.ai не найден. Задайте apiKey в ~/.codexbar/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." = "Хранить несколько API-ключей OpenAI."; +"Admin API key" = "Admin 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" = "Открыть 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 не найден. Войдите на cursor.com в Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Яндекс.Браузере, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX или Edge Canary. Если используете Safari, выдайте CodexBar полный доступ к диску в «Системные настройки» ▸ «Конфиденциальность и безопасность». Также можно войти в Cursor из меню CodexBar: «Добавить/переключить аккаунт»."; +"No OpenCode session cookies found in browsers." = "В браузерах не найдены cookie сеанса OpenCode."; +"No available fetch strategy for %@." = "Нет доступной стратегии получения для %@."; +"Today" = "Сегодня"; +"Today tokens" = "Токены сегодня"; +"30d cost" = "Стоимость за 30 дн."; +"30d tokens" = "Токены за 30 дн."; +"Latest tokens" = "Последние токены"; +"Top model" = "Топ-модель"; +"Storage" = "Хранение"; +"Add Account..." = "Добавить аккаунт…"; +"Usage Dashboard" = "Дашборд использования"; +"Status Page" = "Страница статуса"; +"Open Status Page" = "Открыть страницу статуса"; +"Settings..." = "Настройки…"; +"About CodexBar" = "О CodexBar"; +"Quit" = "Выйти"; +"Last %d day" = "Последний %d день"; +"Last %d days" = "Последние %d дн."; +"%@ tokens" = "%@ токенов"; +"Latest billing day" = "Последний день биллинга"; +"Latest billing day (%@)" = "Последний день биллинга (%@)"; +"%@ left" = "%@ осталось"; +"Resets %@" = "Сброс %@"; +"Resets in %@" = "Сбрасывается через %@"; +"Resets now" = "Сбрасывается сейчас"; +"reset_tomorrow_format" = "завтра, %@"; +"Lasts until reset" = "Действует до сброса"; +"Updated %@" = "Обновлено %@"; +"Updated relative %@" = "Обновлено %@"; +"Updated absolute %@" = "Обновлено %@"; +"Updated %@h ago" = "Обновлено %@ч назад"; +"Updated %@m ago" = "Обновлено %@ мин. назад"; +"Updated just now" = "Обновлено только что"; +"Projected empty in %@" = "По прогнозу закончится через %@"; +"Runs out in %@" = "Закончится через %@"; +"Pace: %@" = "Темп: %@"; +"Pace: %@ · %@" = "Темп: %@ · %@"; +"1.5× headroom" = "Запас 1,5×"; +"%@ · %@" = "%@ · %@"; +"≈ %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." = "Хранить несколько API-ключей DeepSeek."; +"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." = "Автоматически сначала использует локальный API IDE, затем Google OAuth, когда IDE закрыта."; +"Login with Google" = "Войти через Google"; + +/* Popup panels */ +"No usage configured." = "Использование не настроено."; +"Quota" = "Квота"; +"tokens" = "токены"; +"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" = "Данные ежедневного биллинга фиксируются в 07:00 UTC"; +"%@ 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" = "Использование квоты"; +"Your spend" = "Ваши расходы"; +"%.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" = "Тренд расходов API-ключа OpenRouter"; +"CrossModel API spend trend" = "Тренд расходов CrossModel API"; +"z.ai hourly token trend" = "Почасовой тренд токенов z.ai"; +"MiniMax 30 day token usage trend" = "Тренд использования токенов MiniMax за 30 дней"; +"Today cash" = "Расходы сегодня"; +"DeepSeek 30 day token usage trend" = "Тренд использования токенов DeepSeek за 30 дней"; +"cache-hit input" = "ввод с попаданием в кэш"; +"cache-miss input" = "ввод без попадания в кэш"; +"output" = "вывод"; +"Requests" = "Запросы"; +"Reported by OpenAI Admin API organization usage." = "По данным OpenAI Admin API об использовании организации."; +"Reported by Mistral billing usage." = "По данным биллинга Mistral."; +"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. Использует Antigravity.app OAuth, если он доступен, или ANTIGRAVITY_OAUTH_CLIENT_ID и ANTIGRAVITY_OAUTH_CLIENT_SECRET как переопределение."; +"Manual cleanup: past sessions" = "Ручная очистка: прошлые сеансы"; +"Clearing removes past resume, continue, and rewind history." = "Очистка удалит историю resume, continue и rewind."; +"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." = "Очистка удалит оставшиеся runtime-снимки 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 элементов"; +"Other (%d items)" = "Другое (%d шт.)"; +"Expand" = "Развернуть"; +"Collapse" = "Свернуть"; +"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" = "Coding Plan VLM"; +"minimax_service_coding_plan_search" = "Поиск Coding Plan"; + +/* 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. Также можно задать через 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" = "Время входа в Antigravity истекло"; +"Auth source" = "Источник авторизации"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Автоматически импортирует cookie браузера из Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматически импортирует данные сеанса Windsurf из localStorage браузера 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" = "API-ключ 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." = "Выберите хост Moonshot/Kimi API для международных аккаунтов или аккаунтов в материковом Китае."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar не может заменить системный аккаунт, который настроен только через API-ключ."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar не удалось найти сохранённые данные авторизации для этого аккаунта. Авторизуйтесь заново и повторите попытку."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar не удалось прочитать хранилище управляемых аккаунтов. Восстановите хранилище перед добавлением другого аккаунта."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar не удалось прочитать сохранённые данные авторизации для этого аккаунта. Авторизуйтесь заново и повторите попытку."; +"CodexBar could not read the current system account on this Mac." = "CodexBar не удалось прочитать текущий системный аккаунт на этом Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar не удалось заменить текущую авторизацию Codex на этом Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar не удалось безопасно сохранить текущий системный аккаунт перед переключением."; +"CodexBar could not save the current system account before switching." = "CodexBar не удалось сохранить текущий системный аккаунт перед переключением."; +"CodexBar could not update managed account storage." = "CodexBar не удалось обновить хранилище управляемых аккаунтов."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar обнаружил другой управляемый аккаунт, который уже использует текущий системный аккаунт. Устраните дублирующий аккаунт перед переключением."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar запросит у 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." = "CodexBar запросит у macOS Keychain OAuth-токен Claude Code, чтобы получить данные об использовании Claude. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Amp, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у 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." = "CodexBar запросит у macOS Keychain Cookie-заголовок Claude, чтобы получить данные об использовании Claude Web. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Cursor, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Factory, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar запросит у 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." = "CodexBar запросит у 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." = "CodexBar запросит у macOS Keychain токен авторизации Kimi, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-токен MiniMax, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у 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." = "CodexBar запросит у 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." = "CodexBar запросит у macOS Keychain Cookie-заголовок OpenCode, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-ключ Synthetic, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-токен z.ai, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"Could not open Cursor login in your browser." = "Не удалось открыть вход в Cursor в браузере."; +"Could not open browser for Antigravity" = "Не удалось открыть браузер для Antigravity."; +"Credits used" = "Использовано кредитов"; +"Day" = "День"; +"Deployment" = "Развёртывание"; +"Drag to reorder" = "Перетащите, чтобы изменить порядок"; +"Sort providers alphabetically" = "Сортировать провайдеров по алфавиту"; +"Sort providers alphabetically (enabled first)" = "Сортировать провайдеров по алфавиту (включённые сначала)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Отсортировано по алфавиту (включённые сначала) — нажмите, чтобы использовать свой порядок."; +"Endpoint" = "Эндпоинт"; +"Enterprise host" = "Корпоративный хост"; +"Extra usage balance: %@" = "Баланс доп. использования: %@"; +"Keychain Access Required" = "Требуется доступ к Keychain"; +"keychain_prompt_learn_more" = "Узнать больше…"; +"keychain_prompt_privacy_note" = "macOS, а не CodexBar, обрабатывает любой ввод пароля для входа в Mac. Вы можете в любой момент отключить доступ к Keychain в «Настройки» → «Дополнительно»."; +"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 Dashboard"; +"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" = "Открыть API-ключи Ollama"; +"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." = "Необязательно. Применяется к настроенному Admin 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" = "Организации"; +"Organization ID" = "Идентификатор организации"; +"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 отключён на вкладке «Расширенные», поэтому импорт 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." = "Вставьте 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" = "Идентификатор проекта"; +"Re-auth" = "Повторная авторизация"; +"Re-login at claude.ai" = "Повторно войти на claude.ai"; +"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 в браузере, затем обновите Cursor в CodexBar."; +"Simulated error text" = "Имитированный текст ошибки"; +"StepFun platform account (phone number or email)." = "Аккаунт платформы StepFun (номер телефона или адрес электронной почты)."; +"Stored in ~/.codexbar/config.json." = "Хранится в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Хранится в ~/.codexbar/config.json. AZURE_OPENAI_API_KEY также поддерживается."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Хранится в ~/.codexbar/config.json. Для официального API Kimi используйте Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Хранится в ~/.codexbar/config.json. Получите API-ключ в консоли Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Хранится в ~/.codexbar/config.json. Получите API-ключ в настройках Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на 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." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на openrouter.ai/settings/keys и задайте там лимит расходов, чтобы включить отслеживание квоты API-ключа."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Хранится в ~/.codexbar/config.json. В Warp откройте «Настройки» > «Платформа» > «Ключи API», затем создайте ключ."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Хранится в ~/.codexbar/config.json. Для метрик требуется доступ к Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Хранится в ~/.codexbar/config.json. Предпочтителен OPENAI_ADMIN_KEY; OPENAI_API_KEY по-прежнему работает."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Хранится в ~/.codexbar/config.json. Требуется API-ключ Anthropic Admin."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Хранится в ~/.codexbar/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`)." = "Хранится в ~/.codexbar/config.json. Также можно задать CODEBUFF_API_KEY или разрешить CodexBar прочитать ~/.config/manicode/credentials.json, который создаёт `codebuff login`."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Хранится в ~/.codexbar/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)." = "Хранится в ~/.codexbar/config.json. Также можно задать KILO_API_KEY или ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie чата T3"; +"Team mode" = "Командный режим"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Этот аккаунт больше недоступен в CodexBar. Обновите список аккаунтов и повторите попытку."; +"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" = "JSON-пакет сеанса Windsurf"; +"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." = "codex login завершился со статусом %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" = "Поиск провайдеров"; + +"language_vietnamese" = "Tiếng Việt"; +"language_indonesian" = "Bahasa Indonesia"; + +"Request quota: %@ / %@" = "Квота запросов: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Кредиты сброса лимита"; +"1 available" = "1 доступен"; +"%d available" = "%d доступен"; +"Next expires %@" = "Следующий срок действия истекает %@"; +"Expires %@" = "Истекает %@"; +"No expiry" = "Без срока действия"; +"byte_unit_byte" = "байт"; +"byte_unit_bytes" = "байты"; +"byte_unit_kilobyte" = "килобайт"; +"byte_unit_kilobytes" = "килобайты"; +"byte_unit_megabyte" = "мегабайт"; +"byte_unit_megabytes" = "мегабайты"; +"byte_unit_gigabyte" = "гигабайт"; +"byte_unit_gigabytes" = "гигабайты"; + +/* Settings sidebar redesign */ +"Enable" = "Включить"; +"Disable" = "Отключить"; +"providers_on_count" = "%d включено"; +"section_cost_summary" = "Сводная стоимость"; +"section_command_line" = "Командная строка"; +"section_privacy" = "Конфиденциальность"; +"section_diagnostics" = "Диагностика"; +"section_updates" = "Обновления"; +"section_links" = "Ссылки"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index bdabd2d439..03428d64ac 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -409,6 +409,7 @@ "language_swedish" = "Svenska"; "language_french" = "Franska"; "language_ukrainian" = "Ukrainska"; +"language_russian" = "Русский"; "language_japanese" = "Japanska"; "language_korean" = "Koreanska"; "language_turkish" = "Türkçe"; @@ -430,6 +431,8 @@ "cost_history_window_help" = "Anger hur många dagar med lokala användningsloggar som visas i menyn."; "cost_history_days_title" = "Historikfönster: %d dagar"; "cost_auto_refresh_info" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; +"cost_comparison_periods_title" = "Visa kortare jämförelseperioder"; +"cost_comparison_periods_subtitle" = "Lägger till summor för 7, 30 och 90 dagar när de ryms i det valda historikfönstret. Summorna återanvänder samma lokala genomsökning."; "refresh_cadence_title" = "Uppdateringsintervall"; "refresh_cadence_subtitle" = "Hur ofta CodexBar kontrollerar leverantörer i bakgrunden."; "manual_refresh_hint" = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; @@ -1113,10 +1116,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Krediter för gränsåterställning"; "1 available" = "1 tillgänglig"; "%d available" = "%d tillgängliga"; "Next expires %@" = "Nästa upphör %@"; +"Expires %@" = "Upphör %@"; +"No expiry" = "Inget utgångsdatum"; "Other (%d items)" = "Övrigt (%d objekt)"; "Expand" = "Expandera"; "Collapse" = "Fäll ihop"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 98a85a44d9..f537c2d1b9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -411,6 +411,7 @@ "language_swedish" = "สเวนสกา"; "language_french" = "ฝรั่งเศส"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "ภาษาญี่ปุ่น"; "language_korean" = "เกาหลี"; "language_turkish" = "Türkçe"; @@ -430,6 +431,8 @@ "cost_history_window_title" = "กรอบเวลาประวัติ"; "cost_history_window_help" = "กำหนดจำนวนวันของบันทึกการใช้งานในเครื่องที่จะแสดงในเมนู"; "cost_history_days_title" = "กรอบเวลาประวัติ: %d วัน"; +"cost_comparison_periods_title" = "แสดงช่วงเปรียบเทียบที่สั้นกว่า"; +"cost_comparison_periods_subtitle" = "เพิ่มยอดรวม 7, 30 และ 90 วันเมื่ออยู่ภายในกรอบเวลาประวัติที่เลือก โดยใช้การสแกนในเครื่องเดียวกัน"; "cost_auto_refresh_info" = "รีเฟรชอัตโนมัติ: รายชั่วโมง · หมดเวลา: 10m"; "refresh_cadence_title" = "จังหวะการรีเฟรช"; "refresh_cadence_subtitle" = "ความถี่ในการ CodexBar ผู้ให้บริการโพลในเบื้องหลัง"; @@ -1124,10 +1127,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "เครดิตรีเซ็ตขีดจำกัด"; "1 available" = "1 รายการ"; "%d available" = "%d รายการ"; "Next expires %@" = "รายการถัดไปหมดอายุ %@"; +"Expires %@" = "หมดอายุ %@"; +"No expiry" = "ไม่มีวันหมดอายุ"; "byte_unit_byte" = "ไบต์"; "byte_unit_bytes" = "ไบต์"; "byte_unit_kilobyte" = "กิโลไบต์"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 26b945b6d6..308b2740a1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -409,6 +409,7 @@ "language_german" = "Deutsch"; "language_french" = "Français"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_vietnamese" = "Tiếng Việt"; "language_japanese" = "日本語"; "language_korean" = "한국어"; @@ -430,6 +431,8 @@ "cost_history_window_title" = "Geçmiş penceresi"; "cost_history_window_help" = "Menüde kaç günlük yerel kullanım günlüğünün gösterileceğini belirler."; "cost_history_days_title" = "Geçmiş penceresi: %d gün"; +"cost_comparison_periods_title" = "Daha kısa karşılaştırma dönemlerini göster"; +"cost_comparison_periods_subtitle" = "Seçilen geçmiş aralığına sığdığında 7, 30 ve 90 günlük toplamları ekler. Bu toplamlar aynı yerel taramayı yeniden kullanır."; "cost_auto_refresh_info" = "Otomatik yenileme: saatlik · Zaman aşımı: 10 dk"; "refresh_cadence_title" = "Yenileme sıklığı"; "refresh_cadence_subtitle" = "CodexBar'ın arka planda sağlayıcıları ne sıklıkla sorgulayacağı."; @@ -1121,10 +1124,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Limit Sıfırlama Kredileri"; "1 available" = "1 kullanılabilir"; "%d available" = "%d kullanılabilir"; "Next expires %@" = "Sonraki sona erme %@"; +"Expires %@" = "%@ sona eriyor"; +"No expiry" = "Son kullanma yok"; "byte_unit_byte" = "bayt"; "byte_unit_bytes" = "bayt"; "byte_unit_kilobyte" = "kilobayt"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index fa88822a41..e0bf19ce91 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -408,6 +408,7 @@ "language_french" = "Français"; "language_dutch" = "Нідерландська"; "language_ukrainian" = "Українська"; +"language_russian" = "Русский"; "language_japanese" = "Японська"; "language_korean" = "Корейська"; "language_italian" = "Italiano"; @@ -430,6 +431,8 @@ "cost_history_window_help" = "Визначає, скільки днів локальних журналів використання показувати в меню."; "cost_history_days_title" = "Вікно історії: %d днів"; "cost_auto_refresh_info" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; +"cost_comparison_periods_title" = "Показувати коротші періоди порівняння"; +"cost_comparison_periods_subtitle" = "Додає підсумки за 7, 30 і 90 днів, якщо вони входять у вибране вікно історії. Для цих підсумків використовується те саме локальне сканування."; "refresh_cadence_title" = "Оновити каденцію"; "refresh_cadence_subtitle" = "Як часто CodexBar опитує постачальників у фоновому режимі."; "manual_refresh_hint" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; @@ -1114,10 +1117,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Кредити скидання ліміту"; "1 available" = "1 доступне"; "%d available" = "%d доступно"; "Next expires %@" = "Наступне спливає %@"; +"Expires %@" = "Спливає %@"; +"No expiry" = "Без терміну дії"; "Other (%d items)" = "Інше (%d елементів)"; "Expand" = "Розгорнути"; "Collapse" = "Згорнути"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 8562b25832..ecc7afed71 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -408,6 +408,7 @@ "language_french" = "Tiếng Pháp"; "language_dutch" = "Tiếng Hà Lan"; "language_ukrainian" = "Tiếng Ukraina"; +"language_russian" = "Русский"; "language_japanese" = "Tiếng Nhật"; "language_korean" = "Tiếng Hàn"; "language_italian" = "Italiano"; @@ -426,6 +427,8 @@ "cost_history_window_help" = "Đặt số ngày nhật ký sử dụng cục bộ xuất hiệ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"; +"cost_comparison_periods_title" = "Hiển thị các khoảng so sánh ngắn hơn"; +"cost_comparison_periods_subtitle" = "Thêm tổng 7, 30 và 90 ngày khi nằm trong cửa sổ lịch sử đã chọn. Các tổng này dùng lại cùng một lần quét cục bộ."; "refresh_cadence_title" = "Nhịp làm mới"; "refresh_cadence_subtitle" = "Tần suất CodexBar 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."; @@ -1115,10 +1118,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Lượt đặt lại giới hạn"; "1 available" = "1 lượt"; "%d available" = "%d lượt"; "Next expires %@" = "Lượt tiếp theo hết hạn %@"; +"Expires %@" = "Hết hạn %@"; +"No expiry" = "Không hết hạn"; "Other (%d items)" = "Khác (%d mục)"; "Expand" = "Mở rộng"; "Collapse" = "Thu gọn"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 4fbdab092d..777c2f83b3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -415,6 +415,7 @@ "language_german" = "Deutsch"; "language_french" = "法语"; "language_ukrainian" = "乌克兰语"; +"language_russian" = "Русский"; "language_japanese" = "日语"; "language_korean" = "韩语"; "language_italian" = "Italiano"; @@ -434,6 +435,8 @@ "cost_history_window_help" = "设置菜单中显示多少天的本地使用日志。"; "cost_history_days_title" = "历史窗口:%d 天"; "cost_auto_refresh_info" = "自动刷新:每小时 · 超时:10 分钟"; +"cost_comparison_periods_title" = "显示更短的对比周期"; +"cost_comparison_periods_subtitle" = "当 7 天、30 天和 90 天处于所选历史窗口内时,添加相应汇总。这些汇总复用同一次本地扫描。"; "refresh_cadence_title" = "刷新频率"; "refresh_cadence_subtitle" = "CodexBar 在后台轮询提供商的频率。"; "manual_refresh_hint" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; @@ -1092,10 +1095,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "限额重置额度"; "1 available" = "1 次可用"; "%d available" = "%d 次可用"; "Next expires %@" = "下一个将于 %@ 到期"; +"Expires %@" = "%@ 到期"; +"No expiry" = "永不过期"; "Other (%d items)" = "其他(%d 项)"; "Expand" = "展开"; "Collapse" = "收起"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 7a619c7413..a9c37f979e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -416,6 +416,7 @@ "language_german" = "Deutsch"; "language_french" = "法語"; "language_ukrainian" = "烏克蘭語"; +"language_russian" = "Русский"; "language_japanese" = "日語"; "language_korean" = "韓語"; "language_italian" = "Italiano"; @@ -435,6 +436,8 @@ "cost_history_window_help" = "設定選單中顯示多少天的本機使用記錄。"; "cost_history_days_title" = "歷史時段:%d 天"; "cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; +"cost_comparison_periods_title" = "顯示較短的比較期間"; +"cost_comparison_periods_subtitle" = "當 7 天、30 天和 90 天落在所選歷史時段內時,加入相應總計。這些總計會重複使用同一次本機掃描。"; "refresh_cadence_title" = "重新整理頻率"; "refresh_cadence_subtitle" = "CodexBar 在背景輪詢提供者的頻率。"; "manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; @@ -987,10 +990,13 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "限額重設額度"; "1 available" = "1 次可用"; "%d available" = "%d 次可用"; "Next expires %@" = "下一個將於 %@ 到期"; +"Expires %@" = "%@ 到期"; +"No expiry" = "永不過期"; "Other (%d items)" = "其他(%d 個項目)"; "Expand" = "展開"; "Collapse" = "收合"; @@ -1009,6 +1015,7 @@ "minimax_service_coding_plan_search" = "Coding Plan 搜尋"; "Clearing removes old plan-mode files." = "會移除舊的 plan-mode 檔案。"; "%.0f%% %@" = "%2$@ %1$.0f%%"; +"<1%% %@" = "%1$@ <1%%"; "%@: %@%% used" = "%@:已使用 %@%%"; "terminal_app_subtitle" = "「開啟終端」動作使用的終端"; "Admin API key" = "Admin API 金鑰"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 6eaa6cb66d..a4ece45118 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -14,17 +14,28 @@ struct QuotaWarningEvent: Equatable { let threshold: Int let currentRemaining: Double let accountDisplayName: String? + /// Stable id of the extra rate window this warning is for (e.g. `claude-weekly-scoped-fable`), + /// used to keep OS notification ids unique across sibling windows. `nil` for the primary + /// session/weekly lanes. + let windowID: String? + /// Human-facing window label to render instead of the generic session/weekly name + /// (e.g. "Fable only", "Daily Routines"). `nil` falls back to the localized lane name. + let windowDisplayLabel: String? init( window: QuotaWarningWindow, threshold: Int, currentRemaining: Double, - accountDisplayName: String? = nil) + accountDisplayName: String? = nil, + windowID: String? = nil, + windowDisplayLabel: String? = nil) { self.window = window self.threshold = threshold self.currentRemaining = currentRemaining self.accountDisplayName = accountDisplayName + self.windowID = windowID + self.windowDisplayLabel = windowDisplayLabel } } @@ -68,14 +79,20 @@ enum SessionQuotaNotificationLogic { } enum QuotaWarningNotificationLogic { + static func notificationIDPrefix(provider: UsageProvider, event: QuotaWarningEvent) -> String { + let windowSegment = event.windowID.map { "-\($0)" } ?? "" + return "quota-warning-\(provider.rawValue)-\(event.window.rawValue)\(windowSegment)-\(event.threshold)" + } + static func notificationCopy( providerName: String, window: QuotaWarningWindow, threshold: Int, currentRemaining: Double, - accountDisplayName: String? = nil) -> (title: String, body: String) + accountDisplayName: String? = nil, + windowDisplayLabel: String? = nil) -> (title: String, body: String) { - let windowLabel = window.localizedNotificationDisplayName + let windowLabel = windowDisplayLabel ?? window.localizedNotificationDisplayName let remainingText = Self.percentText(currentRemaining) let title = L("quota_warning_notification_title", providerName, windowLabel) let body = if let accountDisplayName { @@ -242,8 +259,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { window: event.window, threshold: threshold, currentRemaining: event.currentRemaining, - accountDisplayName: event.accountDisplayName) - let idPrefix = "quota-warning-\(provider.rawValue)-\(event.window.rawValue)-\(threshold)" + accountDisplayName: event.accountDisplayName, + windowDisplayLabel: event.windowDisplayLabel) + let idPrefix = QuotaWarningNotificationLogic.notificationIDPrefix(provider: provider, event: event) self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) if soundEnabled { (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 666da74e65..de3f257616 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -339,6 +339,14 @@ extension SettingsStore { } } + var costComparisonPeriodsEnabled: Bool { + get { self.defaultsState.costComparisonPeriodsEnabled } + set { + self.defaultsState.costComparisonPeriodsEnabled = newValue + self.userDefaults.set(newValue, forKey: "costComparisonPeriodsEnabled") + } + } + var costSummaryDisplayStyleRaw: String { get { self.defaultsState.costSummaryDisplayStyleRaw } set { @@ -406,9 +414,10 @@ extension SettingsStore { var claudeOAuthKeychainReadStrategy: ClaudeOAuthKeychainReadStrategy { get { guard let raw = self.defaultsState.claudeOAuthKeychainReadStrategyRaw else { - return .securityCLIExperimental + return .securityFramework } - return ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + return strategy == .securityCLIExperimental ? .securityFramework : strategy } set { self.defaultsState.claudeOAuthKeychainReadStrategyRaw = newValue.rawValue @@ -417,11 +426,14 @@ extension SettingsStore { } var claudeOAuthPromptFreeCredentialsEnabled: Bool { - get { self.claudeOAuthKeychainReadStrategy == .securityCLIExperimental } + get { self.claudeOAuthKeychainPromptMode == .never } set { - self.claudeOAuthKeychainReadStrategy = newValue - ? .securityCLIExperimental - : .securityFramework + self.claudeOAuthKeychainReadStrategy = .securityFramework + if newValue { + self.claudeOAuthKeychainPromptMode = .never + } else if self.claudeOAuthKeychainPromptMode == .never { + self.claudeOAuthKeychainPromptMode = .onlyOnUserAction + } } } @@ -724,7 +736,7 @@ extension SettingsStore { if self.userDefaults !== UserDefaults.standard { UserDefaults.standard.set(stored, forKey: "appLanguage") } - UserDefaults.standard.set([stored], forKey: "AppleLanguages") + UserDefaults.standard.removeObject(forKey: "AppleLanguages") } else { self.userDefaults.removeObject(forKey: "appLanguage") if self.userDefaults !== UserDefaults.standard { @@ -732,6 +744,7 @@ extension SettingsStore { } UserDefaults.standard.removeObject(forKey: "AppleLanguages") } + resetCodexBarLocalizationCache() } } @@ -747,6 +760,22 @@ extension SettingsStore { self.userDefaults.set(newValue.rawValue, forKey: "terminalApp") } } + + var agentSessionsEnabled: Bool { + get { self.defaultsState.agentSessionsEnabled } + set { + self.defaultsState.agentSessionsEnabled = newValue + self.userDefaults.set(newValue, forKey: "agentSessionsEnabled") + } + } + + var agentSessionsManualHosts: String { + get { self.defaultsState.agentSessionsManualHosts } + set { + self.defaultsState.agentSessionsManualHosts = newValue + self.userDefaults.set(newValue, forKey: "agentSessionsManualHosts") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index a22f411e31..03bf729aa9 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -35,6 +35,7 @@ extension SettingsStore { _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays + _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle _ = self.appLanguage _ = self.hidePersonalInfo @@ -49,6 +50,8 @@ extension SettingsStore { _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled _ = self.providerStorageFootprintsEnabled + _ = self.agentSessionsEnabled + _ = self.agentSessionsManualHosts _ = self.codexUsageDataSource _ = self.codexActiveSource _ = self.claudeUsageDataSource diff --git a/Sources/CodexBar/SettingsStore+ProviderDetection.swift b/Sources/CodexBar/SettingsStore+ProviderDetection.swift index d4efded24a..e2f35b3491 100644 --- a/Sources/CodexBar/SettingsStore+ProviderDetection.swift +++ b/Sources/CodexBar/SettingsStore+ProviderDetection.swift @@ -1,6 +1,30 @@ +import AppKit import CodexBarCore import Foundation +enum ProviderDetectionPolicy { + struct Signals { + let codexCLIInstalled: Bool + let claudeCLIInstalled: Bool + let claudeDesktopInstalled: Bool + let geminiCLIInstalled: Bool + let geminiConfigured: Bool + let antigravityAvailable: Bool + } + + static func enabledProviders(signals: Signals) -> Set { + var enabled: Set = [] + if signals.codexCLIInstalled { enabled.insert(.codex) } + if signals.claudeCLIInstalled || signals.claudeDesktopInstalled { enabled.insert(.claude) } + if signals.geminiCLIInstalled, signals.geminiConfigured { enabled.insert(.gemini) } + if signals.antigravityAvailable { enabled.insert(.antigravity) } + + // Keep the historical Codex default when no usable provider source is found. + if enabled.isEmpty { enabled.insert(.codex) } + return enabled + } +} + extension SettingsStore { func runInitialProviderDetectionIfNeeded(force: Bool = false) { guard force || !self.providerDetectionCompleted else { return } @@ -13,51 +37,58 @@ extension SettingsStore { func applyProviderDetection() async { guard !self.providerDetectionCompleted else { return } - let codexInstalled = BinaryLocator.resolveCodexBinary() != nil - let claudeInstalled = BinaryLocator.resolveClaudeBinary() != nil - let geminiInstalled = BinaryLocator.resolveGeminiBinary() != nil + let codexCLIInstalled = BinaryLocator.resolveCodexBinary() != nil + let claudeCLIInstalled = BinaryLocator.resolveClaudeBinary() != nil + let claudeDesktopInstalled = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.anthropic.claudefordesktop") != nil + let geminiCLIInstalled = BinaryLocator.resolveGeminiBinary() != nil + let geminiConfigured = FileManager.default.fileExists( + atPath: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini/oauth_creds.json").path) let antigravityRunning = await AntigravityStatusProbe.isRunning() let antigravityLoggedIn = FileManager.default.fileExists( atPath: AntigravityOAuthCredentialsStore().fileURL.path) let logger = CodexBarLog.logger(LogCategories.providerDetection) - // If none installed, keep Codex enabled to match previous behavior. - let noneInstalled = !codexInstalled && !claudeInstalled && !geminiInstalled && !antigravityRunning && - !antigravityLoggedIn - let enableCodex = codexInstalled || noneInstalled - let enableClaude = claudeInstalled - let enableGemini = geminiInstalled - let enableAntigravity = antigravityRunning || antigravityLoggedIn + let enabledProviders = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: codexCLIInstalled, + claudeCLIInstalled: claudeCLIInstalled, + claudeDesktopInstalled: claudeDesktopInstalled, + geminiCLIInstalled: geminiCLIInstalled, + geminiConfigured: geminiConfigured, + antigravityAvailable: antigravityRunning || antigravityLoggedIn)) logger.info( "Provider detection results", metadata: [ - "codexInstalled": codexInstalled ? "1" : "0", - "claudeInstalled": claudeInstalled ? "1" : "0", - "geminiInstalled": geminiInstalled ? "1" : "0", + "codexCLIInstalled": codexCLIInstalled ? "1" : "0", + "claudeCLIInstalled": claudeCLIInstalled ? "1" : "0", + "claudeDesktopInstalled": claudeDesktopInstalled ? "1" : "0", + "geminiCLIInstalled": geminiCLIInstalled ? "1" : "0", + "geminiConfigured": geminiConfigured ? "1" : "0", "antigravityRunning": antigravityRunning ? "1" : "0", "antigravityLoggedIn": antigravityLoggedIn ? "1" : "0", ]) logger.info( "Provider detection enablement", metadata: [ - "codex": enableCodex ? "1" : "0", - "claude": enableClaude ? "1" : "0", - "gemini": enableGemini ? "1" : "0", - "antigravity": enableAntigravity ? "1" : "0", + "codex": enabledProviders.contains(.codex) ? "1" : "0", + "claude": enabledProviders.contains(.claude) ? "1" : "0", + "gemini": enabledProviders.contains(.gemini) ? "1" : "0", + "antigravity": enabledProviders.contains(.antigravity) ? "1" : "0", ]) self.updateProviderConfig(provider: .codex) { entry in - entry.enabled = enableCodex + entry.enabled = enabledProviders.contains(.codex) } self.updateProviderConfig(provider: .claude) { entry in - entry.enabled = enableClaude + entry.enabled = enabledProviders.contains(.claude) } self.updateProviderConfig(provider: .gemini) { entry in - entry.enabled = enableGemini + entry.enabled = enabledProviders.contains(.gemini) } self.updateProviderConfig(provider: .antigravity) { entry in - entry.enabled = enableAntigravity + entry.enabled = enabledProviders.contains(.antigravity) } self.providerDetectionCompleted = true logger.info("Provider detection completed") diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index a32df9783e..2668d099e4 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -29,8 +29,11 @@ extension SettingsStore { nonisolated static func hasAnyTokenCostUsageSources( env: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default) -> Bool + fileManager: FileManager = .default, + homeDirectory: URL? = nil) -> Bool { + let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser + func hasAnyJsonl(in root: URL) -> Bool { guard fileManager.fileExists(atPath: root.path) else { return false } guard let enumerator = fileManager.enumerator( @@ -50,7 +53,7 @@ extension SettingsStore { if let raw, !raw.isEmpty { return URL(fileURLWithPath: raw).appendingPathComponent("sessions", isDirectory: true) } - return fileManager.homeDirectoryForCurrentUser + return home .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) }() @@ -79,11 +82,10 @@ extension SettingsStore { } } - let home = fileManager.homeDirectoryForCurrentUser return [ home.appendingPathComponent(".config/claude/projects", isDirectory: true), home.appendingPathComponent(".claude/projects", isDirectory: true), - ] + ] + ClaudeDesktopProjectsLocator.roots(homeDirectory: home, fileManager: fileManager) }() return claudeRoots.contains(where: hasAnyJsonl(in:)) diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index aa350c4e90..5112f54e2e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -369,6 +369,7 @@ extension SettingsStore { return hadExistingConfig } + // swiftlint:disable:next function_body_length private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { let refreshDefault = userDefaults.string(forKey: "refreshFrequency") .flatMap(RefreshFrequency.init(rawValue:)) @@ -416,6 +417,8 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let costComparisonPeriodsEnabled = userDefaults.object( + forKey: "costComparisonPeriodsEnabled") as? Bool ?? false let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw( userDefaults: userDefaults, costUsageEnabled: costUsageEnabled) @@ -423,8 +426,8 @@ extension SettingsStore { let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false let confettiOnReset = Self.loadConfettiOnResetDefaults(userDefaults: userDefaults) let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false + let claudeOAuthKeychainReadStrategyRaw = Self.loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: userDefaults) let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") - let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true @@ -458,6 +461,8 @@ extension SettingsStore { let providersSortedAlphabetically = userDefaults.object( forKey: "providersSortedAlphabetically") as? Bool ?? false let appLanguageRaw = userDefaults.string(forKey: "appLanguage") + let agentSessionsEnabled = userDefaults.object(forKey: "agentSessionsEnabled") as? Bool ?? true + let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" return SettingsDefaultsState( refreshFrequency: refreshFrequency, refreshAllProvidersOnMenuOpen: refreshAllProvidersOnMenuOpen, @@ -494,6 +499,7 @@ extension SettingsStore { copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, + costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, @@ -516,7 +522,9 @@ extension SettingsStore { providerDetectionCompleted: providerDetectionCompleted, providersSortedAlphabetically: providersSortedAlphabetically, appLanguageRaw: appLanguageRaw, - terminalAppRaw: userDefaults.string(forKey: "terminalApp")) + terminalAppRaw: userDefaults.string(forKey: "terminalApp"), + agentSessionsEnabled: agentSessionsEnabled, + agentSessionsManualHosts: agentSessionsManualHosts) } private static func loadCostSummaryDisplayStyleRaw( @@ -535,6 +543,21 @@ extension SettingsStore { return migratedStyle } + private static func loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: UserDefaults) -> String? { + let key = "claudeOAuthKeychainReadStrategy" + guard let raw = userDefaults.string(forKey: key) else { return nil } + guard let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) else { return raw } + guard strategy == .securityCLIExperimental else { return raw } + + let migrated = ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue + userDefaults.set(migrated, forKey: key) + let promptModeKey = "claudeOAuthKeychainPromptMode" + if userDefaults.string(forKey: promptModeKey) == nil { + userDefaults.set(ClaudeOAuthKeychainPromptMode.never.rawValue, forKey: promptModeKey) + } + return migrated + } + private static func loadConfettiOnResetDefaults(userDefaults: UserDefaults) -> (session: Bool, weekly: Bool) { ( session: userDefaults.object(forKey: "confettiOnSessionLimitResetsEnabled") as? Bool ?? false, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index e72d6d794a..10286d4d37 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -36,6 +36,7 @@ struct SettingsDefaultsState { var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int + var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String var hidePersonalInfo: Bool var randomBlinkEnabled: Bool @@ -59,4 +60,6 @@ struct SettingsDefaultsState { var providersSortedAlphabetically: Bool var appLanguageRaw: String? var terminalAppRaw: String? + var agentSessionsEnabled: Bool + var agentSessionsManualHosts: String } diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index 2c268511f4..c9a68ee7fa 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -1,6 +1,12 @@ import AppKit import CodexBarCore +enum ClaudeSwapMenuPrecedence { + static func prefersClaudeSwap(provider: UsageProvider, accountCount: Int) -> Bool { + provider == .claude && accountCount > 1 + } +} + extension StatusItemController { private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests @@ -28,6 +34,14 @@ extension StatusItemController { func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } + // Multiple claude-swap rows are the selected Claude account source, so do not mix them + // with token-account cards or the segmented token-account switcher. + if ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: provider, + accountCount: self.store.claudeSwapAccountSnapshots.count) + { + return nil + } let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 736520aacf..6fce93f050 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,6 +1,15 @@ import AppKit import CodexBarCore +extension StatusItemController { + /// Identifies which manual refresh a task belongs to, so per-provider refreshes stay independent + /// of each other and of the all-providers refresh. + enum ManualRefreshScope: Hashable { + case global + case provider(UsageProvider) + } +} + enum LoginNotificationLogic { static func notificationCopy(providerName: String) -> (title: String, body: String) { ( @@ -30,7 +39,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { refreshOpenMenusWhenComplete: Bool, interaction: ProviderInteraction) async { - await ProviderInteractionContext.$current.withValue(interaction) { + await self.withProviderInteraction(interaction) { await self.store.refresh(forceTokenUsage: forceTokenUsage) guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } self.store.scheduleStorageFootprintRefreshForOverview(force: true) @@ -47,7 +56,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { refreshOpenMenusWhenComplete: Bool, interaction: ProviderInteraction) async { - await ProviderInteractionContext.$current.withValue(interaction) { + await self.withProviderInteraction(interaction) { let refreshStartedAt = Date() await self.store.refreshProvider(provider) guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } @@ -79,6 +88,23 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } + private func withProviderInteraction( + _ interaction: ProviderInteraction, + operation: () async -> Void) async + { + if interaction == .userInitiated { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(interaction) { + await operation() + } + } + } else { + await ProviderInteractionContext.$current.withValue(interaction) { + await operation() + } + } + } + func refreshOpenMenusAfterExplicitStoreAction() { self.invalidateMenus( refreshOpenMenus: true, @@ -114,10 +140,17 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } private func startManualRefresh(for provider: UsageProvider?) { + let scope: ManualRefreshScope = provider.map(ManualRefreshScope.provider) ?? .global let scopedRefreshInFlight = provider.map { self.store.refreshingProviders.contains($0) } ?? !self.store.refreshingProviders.isEmpty + // Two different providers may refresh concurrently, but an all-providers (.global) refresh must + // not overlap a per-provider one (or vice versa) — that would duplicate the shared fetch work. + let conflictsWithOtherScope = scope == .global + ? self.manualRefreshTasks.contains { $0.key != .global } + : self.manualRefreshTasks[.global] != nil guard !self.hasPreparedForAppShutdown, - self.manualRefreshTask == nil, + self.manualRefreshTasks[scope] == nil, + !conflictsWithOtherScope, !self.store.isRefreshing, !scopedRefreshInFlight else { return } @@ -126,9 +159,8 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { let task = Task { @MainActor [weak self] in guard let self else { return } defer { - self.manualRefreshTask = nil - self.manualRefreshProvider = nil - self.menuCardRefreshMonitor.endManualRefresh() + self.manualRefreshTasks[scope] = nil + self.menuCardRefreshMonitor.endManualRefresh(for: provider) self.updatePersistentRefreshItemsEnabled() self.prepareAttachedClosedMenusIfNeeded() } @@ -152,8 +184,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { interaction: .userInitiated) } } - self.manualRefreshProvider = provider - self.manualRefreshTask = task + self.manualRefreshTasks[scope] = task self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider) self.updatePersistentRefreshItemsEnabled() } @@ -248,6 +279,11 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { if provider == .alibaba { return self.settings.alibabaCodingPlanAPIRegion.dashboardURL } + if provider == .alibabatokenplan { + return AlibabaTokenPlanUsageFetcher.dashboardURL( + region: self.settings.alibabaTokenPlanAPIRegion, + environment: environment) + } if provider == .minimax { return self.settings.minimaxAPIRegion.dashboardURL } diff --git a/Sources/CodexBar/StatusItemController+AgentSessions.swift b/Sources/CodexBar/StatusItemController+AgentSessions.swift new file mode 100644 index 0000000000..c2e42a389c --- /dev/null +++ b/Sources/CodexBar/StatusItemController+AgentSessions.swift @@ -0,0 +1,19 @@ +import AppKit + +extension StatusItemController { + @objc func focusAgentSession(_ sender: NSMenuItem) { + guard let values = sender.representedObject as? [String], + let sessionID = values.first + else { return } + let remoteHost = values.count > 1 && !values[1].isEmpty ? values[1] : nil + let session = if let remoteHost { + self.agentSessions.remoteHosts + .first(where: { $0.host == remoteHost })? + .sessions.first(where: { $0.id == sessionID }) + } else { + self.agentSessions.localSessions.first(where: { $0.id == sessionID }) + } + guard let session else { return } + self.agentSessions.focus(session, remoteHost: remoteHost) + } +} diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 304e2a8ab8..a42f0f23aa 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -250,14 +250,12 @@ extension StatusItemController { // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". - let resolved = snapshot.map { - IconRemainingResolver.resolvedPercents( - snapshot: $0, - style: resolverStyle, - showUsed: showUsed, - renderingStyle: style, - secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) - } + let resolved = self.resolvedMenuBarIconPercents( + provider: primaryProvider, + snapshot: snapshot, + style: resolverStyle, + showUsed: showUsed, + renderingStyle: style) var primary = resolved?.primary var weekly = resolved?.secondary var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot) @@ -462,13 +460,11 @@ extension StatusItemController { self.setButtonTitle(nil, for: button) // OpenRouter always gets a meter here — the brand-logo fallback was removed on purpose. - let resolved = snapshot.map { - IconRemainingResolver.resolvedPercents( - snapshot: $0, - style: style, - showUsed: showUsed, - secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) - } + let resolved = self.resolvedMenuBarIconPercents( + provider: provider, + snapshot: snapshot, + style: style, + showUsed: showUsed) var primary = resolved?.primary var weekly = resolved?.secondary var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) @@ -573,7 +569,57 @@ extension StatusItemController { return String(format: "%.3f", value) } - func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? { + func resolvedMenuBarIconPercents( + provider: UsageProvider, + snapshot: UsageSnapshot?, + style: IconStyle, + showUsed: Bool, + renderingStyle: IconStyle? = nil) + -> (primary: Double?, secondary: Double?)? + { + guard let snapshot else { return nil } + let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + if preference == .monthlyPlan { + guard let metricWindow = self.menuBarMetricWindowForIconOverride( + preference: preference, + provider: provider, + snapshot: snapshot) + else { + return (primary: nil, secondary: nil) + } + return ( + primary: showUsed ? metricWindow.usedPercent : metricWindow.remainingPercent, + secondary: nil) + } + if provider == .mistral { + return (primary: nil, secondary: nil) + } + return IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: style, + showUsed: showUsed, + renderingStyle: renderingStyle, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: snapshot)) + } + + private func menuBarMetricWindowForIconOverride( + preference: MenuBarMetricPreference, + provider: UsageProvider, + snapshot: UsageSnapshot) + -> RateWindow? + { + MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: provider, + snapshot: snapshot, + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) + } + + func menuBarCreditsRemainingForIcon( + provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = Date()) -> 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 @@ -584,7 +630,7 @@ extension StatusItemController { guard provider == .codex else { return nil } return self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) } func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { @@ -652,7 +698,11 @@ extension StatusItemController { } private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { - let value = Self.buttonTitle(title, hasImage: button.image != nil) + let isDebugApp = Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier) + let value = Self.buttonTitle( + title, + hasImage: button.image != nil || title == nil, + isDebugApp: isDebugApp) if button.title != value { button.title = value } @@ -662,12 +712,23 @@ extension StatusItemController { } } - nonisolated static func buttonTitle(_ title: String?, hasImage: Bool) -> String { - guard let title, !title.isEmpty else { return "" } - return hasImage ? " \(title)" : title + nonisolated static func buttonTitle(_ title: String?, hasImage: Bool, isDebugApp: Bool = false) -> String { + var parts: [String] = [] + if let title, !title.isEmpty { + parts.append(title) + } + if isDebugApp { + parts.append("D") + } + let value = parts.joined(separator: " ") + return hasImage && !value.isEmpty ? " \(value)" : value } - func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + func menuBarDisplayText( + for provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = .init()) -> String? + { let mode = self.settings.menuBarDisplayMode if provider == .openrouter, self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, @@ -710,8 +771,8 @@ extension StatusItemController { } if provider == .mistral { let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) - let hasMonthlyWindow = snapshot?.extraRateWindows?.contains { $0.id == "mistral-monthly-plan" } == true - if preference != .monthlyPlan || !hasMonthlyWindow, + let hasMonthlyPlan = snapshot?.extraRateWindows?.contains { $0.id == "mistral-monthly-plan" } == true + if preference != .monthlyPlan || !hasMonthlyPlan, let spend = Self.mistralSpendDisplayText(snapshot: snapshot) { return spend @@ -736,8 +797,7 @@ extension StatusItemController { return spend } - let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) - let now = Date() + let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot, now: now) let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, surface: .menuBar, @@ -1002,10 +1062,10 @@ extension StatusItemController { return value.isEmpty ? nil : value } - private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date) -> RateWindow? { - self.menuBarMetricWindow(for: provider, snapshot: snapshot) + self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now) } /// Resolves the session (5h) and weekly (7d) lanes for the combined "Session + Weekly" menu-bar @@ -1030,8 +1090,11 @@ extension StatusItemController { return nil } let session = Self.combinedSessionLane(snapshot: snapshot, projection: projection) - let weekly = projection?.rateWindow(for: .weekly) - ?? Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes) + let weekly: RateWindow? = if let projection { + projection.menuBarSelectableRateWindow(for: .weekly) + } else { + Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes) + } return (session, weekly) } @@ -1044,8 +1107,8 @@ extension StatusItemController { snapshot: UsageSnapshot?, projection: CodexConsumerProjection?) -> RateWindow? { - if let projected = projection?.rateWindow(for: .session) { - return projected + if let projection { + return projection.menuBarSelectableRateWindow(for: .session) } guard let session = Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.sessionWindowMinutes) else { return nil } @@ -1067,7 +1130,7 @@ extension StatusItemController { percentWindow: RateWindow?) -> RateWindow? { if let projection { - return projection.rateWindow(for: .weekly) + return projection.menuBarSelectableRateWindow(for: .weekly) } if provider == .abacus { return snapshot?.primary diff --git a/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift new file mode 100644 index 0000000000..069fb7e575 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift @@ -0,0 +1,51 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func addClaudeSwapMenuCards(to menu: NSMenu, context: MenuCardContext) { + let cardRows = self.store.claudeSwapAccountSnapshots.compactMap { account -> + (account: ProviderAccountUsageSnapshot, model: UsageMenuCardView.Model)? in + guard let model = self.menuCardModel( + for: .claude, + snapshotOverride: account.snapshot, + errorOverride: ClaudeSwapAccountProjection.displayError( + accountError: account.error, + adapterError: self.store.claudeSwapLastError, + switchError: self.store.claudeSwapTransientState.lastErrorAccountID == account.id + ? self.store.claudeSwapTransientState.lastError + : nil), + forceOverrideCard: account.snapshot == nil, + accountOverride: AccountInfo( + email: account.displayLabel, + plan: nil), + planOverride: self.claudeSwapAccountActionLabel(account)) + else { + return nil + } + return (account, model) + } + self.addStackedMenuCards( + cardRows.map(\.model), + to: menu, + context: context, + planAction: { [weak self] index in + guard cardRows.indices.contains(index) else { return nil } + return self?.claudeSwapAccountSwitchAction(cardRows[index].account) + }) + } + + private func claudeSwapAccountActionLabel(_ account: ProviderAccountUsageSnapshot) -> String? { + if account.isActive { return L("Active") } + if self.store.claudeSwapTransientState.switchingAccountID == account.id { return L("Loading…") } + guard self.store.claudeSwapTransientState.task == nil, account.canActivate else { return nil } + return L("Switch Account...") + } + + private func claudeSwapAccountSwitchAction(_ account: ProviderAccountUsageSnapshot) -> (() -> Void)? { + guard self.store.claudeSwapTransientState.task == nil, account.canActivate else { return nil } + let accountID = account.id + return { [weak self] in + self?.store.switchClaudeSwapAccount(accountID) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 05424c8d50..88cdb1fd23 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -88,14 +88,14 @@ extension StatusItemController { } static func costMenuTooltipLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { - [ + let lines = [ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.hintLine, - tokenUsage?.errorLine, ] .compactMap(\.self) - .filter { !$0.isEmpty } + + (tokenUsage?.comparisonLines ?? []) + + [tokenUsage?.hintLine, tokenUsage?.errorLine].compactMap(\.self) + return lines.filter { !$0.isEmpty } } static func costMenuVisibleDetailLines( @@ -103,12 +103,13 @@ extension StatusItemController { hasSubmenu: Bool) -> [String] { guard !hasSubmenu else { return [] } - let primaryLines = [ + let primaryLines = ([ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.errorLine, ] .compactMap(\.self) + + (tokenUsage?.comparisonLines ?? []) + + [tokenUsage?.errorLine].compactMap(\.self)) .filter { !$0.isEmpty } guard primaryLines.isEmpty else { return primaryLines } return [tokenUsage?.hintLine] diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 50820ec0a1..b82631d31b 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -8,21 +8,32 @@ extension StatusItemController { self.menuBarCountdownRefreshTask?.cancel() self.menuBarCountdownRefreshTask = nil - guard self.settings.menuBarShowsBrandIconWithPercent, - self.settings.menuBarDisplayMode == .resetTime, - self.settings.resetTimeDisplayStyle == .countdown - else { - return + var delays: [TimeInterval] = [] + let providers = self.menuBarRefreshProviders() + if self.settings.menuBarShowsBrandIconWithPercent, + self.settings.menuBarDisplayMode == .resetTime, + self.settings.resetTimeDisplayStyle == .countdown + { + let resetDates = providers.compactMap { provider in + self.menuBarMetricWindow( + for: provider, + snapshot: self.store.snapshot(for: provider), + now: now)?.resetsAt + } + if let delay = Self.menuBarCountdownRefreshDelay(resetDates: resetDates, now: now) { + delays.append(delay) + } } - let resetDates = self.menuBarCountdownProviders().compactMap { provider in - self.menuBarMetricWindow( - for: provider, - snapshot: self.store.snapshot(for: provider))?.resetsAt - } - guard let delay = Self.menuBarCountdownRefreshDelay(resetDates: resetDates, now: now) else { - return + if self.menuBarObservesCodexReset(providers: providers) { + let projection = self.store.codexConsumerProjection(surface: .menuBar, now: now) + if let resetAt = projection.nextMenuBarStateChangeAt { + delays.append(max( + Self.menuBarCountdownRefreshEpsilon, + resetAt.timeIntervalSince(now) + Self.menuBarCountdownRefreshEpsilon)) + } } + guard let delay = delays.min() else { return } self.menuBarCountdownRefreshTask = Task { @MainActor [weak self] in do { @@ -52,13 +63,22 @@ extension StatusItemController { }.min() } - private func menuBarCountdownProviders() -> [UsageProvider] { + private func menuBarRefreshProviders() -> [UsageProvider] { if self.shouldMergeIcons { return [self.primaryProviderForUnifiedIcon()] } return UsageProvider.allCases.filter(self.isVisible) } + private func menuBarObservesCodexReset(providers: [UsageProvider]) -> Bool { + if providers.contains(.codex) { return true } + guard self.shouldMergeIcons, self.settings.menuBarShowsHighestUsage else { return false } + let activeProviders = self.store.enabledProvidersForDisplay() + return self.settings.resolvedMergedOverviewProviders( + activeProviders: activeProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).contains(.codex) + } + #if DEBUG func _test_isMenuBarCountdownRefreshScheduled() -> Bool { self.menuBarCountdownRefreshTask != nil diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 7b036c2117..ed17647d6d 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -262,6 +262,7 @@ extension StatusItemController { snapshot.historyLabel ?? "", snapshot.last30DaysCostUSD.map { String($0.bitPattern, radix: 16) } ?? "nil", String(reflecting: snapshot.daily), + String(reflecting: snapshot.projects), ].joined(separator: "|") } @@ -434,6 +435,7 @@ extension StatusItemController { currencyCode: tokenSnapshot.currencyCode, historyDays: tokenSnapshot.historyDays, windowLabel: tokenSnapshot.historyLabel, + projects: provider == .codex ? tokenSnapshot.projects : [], onHeightChange: { height in relay.hosting?.applyMeasuredHeight(width: width, height: height) }, diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift index f0a92a700c..c5cc54901e 100644 --- a/Sources/CodexBar/StatusItemController+IconObservation.swift +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -37,13 +37,11 @@ extension StatusItemController { 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, - secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) - } + let resolved = self.resolvedMenuBarIconPercents( + provider: provider, + snapshot: snapshot, + style: style, + showUsed: self.settings.usageBarsShowUsed) let creditsRemaining = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 714dbd2791..76a31568e8 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -70,6 +70,7 @@ extension StatusItemController { func menuWillOpen(_ menu: NSMenu) { // Records interaction and may bring an adaptive timer forward; never refreshes synchronously. self.store.noteMenuOpened() + self.agentSessions.refreshOnMenuOpen() let trace = self.beginMenuOperationTrace("menuWillOpen", breadcrumb: "menuWillOpen") defer { self.endMenuOperationTrace(trace, menu: menu, provider: self.menuProvider(for: menu)) } @@ -607,6 +608,16 @@ extension StatusItemController { return false } + // Multiple claude-swap rows take precedence over Claude token-account cards; otherwise + // the stacked token-account branch below would return before rendering the adapter rows. + if ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: context.currentProvider, + accountCount: self.store.claudeSwapAccountSnapshots.count) + { + self.addClaudeSwapMenuCards(to: menu, context: context) + return false + } + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty @@ -669,10 +680,11 @@ extension StatusItemController { return false } - private func addStackedMenuCards( + func addStackedMenuCards( _ cards: [UsageMenuCardView.Model], to menu: NSMenu, - context: MenuCardContext) + context: MenuCardContext, + planAction: ((Int) -> (() -> Void)?)? = nil) { if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) @@ -687,7 +699,10 @@ extension StatusItemController { } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + planAction: planAction?(index)), id: "menuCard-\(index)", width: context.menuWidth, heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", @@ -773,13 +788,7 @@ extension StatusItemController { width: CGFloat, captureMenu: NSMenu? = nil) { - let actionableSections = sections.filter { section in - section.entries.contains { entry in - if case .action = entry { return true } - if case .submenu = entry { return true } - return false - } - } + let actionableSections = sections.filter { section in section.entries.contains(where: \ .isActionable) } for (index, section) in actionableSections.enumerated() { for entry in section.entries { switch entry { @@ -843,6 +852,11 @@ extension StatusItemController { self.applySubtitle(subtitle, to: item, title: localizedTitle) } menu.addItem(item) + case let .unavailable(title, tooltip): + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + item.toolTip = tooltip + menu.addItem(item) case let .submenu(title, systemImageName, submenuItems): let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") if let systemImageName, @@ -1233,25 +1247,22 @@ extension StatusItemController { webItems: OpenAIWebMenuItems) { let provider = layoutModel.provider - let hasUsageBlock = layoutModel.hasUsageContent let hasCredits = layoutModel.creditsText != nil let hasExtraUsage = layoutModel.providerCost != nil let hasCost = layoutModel.tokenUsage != nil - let hasStorage = self.store.storageFootprintText(for: provider) != nil let bottomPadding = CGFloat(hasCredits ? 4 : 6) let sectionSpacing = CGFloat(6) - let usageBottomPadding = bottomPadding let creditsBottomPadding = bottomPadding func addSectionSeparator() { guard menu.items.last?.isSeparatorItem != true else { return } menu.addItem(.separator()) } - if hasUsageBlock { + if layoutModel.hasUsageContent { let usageView = UsageMenuCardHeaderAndUsageSectionView( model: model, layoutModel: layoutModel, - bottomPadding: usageBottomPadding, + bottomPadding: bottomPadding, width: width) let usageSubmenu = self.makeUsageSubmenu( provider: provider, @@ -1280,10 +1291,6 @@ extension StatusItemController { containsInteractiveControls: true)) } - if hasStorage || hasCredits || hasExtraUsage || hasCost { - addSectionSeparator() - } - if self.addStorageMenuCardSection(to: menu, provider: provider, width: width), hasCredits || hasExtraUsage { @@ -1341,6 +1348,13 @@ extension StatusItemController { submenu: costSubmenu, width: width)) } + if !hasCredits, webItems.hasCreditsHistory, self.settings.showOptionalCreditsAndExtraUsage { + addSectionSeparator() + _ = self.addCreditsHistorySubmenu(to: menu) + } + if !hasCredits, webItems.canShowBuyCredits { + menu.addItem(self.makeBuyCreditsItem()) + } } private func switcherIcon(for provider: UsageProvider) -> NSImage { @@ -1352,12 +1366,14 @@ extension StatusItemController { let snapshot = self.store.snapshot(for: provider) let showUsed = self.settings.usageBarsShowUsed let style = self.store.style(for: provider) + let now = Date() let resolved = snapshot.map { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, showUsed: showUsed, - secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0), + now: now) } let primary = resolved?.primary let weekly = resolved?.secondary @@ -1365,11 +1381,11 @@ extension StatusItemController { for: provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let credits = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil let stale = self.store.isStale(provider: provider) let indicator = self.store.statusIndicator(for: provider) diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index b5063c3aca..4898026b04 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -20,6 +20,8 @@ extension StatusItemController { case .about: (#selector(self.showSettingsAbout), nil) case .quit: (#selector(self.quit), nil) case let .copyError(message): (#selector(self.copyError(_:)), message) + case let .focusAgentSession(session, remoteHost): + (#selector(self.focusAgentSession(_:)), [session.id, remoteHost ?? ""]) } } diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 3d8572132b..a97c1d8337 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -7,7 +7,8 @@ extension StatusItemController { snapshotOverride: UsageSnapshot? = nil, errorOverride: String? = nil, forceOverrideCard: Bool = false, - accountOverride: AccountInfo? = nil) -> UsageMenuCardView.Model? + accountOverride: AccountInfo? = nil, + planOverride: String? = nil) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) @@ -44,9 +45,11 @@ extension StatusItemController { let tokenError: String? if let codexProjection { credits = codexProjection.credits?.snapshot - creditsError = codexProjection.credits?.userFacingError + // Credits and dashboard collection are optional adjuncts. Keep their setup diagnostics in + // provider Settings so a signed-out browser does not dominate the glanceable menu card. + creditsError = nil dashboard = nil - dashboardError = codexProjection.userFacingErrors.dashboard + dashboardError = nil if surface == .liveCard { tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) @@ -55,7 +58,7 @@ extension StatusItemController { tokenError = nil } } else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, - snapshotOverride == nil + surface == .liveCard { credits = nil creditsError = nil @@ -72,7 +75,7 @@ extension StatusItemController { tokenError = nil } - let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil + let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto // Abacus uses primary for monthly credits (no secondary window) let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary @@ -101,10 +104,15 @@ extension StatusItemController { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: fallbackAccount, + accountIsAuthoritative: accountOverride != nil, + planOverride: planOverride, isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target), + // Provider-level errors can belong to a different account, so + // override cards never inherit them (same rule as the snapshot, + // token-cost, and source-label fallbacks above). lastError: errorOverride ?? codexProjection?.userFacingErrors.usage - ?? self.store.userFacingError(for: target), + ?? (surface == .liveCard ? self.store.userFacingError(for: target) : nil), limitsAvailability: self.store.knownLimitsAvailability(for: target), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, @@ -112,6 +120,7 @@ extension StatusItemController { tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && self.settings.costSummaryShowsSubmenu(for: target), + costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 70dba06660..87954d58e0 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -5,10 +5,12 @@ import SwiftUI extension StatusItemController { func switcherWeeklyRemaining(for provider: UsageProvider) -> Double? { - Self.switcherWeeklyMetricPercent( + let snapshot = self.store.snapshot(for: provider) + return Self.switcherWeeklyMetricPercent( for: provider, - snapshot: self.store.snapshot(for: provider), - showUsed: self.settings.usageBarsShowUsed) + snapshot: snapshot, + showUsed: self.settings.usageBarsShowUsed, + preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)) } func applySubtitle(_ subtitle: String, to item: NSMenuItem, title: String) { diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 7de0cf5ec5..c4764814cd 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -119,6 +119,7 @@ extension StatusItemController { "openAIUsage=\(Self.dashboardBreakdownReadinessSignature(dashboardUsageBreakdown))", "credits=\(self.store.credits == nil ? "0" : "1")", "planHistoryRevision=\(self.store.planUtilizationHistoryRevision)", + "claudeSwapRevision=\(self.store.claudeSwapRevision)", ] for provider in self.store.enabledProvidersForDisplay() { @@ -166,6 +167,27 @@ extension StatusItemController { ].joined(separator: ",") } .joined(separator: ";") + let projects = snapshot.projects + .map { project in + let sources = project.sources + .map { source in + [ + source.name, + source.path ?? "", + "\(source.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(source.totalCostUSD), + ].joined(separator: ",") + } + .joined(separator: "|") + return [ + project.name, + project.path ?? "", + "\(project.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(project.totalCostUSD), + sources, + ].joined(separator: ",") + } + .joined(separator: ";") return [ "sessionTokens=\(snapshot.sessionTokens ?? -1)", "sessionCost=\(Self.formatOptionalDoubleForSignature(snapshot.sessionCostUSD))", @@ -173,6 +195,7 @@ extension StatusItemController { "lastCost=\(Self.formatOptionalDoubleForSignature(snapshot.last30DaysCostUSD))", "updated=\(Int(snapshot.updatedAt.timeIntervalSince1970 * 1000))", "daily=\(daily)", + "projects=\(projects)", ].joined(separator: ",") } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 751d19ef00..43118d1e5a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -111,7 +111,7 @@ extension StatusItemController { var isMenuDataRefreshInFlight: Bool { self.store.isRefreshing || - self.manualRefreshTask != nil || + !self.manualRefreshTasks.isEmpty || !self.store.refreshingProviders.isEmpty || UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } } @@ -347,6 +347,15 @@ extension StatusItemController { parts.append(self.providerIdentitySignature(scopeSnapshot.snapshot?.identity(for: target))) } } + + if target == .claude { + parts.append(Self.menuIdentityField(self.store.claudeSwapLastError ?? "")) + for accountSnapshot in self.store.claudeSwapAccountSnapshots { + parts.append(Self.menuIdentityField(accountSnapshot.id.opaqueID)) + parts.append(accountSnapshot.isActive ? "active" : "inactive") + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + } } return parts.joined(separator: "|") } diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index 8f18e5266a..c67b7cbd2a 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -43,7 +43,10 @@ extension StatusItemController { managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: includeContextualActions) + includeContextualActions: includeContextualActions, + agentSessionsEnabled: self.settings.agentSessionsEnabled, + localAgentSessions: self.agentSessions.localSessions, + remoteAgentHosts: self.agentSessions.remoteHosts) } func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { @@ -88,6 +91,8 @@ extension StatusItemController { "text:\(style):\(text)" case let .action(title, action): "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" + case let .unavailable(title, tooltip): + "unavailable:\(title):\(tooltip ?? "")" case let .submenu(title, systemImageName, submenuItems): "submenu:\(title):\(systemImageName ?? ""):" + submenuItems.map { item in [ @@ -136,6 +141,8 @@ extension StatusItemController { "quit" case let .copyError(message): "copyError:\(message)" + case let .focusAgentSession(session, remoteHost): + "focusAgentSession:\(remoteHost ?? "local"):\(session.id)" } } } diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index c875e87fe1..f04dc52b51 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -16,18 +16,29 @@ extension StatusItemController { } func isRefreshActionInFlight(for menu: NSMenu) -> Bool { - if self.manualRefreshTask != nil { + // An all-providers manual refresh (⌘R / overview) legitimately busies every row. + if self.manualRefreshTasks[.global] != nil { return true } if self.isMergedOverviewSelected(in: menu) { - // Overview refresh is global, so its busy state must mirror the global manual-refresh gate. - return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty + // Overview stands for every provider, so it is busy while ANY manual refresh runs — + // including the post-fetch tail of a per-provider refresh, after `refreshingProviders` + // has cleared but its `.provider` task is still finishing status/token/credits work. + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty } if let provider = self.menuProvider(for: menu) { - return self.store.isRefreshing || self.store.refreshingProviders.contains(provider) + // A manual refresh of a different provider must not grey out this provider's row: only + // reflect the global refresh, this provider's own manual refresh, and its store refresh. + return self.store.isRefreshing + || self.manualRefreshTasks[.provider(provider)] != nil + || self.store.refreshingProviders.contains(provider) } - return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty } func isMergedOverviewSelected(in menu: NSMenu) -> Bool { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index e7b7a0b664..a5a6ce8827 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -22,16 +22,18 @@ extension StatusItemController { } private func cancelShutdownTasks() { + self.agentSessions.stop() self.blinkTask?.cancel() self.blinkTask = nil self.menuBarCountdownRefreshTask?.cancel() self.menuBarCountdownRefreshTask = nil self.loginTask?.cancel() self.loginTask = nil - self.manualRefreshTask?.cancel() - self.manualRefreshTask = nil - self.manualRefreshProvider = nil - self.menuCardRefreshMonitor.endManualRefresh() + for task in self.manualRefreshTasks.values { + task.cancel() + } + self.manualRefreshTasks.removeAll() + self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil self.pendingScreenChangePreviousCount = nil diff --git a/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift b/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift index d078111143..47c4f1377c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift @@ -4,9 +4,20 @@ extension StatusItemController { nonisolated static func switcherWeeklyMetricPercent( for provider: UsageProvider, snapshot: UsageSnapshot?, - showUsed: Bool) -> Double? + showUsed: Bool, + preference: MenuBarMetricPreference = .automatic) -> Double? { - let window = snapshot?.switcherWeeklyWindow(for: provider, showUsed: showUsed) + let window: RateWindow? = if preference == .monthlyPlan { + MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: provider, + snapshot: snapshot, + supportsAverage: false) + } else if provider == .mistral { + nil + } else { + snapshot?.switcherWeeklyWindow(for: provider, showUsed: showUsed) + } guard let window else { return nil } return showUsed ? window.usedPercent : window.remainingPercent } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 4d6c033d53..17b4e3b921 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -41,6 +41,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests static let quotaWarningFlashDuration: TimeInterval = 60 private nonisolated static let statusItemAccessibilityTitle = "CodexBar" + private nonisolated static let debugStatusItemAccessibilityTitle = "CodexBar Debug" private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" private nonisolated static let mergedLegacyDefaultItemIndex = 0 @@ -67,6 +68,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } + nonisolated static func isDebugApp(bundleIdentifier: String?) -> Bool { + bundleIdentifier?.contains(".debug") == true + } + + nonisolated static func statusItemAccessibilityTitle(isDebugApp: Bool) -> String { + isDebugApp ? self.debugStatusItemAccessibilityTitle : self.statusItemAccessibilityTitle + } + #if DEBUG var menuRefreshEnabledOverrideForTesting: Bool? #endif @@ -108,6 +117,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let store: UsageStore let settings: SettingsStore + let agentSessions: AgentSessionsStore lazy var menuCardRefreshMonitor = MenuCardRefreshMonitor { [weak self] provider in self?.menuCardModel(for: provider) } @@ -121,6 +131,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let menuRefreshEnabledForController: Bool var statusItem: NSStatusItem var statusItems: [UsageProvider: NSStatusItem] = [:] + /// App intent survives Tahoe changing `NSStatusItem.isVisible` after Control Center rejects its scene. + var expectedVisibleStatusItemAutosaveNames: Set = [] var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuSession = MenuSessionCoordinator() @@ -138,8 +150,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] - var manualRefreshTask: Task? - var manualRefreshProvider: UsageProvider? + /// Manual refreshes tracked per scope so refreshing one provider neither greys out nor blocks + /// a manual refresh of another. `.global` covers the all-providers refresh (⌘R / merged overview). + var manualRefreshTasks: [ManualRefreshScope: Task] = [:] + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildRequests = MenuRebuildRequestRegistry() var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] @@ -230,6 +244,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool + private var lastAgentSessionsEnabled: Bool + private var lastAgentSessionsManualHosts: String /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool @@ -290,11 +306,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) item.autosaveName = identity.autosaveName if let button = item.button { + let title = self.statusItemAccessibilityTitle( + isDebugApp: self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier)) // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). button.imageScaling = .scaleNone button.setAccessibilityIdentifier(identity.accessibilityIdentifier) - button.setAccessibilityTitle(self.statusItemAccessibilityTitle) - button.toolTip = self.statusItemAccessibilityTitle + button.setAccessibilityTitle(title) + button.toolTip = title } return item } @@ -322,9 +340,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin case waitingBrowser } - func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? { if provider == .codex { - return self.codexMenuBarMetricWindow(snapshot: snapshot) + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } return MenuBarMetricWindowResolver.rateWindow( preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot), @@ -333,45 +351,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } - private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?) -> RateWindow? { + private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?, now: Date) -> RateWindow? { guard let snapshot else { return nil } - let projection = CodexConsumerProjection.make( - surface: .menuBar, - context: CodexConsumerProjection.Context( - snapshot: snapshot, - rawUsageError: nil, - liveCredits: self.store.credits, - rawCreditsError: self.store.lastCreditsError, - liveDashboard: self.store.openAIDashboard, - rawDashboardError: self.store.lastOpenAIDashboardError, - dashboardAttachmentAuthorized: self.store.openAIDashboardAttachmentAuthorized, - dashboardRequiresLogin: self.store.openAIDashboardRequiresLogin, - now: snapshot.updatedAt)) - let lanes = projection.visibleRateLanes - let first = lanes.first.flatMap { projection.rateWindow(for: $0) } - let second = lanes.dropFirst().first.flatMap { projection.rateWindow(for: $0) } - let preference = self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) - - switch preference { - case .secondary, .tertiary: - return second ?? first - case .extraUsage: - return first - case .average: - guard self.settings.menuBarMetricSupportsAverage(for: .codex), - let primary = first, - let secondary = second - else { - return first - } - let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 - return RateWindow( - usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - case .primaryAndSecondary: - return [first, second].compactMap(\.self).max(by: { $0.usedPercent < $1.usedPercent }) - case .automatic, .primary, .monthlyPlan: - return first - } + return self.store.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } init( @@ -393,6 +375,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.store = store self.settings = settings + self.agentSessions = AgentSessionsStore(settings: settings) self.account = account self.updater = updater self.preferencesSelection = preferencesSelection @@ -408,6 +391,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed + self.lastAgentSessionsEnabled = settings.agentSessionsEnabled + self.lastAgentSessionsManualHosts = settings.agentSessionsManualHosts self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.menuCardRenderingEnabledForController = menuCardRenderingEnabled self.menuRefreshEnabledForController = menuRefreshEnabled @@ -430,6 +415,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion self.wireBindings() + self.agentSessions.onUpdate = { [weak self] in + self?.invalidateMenus(refreshOpenMenus: true) + } + if !SettingsStore.isRunningTests { + self.agentSessions.start() + } self.updateVisibility() self.updateIcons() self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( @@ -661,6 +652,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif + let agentSessionsSettingsChanged = self.settings.agentSessionsEnabled != self.lastAgentSessionsEnabled || + self.settings.agentSessionsManualHosts != self.lastAgentSessionsManualHosts + if agentSessionsSettingsChanged { + self.lastAgentSessionsEnabled = self.settings.agentSessionsEnabled + self.lastAgentSessionsManualHosts = self.settings.agentSessionsManualHosts + self.agentSessions.settingsDidChange() + } let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature @@ -766,8 +764,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons + var expectedVisibleAutosaveNames: Set = [] if mergeIcons { - self.statusItem.isVisible = anyEnabled || force + let shouldBeVisible = anyEnabled || force + self.statusItem.isVisible = shouldBeVisible + if shouldBeVisible { + expectedVisibleAutosaveNames.insert(self.statusItem.autosaveName) + } for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -781,12 +784,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true + expectedVisibleAutosaveNames.insert(item.autosaveName) } else { self.removeProviderStatusItem(for: provider) } } self.attachMenus(fallback: fallback) } + self.expectedVisibleStatusItemAutosaveNames = expectedVisibleAutosaveNames self.updateAnimationState() self.updateBlinkingState() } diff --git a/Sources/CodexBar/SyntheticTokenStore.swift b/Sources/CodexBar/SyntheticTokenStore.swift index fb4c78fd60..b3d3a41b36 100644 --- a/Sources/CodexBar/SyntheticTokenStore.swift +++ b/Sources/CodexBar/SyntheticTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw SyntheticTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index 83a31f30d0..75b7aefd07 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -4,6 +4,12 @@ import SwiftUI @MainActor struct UsageBreakdownChartMenuView: View { + enum PresentationState: Equatable { + case empty + case totalsOnly + case chart + } + private struct Point: Identifiable { let id: String let date: Date @@ -19,23 +25,49 @@ struct UsageBreakdownChartMenuView: View { } private let breakdown: [OpenAIDashboardDailyBreakdown] + private let now: Date + private let calendar: Calendar private let width: CGFloat @State private var selectedDayKey: String? - init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) { + init( + breakdown: [OpenAIDashboardDailyBreakdown], + now: Date = Date(), + calendar: Calendar = .current, + width: CGFloat) + { self.breakdown = breakdown + self.now = now + self.calendar = calendar self.width = width } var body: some View { - let model = Self.makeModel(from: self.breakdown) + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: self.breakdown, + now: self.now, + calendar: self.calendar) + let model = Self.makeModel(from: summary.daily) + let presentationState = Self.presentationState( + hasSummary: !summary.daily.isEmpty, + hasChartPoints: !model.points.isEmpty) VStack(alignment: .leading, spacing: 10) { - if model.points.isEmpty { + if presentationState != .empty { + HStack(alignment: .firstTextBaseline) { + self.summaryMetric(title: L("Today"), credits: summary.todayCredits) + Spacer(minLength: 12) + self.summaryMetric( + title: String(format: L("Last %d days"), summary.historyDays), + credits: summary.totalCredits) + } + } + + if presentationState == .empty { Text(L("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) .accessibilityLabel(L("No usage breakdown data available.")) - } else { + } else if presentationState == .chart { Chart { ForEach(model.points) { point in BarMark( @@ -55,12 +87,16 @@ struct UsageBreakdownChartMenuView: View { .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) .chartYAxis(.hidden) .chartXAxis { - AxisMarks(values: model.axisDates) { _ in + AxisMarks(values: model.axisDates) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + if let date = value.as(Date.self) { + AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + Text(date, format: .dateTime.month(.abbreviated).day()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } } } .chartLegend(.hidden) @@ -133,6 +169,12 @@ struct UsageBreakdownChartMenuView: View { .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } + static func presentationState(hasSummary: Bool, hasChartPoints: Bool) -> PresentationState { + if hasChartPoints { return .chart } + if hasSummary { return .totalsOnly } + return .empty + } + private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] @@ -154,6 +196,25 @@ struct UsageBreakdownChartMenuView: View { private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) + private func summaryMetric(title: String, credits: Double?) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(Self.creditsString(credits)) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + } + .accessibilityElement(children: .combine) + } + + private static func creditsString(_ credits: Double?) -> String { + guard let credits, credits.isFinite else { return "—" } + let value = credits.formatted(.number.precision(.fractionLength(0...2))) + return "\(value) \(L("credits"))" + } + private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } @@ -262,6 +323,16 @@ struct UsageBreakdownChartMenuView: View { return [firstDate, lastDate] } + private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { + if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + return .topLeading + } + if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + return .topTrailing + } + return .top + } + private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 23dd00fc4f..fe374a09ea 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -108,8 +108,10 @@ enum UsagePaceText { } static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude || provider == .ollama else { return nil } + guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity + else { return nil } if provider == .ollama, window.windowMinutes == nil { return nil } + if provider == .antigravity, let windowMinutes = window.windowMinutes, windowMinutes != 300 { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil } guard pace.expectedUsedPercent >= 3 else { return nil } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index c213173bda..de00f0486a 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -2,7 +2,27 @@ import SwiftUI /// Static progress fill with no implicit animations, used inside the menu card. struct UsageProgressBar: View { + enum MarkerKind: Equatable { + case quotaWarning + case workdayBoundary + } + + struct Marker: Equatable { + let percent: Double + let kind: MarkerKind + } + private static let paceStripeCount = 3 + private static let stripePunchOpacity = 0.9 + + private nonisolated static var warningMarkerPunchWidth: CGFloat { + 5 + } + + private nonisolated static var warningMarkerStripeWidth: CGFloat { + 1 + } + private static func paceStripeWidth(for scale: CGFloat) -> CGFloat { 2 } @@ -18,6 +38,7 @@ struct UsageProgressBar: View { let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] + let workdayMarkerPercents: [Double] @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -27,7 +48,8 @@ struct UsageProgressBar: View { accessibilityLabel: String, pacePercent: Double? = nil, paceOnTop: Bool = true, - warningMarkerPercents: [Double] = []) + warningMarkerPercents: [Double] = [], + workdayMarkerPercents: [Double] = []) { self.percent = percent self.tint = tint @@ -35,6 +57,7 @@ struct UsageProgressBar: View { self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents + self.workdayMarkerPercents = workdayMarkerPercents } private var clamped: Double { @@ -55,9 +78,9 @@ struct UsageProgressBar: View { let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 - let markerPercents = self.warningMarkerPercents - .map(Self.clampedPercent) - .filter { $0 > 0 && $0 < 100 } + let markers = Self.resolvedMarkers( + warningPercents: self.warningMarkerPercents, + workdayPercents: self.workdayMarkerPercents) let cornerRadius = size.height / 2 let cornerSize = CGSize(width: cornerRadius, height: cornerRadius) @@ -78,17 +101,31 @@ struct UsageProgressBar: View { with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))) } - if !markerPercents.isEmpty { - let markerColor = Self.warningMarkerColor(isHighlighted: self.isHighlighted) - for markerPercent in markerPercents { - let x = size.width * markerPercent / 100 + for marker in markers { + let x = size.width * marker.percent / 100 + switch marker.kind { + case .quotaWarning: let markerRect = Self.warningMarkerRect(x: x, size: size, scale: scale) - let markerPath = Path { p in - p.addRoundedRect( - in: markerRect, - cornerSize: CGSize(width: markerRect.width / 2, height: markerRect.width / 2)) + let markerStripeRect = Self.warningMarkerStripeRect(markerRect, scale: scale) + let markerPunchPath = Path { p in + p.addRect(Self.extendedMarkerRect(markerRect, size: size)) } - context.fill(markerPath, with: .color(markerColor)) + let markerStripePath = Path { p in + p.addRect(Self.extendedMarkerRect(markerStripeRect, size: size)) + } + + // Match the pace stripe treatment: punch through the bar, then draw a slimmer neutral stripe. + context.blendMode = .destinationOut + context.fill(markerPunchPath, with: .color(.white.opacity(Self.stripePunchOpacity))) + context.blendMode = .normal + context.fill( + markerStripePath, + with: .color(Self.warningMarkerColor(isHighlighted: self.isHighlighted))) + case .workdayBoundary: + let markerRect = Self.workdayMarkerRect(x: x, size: size, scale: scale) + context.fill( + Path(markerRect), + with: .color(Self.workdayMarkerColor(isHighlighted: self.isHighlighted))) } } @@ -111,7 +148,7 @@ struct UsageProgressBar: View { // Punch out of the accumulated track+fill pixels. context.blendMode = .destinationOut - context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(0.9))) + context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(Self.stripePunchOpacity))) context.blendMode = .normal context.fill(stripes.center.applying(shift), with: .color(stripeColor)) @@ -119,7 +156,51 @@ struct UsageProgressBar: View { } .frame(height: 6) .accessibilityLabel(self.accessibilityLabel) - .accessibilityValue(L("%d percent", Self.displayPercent(self.clamped))) + .accessibilityValue(self.markerAccessibilityValue) + } + + private var markerAccessibilityValue: String { + var parts = [L("%d percent", Self.displayPercent(self.clamped))] + let markers = Self.resolvedMarkers( + warningPercents: self.warningMarkerPercents, + workdayPercents: self.workdayMarkerPercents) + let warnings = markers.filter { $0.kind == .quotaWarning }.map(Self.markerPercentText) + let workdays = markers.filter { $0.kind == .workdayBoundary }.map(Self.markerPercentText) + if !warnings.isEmpty { + parts.append("\(L("quota_warnings_title")): \(warnings.joined(separator: ", "))") + } + if !workdays.isEmpty { + parts.append("\(L("weekly_progress_work_days_title")): \(workdays.joined(separator: ", "))") + } + return parts.joined(separator: ". ") + } + + nonisolated static func resolvedMarkers( + warningPercents: [Double], + workdayPercents: [Double]) -> [Marker] + { + let warnings = Self.normalizedMarkerPercents(warningPercents) + let workdays = Self.normalizedMarkerPercents(workdayPercents) + .filter { workday in !warnings.contains { abs($0 - workday) < 0.001 } } + return ( + warnings.map { Marker(percent: $0, kind: .quotaWarning) } + + workdays.map { Marker(percent: $0, kind: .workdayBoundary) }) + .sorted { lhs, rhs in lhs.percent < rhs.percent } + } + + private nonisolated static func normalizedMarkerPercents(_ values: [Double]) -> [Double] { + values + .map(self.clampedPercent) + .filter { $0 > 0 && $0 < 100 } + .reduce(into: [Double]()) { result, value in + if !result.contains(where: { abs($0 - value) < 0.001 }) { + result.append(value) + } + } + } + + private nonisolated static func markerPercentText(_ marker: Marker) -> String { + "\(Int(marker.percent.rounded()))%" } /// Aligns edge rendering with the rounded percent label: sub-0.5% is empty and 99.5%+ is full. @@ -181,21 +262,57 @@ struct UsageProgressBar: View { nonisolated static func warningMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect { let scale = max(rawScale, 1) - let width = max(1 / scale, 1) - let height = min(size.height, max(1 / scale, size.height * 0.55)) + let width = Self.warningMarkerPunchWidth let align: (CGFloat) -> CGFloat = { value in (value * scale).rounded() / scale } return CGRect( x: align(x - width / 2), - y: align((size.height - height) / 2), + y: 0, + width: width, + height: align(size.height)) + } + + nonisolated static func warningMarkerStripeRect(_ markerRect: CGRect, scale rawScale: CGFloat) -> CGRect { + let scale = max(rawScale, 1) + let width = min(markerRect.width, max(1 / scale, Self.warningMarkerStripeWidth)) + let align: (CGFloat) -> CGFloat = { value in + (value * scale).rounded() / scale + } + + return CGRect( + x: align(markerRect.midX - width / 2), + y: markerRect.minY, + width: width, + height: markerRect.height) + } + + nonisolated static func workdayMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect { + let scale = max(rawScale, 1) + let width = 1 / scale + let height = max(1 / scale, size.height * 0.5) + let align: (CGFloat) -> CGFloat = { value in + (value * scale).rounded() / scale + } + return CGRect( + x: align(x - width / 2), + y: align(size.height - height), width: width, height: align(height)) } + private nonisolated static func extendedMarkerRect(_ rect: CGRect, size: CGSize) -> CGRect { + let extend = size.height * 2 + return rect.insetBy(dx: 0, dy: -extend) + } + nonisolated static func warningMarkerColor(isHighlighted: Bool) -> Color { - isHighlighted ? .white.opacity(0.72) : .primary.opacity(0.32) + isHighlighted ? .white.opacity(0.96) : .primary.opacity(0.68) + } + + nonisolated static func workdayMarkerColor(isHighlighted: Bool) -> Color { + isHighlighted ? .white.opacity(0.55) : .primary.opacity(0.30) } private nonisolated static func displayPercent(_ percent: Double) -> Int { diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 0b00f83249..a4b023c05f 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -2,6 +2,10 @@ import CodexBarCore import Foundation extension UsageStore { + func version(for provider: UsageProvider) -> String? { + self.versions[provider] + } + var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } @@ -58,6 +62,8 @@ extension UsageStore { return OpenRouterSettingsError.missingToken.errorDescription case .crossmodel: return CrossModelSettingsError.missingToken.errorDescription + case .clawrouter: + return ClawRouterUsageError.missingCredentials.errorDescription case .azureopenai: return AzureOpenAISettingsError.missingAPIKey.errorDescription case .elevenlabs: diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index 40a02193d4..de3456ca56 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -7,6 +7,9 @@ extension UsageStore { self.refreshingProviders.remove(provider) self.snapshots.removeValue(forKey: provider) self.errors[provider] = nil + if provider == .gemini { + self.clearGeminiConsumerTierDeprecationObservation() + } self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.lastSourceLabels.removeValue(forKey: provider) self.lastFetchAttempts.removeValue(forKey: provider) diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift new file mode 100644 index 0000000000..f9d51ab328 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -0,0 +1,133 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + typealias CodexResetCreditsFetcher = @Sendable ([String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot? + + func codexResetCreditsFetcher() -> CodexResetCreditsFetcher { + if let override = self._test_codexResetCreditsFetcherOverride { + return override + } + return { env in + try await Self.fetchCodexResetCredits(env: env) + } + } + + func handleCodexResetCreditNotifications(snapshot: UsageSnapshot) { + guard self.settings.showOptionalCreditsAndExtraUsage, + let resetCredits = snapshot.codexResetCredits + else { + return + } + CodexResetCreditExpiryNotifier().postExpiringCreditsIfNeeded( + snapshot: resetCredits, + resetStyle: self.settings.resetTimeDisplayStyle) + } + + nonisolated static func attachingCodexResetCreditsIfNeeded( + to outcome: ProviderFetchOutcome, + env: [String: String], + fetcher: @escaping CodexResetCreditsFetcher) async -> ProviderFetchOutcome + { + guard case let .success(result) = outcome.result else { return outcome } + let requiresResetCreditRescue = Self.requiresResetCreditRescue(result) + if result.usage.codexResetCredits != nil { + return outcome + } + + do { + try Task.checkCancellation() + let resetCredits = try await fetcher(env) + try Task.checkCancellation() + if requiresResetCreditRescue, + (resetCredits?.availableInventory(at: result.usage.updatedAt).count ?? 0) == 0 + { + return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound)) + } + return outcome.replacingUsage(result.usage.withCodexResetCredits(resetCredits)) + } catch { + if error is CancellationError || Task.isCancelled { + return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: outcome.attempts) + } + if requiresResetCreditRescue { + return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound)) + } + // A successful usage refresh must not retain reset-credit inventory from an older snapshot. + return outcome.replacingUsage(result.usage.withCodexResetCredits(nil)) + } + } + + private nonisolated static func requiresResetCreditRescue(_ result: ProviderFetchResult) -> Bool { + result.strategyID == "codex.oauth" + && result.credits == nil + && result.usage.primary == nil + && result.usage.secondary == nil + && result.usage.tertiary == nil + && (result.usage.extraRateWindows?.isEmpty ?? true) + } + + nonisolated static func fetchCodexResetCredits( + env: [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try Task.checkCancellation() + let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env) + return try await Self.fetchCodexResetCredits( + credentials: credentials, + env: env, + request: { accessToken, accountId, requestEnvironment in + try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: accessToken, + accountId: accountId, + env: requestEnvironment) + }) + } + + private nonisolated static func fetchCodexResetCredits( + credentials: CodexOAuthCredentials, + env: [String: String], + request: @escaping @Sendable (String, String?, [String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try Task.checkCancellation() + // Supplemental inventory is strictly read-only. The main OAuth usage strategy owns token refreshes; + // CLI/web winners with stale credentials simply skip this best-effort GET. + guard !credentials.needsRefresh else { return nil } + return try await request(credentials.accessToken, credentials.accountId, env) + } + + nonisolated static func _fetchCodexResetCreditsForTesting( + credentials: CodexOAuthCredentials, + env: [String: String] = [:], + request: @escaping @Sendable (String, String?, [String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try await self.fetchCodexResetCredits(credentials: credentials, env: env, request: request) + } +} + +extension ProviderFetchOutcome { + fileprivate func replacingUsage(_ usage: UsageSnapshot) -> ProviderFetchOutcome { + guard case let .success(result) = self.result else { return self } + return ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: usage, + credits: result.credits, + dashboard: result.dashboard, + sourceLabel: result.sourceLabel, + strategyID: result.strategyID, + strategyKind: result.strategyKind, + claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, + claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, + claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable)), + attempts: self.attempts) + } + + fileprivate func replacingResult( + with result: Result) -> ProviderFetchOutcome + { + ProviderFetchOutcome(result: result, attempts: self.attempts) + } +} diff --git a/Sources/CodexBar/UsageStore+GeminiMigration.swift b/Sources/CodexBar/UsageStore+GeminiMigration.swift new file mode 100644 index 0000000000..fce0a8ed46 --- /dev/null +++ b/Sources/CodexBar/UsageStore+GeminiMigration.swift @@ -0,0 +1,16 @@ +import CodexBarCore + +extension UsageStore { + static func isGeminiConsumerTierDeprecationError(_ error: Error?) -> Bool { + (error as? GeminiStatusProbeError) == .consumerTierDeprecated + } + + func observeGeminiConsumerTierDeprecation(from error: Error) { + guard Self.isGeminiConsumerTierDeprecationError(error) else { return } + self.geminiObservedConsumerTierDeprecation = true + } + + func clearGeminiConsumerTierDeprecationObservation() { + self.geminiObservedConsumerTierDeprecation = false + } +} diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 5b8429ec34..f629158a76 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -5,7 +5,7 @@ import Foundation extension UsageStore { /// Returns the enabled candidate provider with the highest usage percentage (closest to rate limit). /// Excludes providers that are fully rate-limited. - func providerWithHighestUsage(candidateProviders: [UsageProvider]? = nil) + func providerWithHighestUsage(candidateProviders: [UsageProvider]? = nil, now: Date = Date()) -> (provider: UsageProvider, usedPercent: Double)? { let candidateSet = candidateProviders.map(Set.init) @@ -14,14 +14,19 @@ extension UsageStore { where candidateSet?.contains(provider) ?? true { guard let snapshot = self.snapshots[provider] else { continue } - guard let window = self.menuBarMetricWindowForHighestUsage(provider: provider, snapshot: snapshot) else { + guard let window = self.menuBarMetricWindowForHighestUsage( + provider: provider, + snapshot: snapshot, + now: now) + else { continue } let percent = window.usedPercent guard !self.shouldExcludeFromHighestUsage( provider: provider, snapshot: snapshot, - metricPercent: percent) + metricPercent: percent, + now: now) else { continue } @@ -32,11 +37,18 @@ extension UsageStore { return highest } - private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + private func menuBarMetricWindowForHighestUsage( + provider: UsageProvider, + snapshot: UsageSnapshot, + now: Date) -> RateWindow? + { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) if provider == .antigravity, effectivePreference == .automatic { return Self.mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) } + if provider == .codex { + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) + } return MenuBarMetricWindowResolver.rateWindow( preference: effectivePreference, provider: provider, @@ -47,12 +59,21 @@ extension UsageStore { private func shouldExcludeFromHighestUsage( provider: UsageProvider, snapshot: UsageSnapshot, - metricPercent: Double) + metricPercent: Double, + now: Date) -> Bool { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) guard metricPercent >= 100 else { return false } if provider == .codex || provider == .claude, effectivePreference == .primaryAndSecondary { + if provider == .codex, + self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now).hasBindingWeeklyCap + { + return true + } // A Claude spend-limit-only snapshot has no real session/weekly lanes; the metric resolves to // the spend-limit window, so reaching here (metricPercent >= 100) means the spend limit itself // is exhausted. Mirror that resolver fallback and exclude, instead of inspecting the raw 0% diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a460d791f6..b7d400e9cd 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -5,10 +5,33 @@ extension UsageStore { private nonisolated static let limitResetThreshold = 1.0 nonisolated static let sessionLimitResetDetectorDefaultsKey = "sessionLimitResetDetectorStates" private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates" + private nonisolated static let claudeOAuthAccountUuidMapDefaultsKey = "ClaudeOAuthHistoryOwnerAccountUuidMapV1" + private nonisolated static let claudeOAuthAccountCandidateMapDefaultsKey = + "ClaudeOAuthHistoryOwnerAccountCandidateMapV1" private nonisolated static let weeklyWindowMinutes = 7 * 24 * 60 private nonisolated static let planUtilizationUnscopedPreferredKey = "__unscoped__" private nonisolated static let claudeOAuthPlanUtilizationAccountKeyPrefix = "__claude_oauth__:" + enum ClaudeOAuthActiveAccountObservation: Equatable, Sendable { + case stable(identity: String?) + case changed + } + + struct ClaudeOAuthAccountBindingCandidate: Codable, Equatable { + let identity: String + let observedAt: Date + } + + private struct ClaudeOAuthHistoryEvidence { + let owner: String + let persistentRefHash: String? + let keychainCredentialMismatch: Bool + let keychainCredentialAbsent: Bool + let keychainCredentialUnavailable: Bool + let activeAccountObservation: ClaudeOAuthActiveAccountObservation + let observedAt: Date + } + struct LimitResetDetectorState: Codable, Equatable { let wasAboveThreshold: Bool let lastObservedAt: Date @@ -152,6 +175,10 @@ extension UsageStore { account: ProviderTokenAccount? = nil, claudeOAuthPersistentRefHash: String? = nil, claudeOAuthHistoryOwnerIdentifier: String? = nil, + claudeOAuthKeychainCredentialMismatch: Bool = false, + claudeOAuthKeychainCredentialAbsent: Bool = false, + claudeOAuthKeychainCredentialUnavailable: Bool = false, + claudeOAuthActiveAccountObservation: ClaudeOAuthActiveAccountObservation = .stable(identity: nil), isClaudeOAuthSample: Bool = false, shouldUpdatePreferredAccountKey: Bool = true, shouldAdoptUnscopedHistory: Bool = true, @@ -159,9 +186,20 @@ extension UsageStore { async { let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) + var effectiveOwner = claudeOAuthHistoryOwnerIdentifier + if provider == .claude, isClaudeOAuthSample, let owner = claudeOAuthHistoryOwnerIdentifier { + effectiveOwner = self.resolvedClaudeOAuthHistoryOwner(evidence: ClaudeOAuthHistoryEvidence( + owner: owner, + persistentRefHash: claudeOAuthPersistentRefHash, + keychainCredentialMismatch: claudeOAuthKeychainCredentialMismatch, + keychainCredentialAbsent: claudeOAuthKeychainCredentialAbsent, + keychainCredentialUnavailable: claudeOAuthKeychainCredentialUnavailable, + activeAccountObservation: claudeOAuthActiveAccountObservation, + observedAt: now)) + } let detectorAccountKey = if provider == .claude, isClaudeOAuthSample { Self.claudeOAuthPlanUtilizationAccountKey( - historyOwnerIdentifier: claudeOAuthHistoryOwnerIdentifier, + historyOwnerIdentifier: effectiveOwner, corroboratingPersistentRefHash: claudeOAuthPersistentRefHash) } else { self.planUtilizationAccountKey( @@ -199,7 +237,7 @@ extension UsageStore { snapshot: snapshot, preferredAccount: preferredAccount, claudeOAuthPersistentRefHash: claudeOAuthPersistentRefHash, - claudeOAuthHistoryOwnerIdentifier: claudeOAuthHistoryOwnerIdentifier, + claudeOAuthHistoryOwnerIdentifier: effectiveOwner, isClaudeOAuthSample: isClaudeOAuthSample, shouldUpdatePreferredAccountKey: shouldUpdatePreferredAccountKey, shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, @@ -884,6 +922,186 @@ extension UsageStore { } } + // MARK: - Active Claude account corroboration (~/.claude.json) + + /// The currently-active Claude account UUID, read prompt-free from `~/.claude.json`. This is the only + /// always-fresh, never-gated signal of the active account on a background poll: Claude Code's `/login` + /// updates the Keychain item in place and leaves `~/.claude/.credentials.json` stale, but immediately + /// rewrites `oauthAccount.accountUuid` in this sibling plain file. Returns nil on absence/corruption. + nonisolated static func activeClaudeAccountUuid() -> String? { + ClaudeActiveAccountProbe.activeClaudeAccountUuid() + } + + /// Persisted `historyOwnerIdentifier -> hashed active account identity` bindings. + nonisolated static func loadClaudeOAuthAccountUuidMap(from userDefaults: UserDefaults) -> [String: String] { + guard let data = userDefaults.data(forKey: claudeOAuthAccountUuidMapDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: String].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode Claude OAuth history owner account UUID map", + metadata: ["error": String(describing: error)]) + return [:] + } + } + + /// Persist the `historyOwnerIdentifier -> active accountUuid` map. Mirrors `persistLimitResetDetectorStates`. + func persistClaudeOAuthAccountUuidMap(_ map: [String: String]) { + do { + let data = try JSONEncoder().encode(map) + self.settings.userDefaults.set(data, forKey: Self.claudeOAuthAccountUuidMapDefaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode Claude OAuth history owner account UUID map", + metadata: ["error": String(describing: error)]) + } + } + + nonisolated static func loadClaudeOAuthAccountBindingCandidateMap( + from userDefaults: UserDefaults) -> [String: ClaudeOAuthAccountBindingCandidate] + { + guard let data = userDefaults.data(forKey: claudeOAuthAccountCandidateMapDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: ClaudeOAuthAccountBindingCandidate].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode Claude OAuth account binding candidates", + metadata: ["error": String(describing: error)]) + return [:] + } + } + + private func confirmClaudeOAuthAccountBindingCandidate( + owner: String, + identity: String, + observedAt: Date) -> Bool + { + var candidates = Self.loadClaudeOAuthAccountBindingCandidateMap(from: self.settings.userDefaults) + if let candidate = candidates[owner], + candidate.identity == identity, + candidate.observedAt < observedAt + { + candidates.removeValue(forKey: owner) + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + return true + } + candidates[owner] = ClaudeOAuthAccountBindingCandidate(identity: identity, observedAt: observedAt) + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + return false + } + + private func resolvedClaudeOAuthHistoryOwner(evidence: ClaudeOAuthHistoryEvidence) -> String? { + let requiresClaudeCodeCorroboration = evidence.persistentRefHash != nil + || evidence.keychainCredentialMismatch + || evidence.keychainCredentialAbsent + || evidence.keychainCredentialUnavailable + guard requiresClaudeCodeCorroboration else { + // Explicit/environment credentials do not belong to Claude Code's active-account lifecycle. + return evidence.owner + } + guard case let .stable(currentAccountIdentity) = evidence.activeAccountObservation else { + // An account/credential change while capturing the UUID cannot safely identify this sample. + return nil + } + var map = Self.loadClaudeOAuthAccountUuidMap(from: self.settings.userDefaults) + if let mapped = map[evidence.owner] { + guard let currentAccountIdentity else { + return evidence.keychainCredentialMismatch || evidence.keychainCredentialUnavailable + ? nil + : evidence.owner + } + guard mapped != currentAccountIdentity else { + self.clearClaudeOAuthAccountBindingCandidate(owner: evidence.owner) + return evidence.owner + } + guard evidence.persistentRefHash != nil, + self.confirmClaudeOAuthAccountBindingCandidate( + owner: evidence.owner, + identity: currentAccountIdentity, + observedAt: evidence.observedAt) + else { + return nil + } + // Two stable exact-Keychain observations repair a binding poisoned by a non-atomic login. + map[evidence.owner] = currentAccountIdentity + self.persistClaudeOAuthAccountUuidMap(map) + return evidence.owner + } + + if evidence.keychainCredentialUnavailable, + !evidence.keychainCredentialMismatch + { + // With no authoritative binding, the secret-derived file owner is the only safe bootstrap scope. + // Existing bindings are checked above, so normal background gating cannot bypass a detected switch. + return evidence.owner + } + if evidence.keychainCredentialAbsent { + // A proven-empty Keychain leaves the file credential as the only owner. Existing bindings were + // checked above, so an unbound owner is safe without inventing account continuity. + return evidence.owner + } + + guard let currentAccountIdentity else { + return evidence.keychainCredentialMismatch || evidence.keychainCredentialUnavailable + ? nil + : evidence.owner + } + guard evidence.persistentRefHash != nil else { return nil } + // Two stable exact-Keychain observations are required before a first binding becomes authoritative. + if self.confirmClaudeOAuthAccountBindingCandidate( + owner: evidence.owner, + identity: currentAccountIdentity, + observedAt: evidence.observedAt) + { + map[evidence.owner] = currentAccountIdentity + self.persistClaudeOAuthAccountUuidMap(map) + } + return evidence.owner + } + + private func clearClaudeOAuthAccountBindingCandidate(owner: String) { + var candidates = Self.loadClaudeOAuthAccountBindingCandidateMap(from: self.settings.userDefaults) + guard candidates.removeValue(forKey: owner) != nil else { return } + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + } + + private func persistClaudeOAuthAccountBindingCandidateMap( + _ candidates: [String: ClaudeOAuthAccountBindingCandidate]) + { + do { + let data = try JSONEncoder().encode(candidates) + self.settings.userDefaults.set(data, forKey: Self.claudeOAuthAccountCandidateMapDefaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode Claude OAuth account binding candidates", + metadata: ["error": String(describing: error)]) + } + } + + nonisolated static func activeClaudeAccountIdentity() -> String? { + self.activeClaudeAccountUuid().map(self.claudeAccountIdentity) + } + + private nonisolated static func claudeAccountIdentity(_ uuid: String) -> String { + self.sha256Hex( + "claude:active-account:v1:\(uuid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())") + } + + #if DEBUG + static func withActiveClaudeAccountUuidForTesting( + _ uuid: String?, + _ body: () async throws -> T) async rethrows -> T + { + try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue( + .value(uuid), + operation: body) + } + + nonisolated static func _activeClaudeAccountIdentityForTesting(_ uuid: String) -> String { + self.claudeAccountIdentity(uuid) + } + #endif + private func resolvePlanUtilizationAccountKey( provider: UsageProvider, snapshot: UsageSnapshot?, @@ -1415,3 +1633,46 @@ actor PlanUtilizationHistoryPersistenceCoordinator { }.value } } + +/// Prompt-free reader for the active Claude account UUID recorded in `~/.claude.json`. The `@TaskLocal` test +/// seam lives here (not on `UsageStore`) because Swift forbids stored properties in extensions and task-local +/// storage must be nonisolated, whereas `UsageStore` is `@MainActor`. +private enum ClaudeActiveAccountProbe { + #if DEBUG + enum Override: Sendable { + case value(String?) + } + + @TaskLocal static var activeClaudeAccountUuidOverrideForTesting: Override? + #endif + + private struct ClaudeConfigAccount: Decodable { + struct OAuthAccount: Decodable { + let accountUuid: String? + } + + let oauthAccount: OAuthAccount? + } + + static func activeClaudeAccountUuid() -> String? { + #if DEBUG + if case let .value(uuid) = self.activeClaudeAccountUuidOverrideForTesting { + return uuid + } + #endif + // `~/.claude.json` is a SIBLING of `.claude/`, not inside it. Home resolution mirrors + // `ClaudeOAuthCredentials.defaultCredentialsURL()`. This intentionally does NOT honor + // CLAUDE_CONFIG_DIR: the credential store that yields `historyOwnerIdentifier` is purely + // home-relative, so the accountUuid corroboration must resolve against the same home or the + // two signals would point at different accounts. + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json") + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data), + let uuid = decoded.oauthAccount?.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), + !uuid.isEmpty + else { + return nil + } + return uuid + } +} diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index 915e82b43f..646d04c9a6 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -36,6 +36,59 @@ extension UsageStore { rateWindow: secondaryWindow, source: source, accountDisplayName: accountDisplayName) + self.handleClaudeExtraWindowQuotaWarnings( + provider: provider, + snapshot: snapshot, + accountDisplayName: accountDisplayName) + } + + /// Emit weekly-lane quota warnings for Claude's extra rate windows — model-scoped weekly + /// carve-outs (`claude-weekly-scoped-*`, e.g. Fable) and Daily Routines — which surface in the + /// menu but were otherwise silent. Antigravity's summary windows are already covered by the + /// primary and weekly lanes above, so they are excluded here. + private func handleClaudeExtraWindowQuotaWarnings( + provider: UsageProvider, + snapshot: UsageSnapshot, + accountDisplayName: String?) + { + guard provider == .claude else { return } + guard self.settings.quotaWarningEnabled(provider: provider, window: .weekly) else { + let extraWindowKeys = self.quotaWarningState.keys.filter { + $0.provider == provider && $0.windowID != nil + } + for key in extraWindowKeys { + self.quotaWarningState.removeValue(forKey: key) + } + return + } + + let windows = (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) + for named in windows { + self.handleQuotaWarningTransition( + provider: provider, + window: .weekly, + rateWindow: named.window, + source: nil, + accountDisplayName: accountDisplayName, + windowID: named.id, + windowDisplayLabel: named.title) + } + // A missing extras payload is not authoritative, but when another notifiable window remains, + // reconcile tracked IDs so a later incarnation of a disappeared window can warn again. + guard !windows.isEmpty else { return } + let activeIDs = Set(windows.map(\.id)) + let staleKeys = self.quotaWarningState.keys.filter { key in + guard key.provider == provider, let windowID = key.windowID else { return false } + return !activeIDs.contains(windowID) + } + for key in staleKeys { + self.quotaWarningState.removeValue(forKey: key) + } + } + + private static func isClaudeNotifiableExtraWindow(_ named: NamedRateWindow) -> Bool { + guard named.usageKnown else { return false } + return named.id.hasPrefix("claude-weekly-scoped-") || named.id == "claude-routines" } private func handleQuotaWarningTransition( @@ -43,9 +96,11 @@ extension UsageStore { window: QuotaWarningWindow, rateWindow: RateWindow?, source: SessionQuotaWindowSource?, - accountDisplayName: String?) + accountDisplayName: String?, + windowID: String? = nil, + windowDisplayLabel: String? = nil) { - let key = QuotaWarningStateKey(provider: provider, window: window) + let key = QuotaWarningStateKey(provider: provider, window: window, windowID: windowID) guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { self.quotaWarningState.removeValue(forKey: key) return @@ -84,7 +139,9 @@ extension UsageStore { window: window, threshold: threshold, currentRemaining: currentRemaining, - accountDisplayName: accountDisplayName), + accountDisplayName: accountDisplayName, + windowID: windowID, + windowDisplayLabel: windowDisplayLabel), provider: provider) } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 87e8e3c63b..e52d32cbb3 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -6,6 +6,7 @@ extension UsageStore { let generation: UInt64 let codexExpectedGuard: CodexAccountScopedRefreshGuard? let claudeOAuthHistoryPersistentRefHash: String? + let claudeOAuthActiveAccountObservation: ClaudeOAuthActiveAccountObservation } static func commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( @@ -158,6 +159,10 @@ extension UsageStore { await MainActor.run { self.kiloScopeSnapshots = [] } } + if provider == .claude { + self.scheduleClaudeSwapAccountRefresh(generation: generation) + } + let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { await self.refreshTokenAccounts( @@ -176,22 +181,26 @@ extension UsageStore { : nil let fetchContext = self.makeFetchContext(provider: provider, override: nil) let descriptor = spec.descriptor + let codexResetCreditsFetcher = self.codexResetCreditsFetcher() // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. let outcome = await withTaskGroup( of: ProviderFetchOutcome.self, returning: ProviderFetchOutcome.self) { group in group.addTask { - await descriptor.fetchOutcome(context: fetchContext) + let outcome = await descriptor.fetchOutcome(context: fetchContext) + guard provider == .codex else { return outcome } + return await Self.attachingCodexResetCreditsIfNeeded( + to: outcome, + env: fetchContext.env, + fetcher: codexResetCreditsFetcher) } return await group.next()! } - let claudeAuthFingerprintAfterFetch = provider == .claude - ? await Self.captureClaudeAuthFingerprintToken() - : nil - let claudeKeychainPersistentRefHashAfterFetch = provider == .claude - ? await Self.captureClaudeKeychainPersistentRefHash() + let claudeHistoryAccountState = provider == .claude + ? await Self.captureClaudeHistoryAccountState() : nil + let claudeAuthFingerprintAfterFetch = claudeHistoryAccountState?.fingerprintToken let claudeAuthChangedDuringFetch = Self.claudeAuthChangedDuringFetch( provider: provider, beforeFetch: claudeAuthStateBeforeFetch, @@ -206,7 +215,11 @@ extension UsageStore { let claudeOAuthHistoryPersistentRefHash = Self.stableClaudeKeychainPersistentRefHash( beforeFetch: claudeAuthStateBeforeFetch, afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch, - afterFetchPersistentRefHash: claudeKeychainPersistentRefHashAfterFetch) + afterFetchPersistentRefHash: claudeHistoryAccountState?.keychainPersistentRefHash, + accountStateWasStable: claudeHistoryAccountState?.wasStable == true) + let claudeOAuthActiveAccountObservation = Self.claudeOAuthActiveAccountObservation( + beforeFetch: claudeAuthStateBeforeFetch, + afterFetch: claudeHistoryAccountState) // Credential detection consumes change markers. Clean up before rejecting a superseded generation; // replacement refreshes wait for their predecessor, so they cannot race this state reset. if claudeCredentialsChanged { @@ -222,7 +235,8 @@ extension UsageStore { context: ProviderRefreshOutcomeContext( generation: generation, codexExpectedGuard: codexExpectedGuard, - claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash)) + claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash, + claudeOAuthActiveAccountObservation: claudeOAuthActiveAccountObservation)) } private func applyProviderRefreshOutcome( @@ -256,6 +270,9 @@ extension UsageStore { let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) + if provider == .codex { + self.handleCodexResetCreditNotifications(snapshot: backfilled) + } self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { @@ -268,6 +285,9 @@ extension UsageStore { } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil + if provider == .gemini { + self.clearGeminiConsumerTierDeprecationObservation() + } self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.failureGates[provider]?.recordSuccess() if provider == .codex { @@ -294,6 +314,15 @@ extension UsageStore { claudeOAuthHistoryOwnerIdentifier: isClaudeOAuthSample ? result.claudeOAuthHistoryOwnerIdentifier : nil, + claudeOAuthKeychainCredentialMismatch: isClaudeOAuthSample + && result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: isClaudeOAuthSample + && result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: isClaudeOAuthSample + && (result.claudeOAuthKeychainCredentialUnavailable + || (result.claudeOAuthKeychainPersistentRefHash != nil + && claudeOAuthPersistentRefHash == nil)), + claudeOAuthActiveAccountObservation: context.claudeOAuthActiveAccountObservation, isClaudeOAuthSample: isClaudeOAuthSample) guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } if let runtime = self.providerRuntimes[provider] { @@ -338,6 +367,9 @@ extension UsageStore { if provider == .kilo { self.kiloScopeSnapshots = [] } + if provider == .claude { + self.clearClaudeSwapAccountState() + } self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.failureGates[provider]?.reset() @@ -356,6 +388,15 @@ extension UsageStore { let credentialsFileChanged: Bool let keychainFingerprintChanged: Bool let keychainPersistentRefHash: String? + let activeAccountIdentity: String? + let accountStateWasStable: Bool + } + + private struct ClaudeHistoryAccountState { + let fingerprintToken: String + let keychainPersistentRefHash: String? + let activeAccountIdentity: String? + let wasStable: Bool } private nonisolated static func claudeCredentialsChanged( @@ -387,48 +428,77 @@ extension UsageStore { { await withTaskGroup(of: ClaudeRefreshAuthState.self, returning: ClaudeRefreshAuthState.self) { group in group.addTask { - let fingerprintToken = ClaudeOAuthCredentialsStore.authFingerprintToken() let credentialsFileChanged = invalidateCredentialsFile ? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() : false let keychainFingerprintChanged = ClaudeOAuthCredentialsStore .claudeKeychainFingerprintChangedWithoutConsuming() - let keychainPersistentRefHash = ClaudeOAuthCredentialsStore + let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() + let persistentRefBefore = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let activeAccountIdentity = Self.activeClaudeAccountIdentity() + let persistentRefAfter = ClaudeOAuthCredentialsStore .claudeKeychainPersistentRefHashWithoutPrompt() + let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() + let accountStateWasStable = fingerprintBefore == fingerprintAfter + && persistentRefBefore == persistentRefAfter return ClaudeRefreshAuthState( - fingerprintToken: fingerprintToken, + fingerprintToken: fingerprintAfter, credentialsFileChanged: credentialsFileChanged, keychainFingerprintChanged: keychainFingerprintChanged, - keychainPersistentRefHash: keychainPersistentRefHash) + keychainPersistentRefHash: persistentRefAfter, + activeAccountIdentity: activeAccountIdentity, + accountStateWasStable: accountStateWasStable) } return await group.next()! } } - private nonisolated static func captureClaudeAuthFingerprintToken() async -> String { - await withTaskGroup(of: String.self, returning: String.self) { group in + private nonisolated static func captureClaudeHistoryAccountState() async -> ClaudeHistoryAccountState { + await withTaskGroup(of: ClaudeHistoryAccountState.self, returning: ClaudeHistoryAccountState.self) { group in group.addTask { - ClaudeOAuthCredentialsStore.authFingerprintToken() + let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() + let persistentRefBefore = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let activeAccountIdentity = Self.activeClaudeAccountIdentity() + let persistentRefAfter = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() + let wasStable = fingerprintBefore == fingerprintAfter && persistentRefBefore == persistentRefAfter + return ClaudeHistoryAccountState( + fingerprintToken: fingerprintAfter, + keychainPersistentRefHash: persistentRefAfter, + activeAccountIdentity: activeAccountIdentity, + wasStable: wasStable) } return await group.next()! } } - private nonisolated static func captureClaudeKeychainPersistentRefHash() async -> String? { - await withTaskGroup(of: String?.self, returning: String?.self) { group in - group.addTask { - ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt() - } - return await group.next()! + private nonisolated static func claudeOAuthActiveAccountObservation( + beforeFetch: ClaudeRefreshAuthState?, + afterFetch: ClaudeHistoryAccountState?) -> ClaudeOAuthActiveAccountObservation + { + guard let beforeFetch, + beforeFetch.accountStateWasStable, + let afterFetch, + afterFetch.wasStable, + beforeFetch.activeAccountIdentity == afterFetch.activeAccountIdentity + else { + return .changed } + return .stable(identity: afterFetch.activeAccountIdentity) } private nonisolated static func stableClaudeKeychainPersistentRefHash( beforeFetch: ClaudeRefreshAuthState?, afterFetchFingerprintToken: String?, - afterFetchPersistentRefHash: String?) -> String? + afterFetchPersistentRefHash: String?, + accountStateWasStable: Bool) -> String? { - guard let beforeFetch, + guard accountStateWasStable, + let beforeFetch, + beforeFetch.accountStateWasStable, beforeFetch.fingerprintToken == afterFetchFingerprintToken, let beforeFetchPersistentRefHash = beforeFetch.keychainPersistentRefHash, beforeFetchPersistentRefHash == afterFetchPersistentRefHash @@ -450,9 +520,33 @@ extension UsageStore { fingerprintToken: beforeFetchFingerprintToken, credentialsFileChanged: false, keychainFingerprintChanged: false, - keychainPersistentRefHash: beforeFetchPersistentRefHash), + keychainPersistentRefHash: beforeFetchPersistentRefHash, + activeAccountIdentity: nil, + accountStateWasStable: true), afterFetchFingerprintToken: afterFetchFingerprintToken, - afterFetchPersistentRefHash: afterFetchPersistentRefHash) + afterFetchPersistentRefHash: afterFetchPersistentRefHash, + accountStateWasStable: true) + } + + nonisolated static func _claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: String?, + identityAfterFetch: String?, + beforeFetchWasStable: Bool = true, + afterFetchWasStable: Bool = true) -> ClaudeOAuthActiveAccountObservation + { + self.claudeOAuthActiveAccountObservation( + beforeFetch: ClaudeRefreshAuthState( + fingerprintToken: "before", + credentialsFileChanged: false, + keychainFingerprintChanged: false, + keychainPersistentRefHash: "before-ref", + activeAccountIdentity: identityBeforeFetch, + accountStateWasStable: beforeFetchWasStable), + afterFetch: ClaudeHistoryAccountState( + fingerprintToken: "after", + keychainPersistentRefHash: "after-ref", + activeAccountIdentity: identityAfterFetch, + wasStable: afterFetchWasStable)) } #endif @@ -510,6 +604,18 @@ extension UsageStore { let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) { + // This is a durable provider migration signal, not a transient fetch failure. + // Surface it immediately so a cached snapshot cannot hide the required handoff. + self.observeGeminiConsumerTierDeprecation(from: error) + self.errors[provider] = error.localizedDescription + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + self.lastSourceLabels.removeValue(forKey: provider) + self.failureGates[provider]?.reset() + return + } let hadKnownUnavailableLimits = self.knownLimitsAvailabilityByProvider[provider]?.isUnavailable == true self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) if provider == .claude, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index dc12e469b1..86bab22858 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -567,7 +567,12 @@ extension UsageStore { provider: provider, override: override, codexActiveSourceOverride: codexActiveSourceOverride) - return await descriptor.fetchOutcome(context: context) + let outcome = await descriptor.fetchOutcome(context: context) + guard provider == .codex else { return outcome } + return await Self.attachingCodexResetCreditsIfNeeded( + to: outcome, + env: context.env, + fetcher: self.codexResetCreditsFetcher()) } private func fetchTokenAccountOutcomes( @@ -612,6 +617,7 @@ extension UsageStore { private func fetchCodexVisibleAccountOutcomes(_ accounts: [CodexVisibleAccount]) async -> [CodexAccountFetchResult] { + let resetCreditsFetcher = self.codexResetCreditsFetcher() let requests: [( index: Int, account: CodexVisibleAccount, @@ -633,7 +639,11 @@ extension UsageStore { { group in for request in requests { group.addTask { - let outcome = await request.descriptor.fetchOutcome(context: request.context) + let baseOutcome = await request.descriptor.fetchOutcome(context: request.context) + let outcome = await Self.attachingCodexResetCreditsIfNeeded( + to: baseOutcome, + env: request.context.env, + fetcher: resetCreditsFetcher) return CodexAccountFetchResult( index: request.index, account: request.account, @@ -1186,6 +1196,7 @@ extension UsageStore { switch outcome.result { case .success: guard let snapshot else { return } + self.handleCodexResetCreditNotifications(snapshot: snapshot) self.handleSessionQuotaTransition(provider: .codex, snapshot: snapshot) self.lastKnownResetSnapshots[.codex] = snapshot self.lastCodexAccountScopedRefreshGuard = Self.codexScopedRefreshGuard(for: account) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 44d2e4e646..46f8d241f1 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -25,7 +25,7 @@ extension UsageStore { Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshots[.codex] == nil else { return } - guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( + guard let result = await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: scope.codexHomePath, historyDays: historyDays) @@ -39,8 +39,15 @@ extension UsageStore { else { return } - self.tokenSnapshots[.codex] = snapshot + self.tokenSnapshots[.codex] = result.snapshot self.tokenErrors[.codex] = nil + if let lastRefreshAt = result.lastRefreshAt, + now.timeIntervalSince(lastRefreshAt) >= 0, + now.timeIntervalSince(lastRefreshAt) < self.tokenFetchTTL + { + self.lastTokenFetchAt[.codex] = lastRefreshAt + self.lastTokenFetchScope[.codex] = "\(scope.signature)|historyDays=\(historyDays)" + } } } diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index ea2c35db79..485b97cf2e 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -26,18 +26,19 @@ extension UsageStore { } private func makeWidgetSnapshot() -> WidgetSnapshot { + let now = Date() let enabledProviders = self.enabledProviders() let entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry(for: provider) + self.makeWidgetEntry(for: provider, now: now) } return WidgetSnapshot( entries: entries, enabledProviders: enabledProviders, usageBarsShowUsed: self.settings.usageBarsShowUsed, - generatedAt: Date()) + generatedAt: now) } - private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { + private func makeWidgetEntry(for provider: UsageProvider, now: Date) -> WidgetSnapshot.ProviderEntry? { let snapshot = self.snapshots[provider] let storedTokenSnapshot = self.tokenSnapshots[provider] guard snapshot != nil || (provider == .claude && storedTokenSnapshot != nil) else { return nil } @@ -52,7 +53,7 @@ extension UsageStore { } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider) - let usageRows = snapshot.map { self.widgetUsageRows(provider: provider, snapshot: $0) } ?? [] + let usageRows = snapshot.map { self.widgetUsageRows(provider: provider, snapshot: $0, now: now) } ?? [] let creditsRemaining: Double? let codeReviewRemaining: Double? @@ -60,7 +61,7 @@ extension UsageStore { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) let displayOnlyExtrasHidden = projection.dashboardVisibility == .displayOnly creditsRemaining = displayOnlyExtrasHidden ? nil : projection.credits?.remaining codeReviewRemaining = displayOnlyExtrasHidden ? nil : projection.remainingPercent(for: .codeReview) @@ -68,10 +69,17 @@ extension UsageStore { creditsRemaining = nil codeReviewRemaining = nil } + let providerCost: ProviderCostSnapshot? = if provider == .devin, + self.settings.showOptionalCreditsAndExtraUsage + { + snapshot?.providerCost + } else { + nil + } return WidgetSnapshot.ProviderEntry( provider: provider, - updatedAt: snapshot?.updatedAt ?? tokenSnapshot?.updatedAt ?? Date(), + updatedAt: snapshot?.updatedAt ?? tokenSnapshot?.updatedAt ?? now, primary: snapshot?.primary, secondary: snapshot?.secondary, tertiary: snapshot?.tertiary, @@ -79,7 +87,8 @@ extension UsageStore { creditsRemaining: creditsRemaining, codeReviewRemainingPercent: codeReviewRemaining, tokenUsage: tokenUsage, - dailyUsage: dailyUsage) + dailyUsage: dailyUsage, + providerCost: providerCost) } private nonisolated static func widgetTokenUsageSummary( @@ -103,16 +112,17 @@ extension UsageStore { private func widgetUsageRows( provider: UsageProvider, - snapshot: UsageSnapshot) -> [WidgetSnapshot.WidgetUsageRowSnapshot] + snapshot: UsageSnapshot, + now: Date) -> [WidgetSnapshot.WidgetUsageRowSnapshot] { let metadata = ProviderDefaults.metadata[provider] if provider == .codex { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) return projection.visibleRateLanes.compactMap { lane in - guard let window = projection.rateWindow(for: lane) else { return nil } + guard let window = projection.sourceRateWindow(for: lane) else { return nil } let title = switch lane { case .session: metadata?.sessionLabel ?? "Session" @@ -122,7 +132,8 @@ extension UsageStore { return WidgetSnapshot.WidgetUsageRowSnapshot( id: lane.rawValue, title: title, - percentLeft: window.remainingPercent) + percentLeft: window.remainingPercent, + window: window) } } if provider == .antigravity, @@ -170,6 +181,18 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } + if provider == .kimi { + // Keep persisted widget order stable and include only Kimi's intentional subscription lanes. + let kimiWindowIDs = ["kimi-monthly", "kimi-code-7d"] + rows.append(contentsOf: kimiWindowIDs.compactMap { id in + guard let window = snapshot.extraRateWindows?.first(where: { $0.id == id }), window.usageKnown + else { return nil } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: window.id, + title: window.title, + percentLeft: window.window.remainingPercent) + }) + } return rows.filter { $0.percentLeft != nil } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 7839db907e..8f1cb353bc 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -17,6 +17,9 @@ extension UsageStore { _ = self.accountSnapshots _ = self.codexAccountSnapshots _ = self.kiloScopeSnapshots + _ = self.claudeSwapAccountSnapshots + _ = self.claudeSwapLastError + _ = self.claudeSwapRevision _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight @@ -138,12 +141,20 @@ final class UsageStore { var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] + var geminiObservedConsumerTierDeprecation = false var knownLimitsAvailabilityByProvider: [UsageProvider: UsageLimitsAvailability] = [:] var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] var kiloScopeSnapshots: [KiloScopeSnapshot] = [] + var claudeSwapAccountSnapshots: [ProviderAccountUsageSnapshot] = [] + var claudeSwapLastRefreshAt: Date? + var claudeSwapLastError: String? + var claudeSwapDetectedVersion: String? + var claudeSwapRevision: UInt64 = 0 + @ObservationIgnored var claudeSwapRefreshTask: Task? + @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] @@ -206,6 +217,7 @@ final class UsageStore { Bool, TimeInterval) async throws -> OpenAIDashboardSnapshot)? @ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)? + @ObservationIgnored var _test_codexResetCreditsFetcherOverride: CodexResetCreditsFetcher? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? @@ -281,7 +293,7 @@ final class UsageStore { @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 let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator @@ -398,10 +410,6 @@ final class UsageStore { ClaudePlan.isSubscriptionLoginMethod(loginMethod) } - func version(for provider: UsageProvider) -> String? { - self.versions[provider] - } - var preferredSnapshot: UsageSnapshot? { for provider in self.enabledProviders() { if let snap = self.snapshots[provider] { return snap } @@ -875,6 +883,17 @@ final class UsageStore { struct QuotaWarningStateKey: Hashable { let provider: UsageProvider let window: QuotaWarningWindow + /// Distinguishes independent extra rate windows that share a provider/window lane + /// (e.g. multiple `claude-weekly-scoped-*` windows) so their fired-threshold state + /// does not clobber each other or the primary session/weekly lanes. `nil` for the + /// primary session and weekly lanes. + let windowID: String? + + init(provider: UsageProvider, window: QuotaWarningWindow, windowID: String? = nil) { + self.provider = provider + self.window = window + self.windowID = windowID + } } struct QuotaWarningState { @@ -1127,6 +1146,7 @@ extension UsageStore { .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", .chutes: "Chutes debug log not yet implemented", + .clawrouter: "ClawRouter debug log not yet implemented", ] let buildText = { switch provider { @@ -1207,7 +1227,8 @@ extension UsageStore { .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, - .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes: + .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, + .clawrouter: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBar/ZaiTokenStore.swift b/Sources/CodexBar/ZaiTokenStore.swift index ee4c391810..2b33921127 100644 --- a/Sources/CodexBar/ZaiTokenStore.swift +++ b/Sources/CodexBar/ZaiTokenStore.swift @@ -67,7 +67,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() @@ -123,7 +123,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() @@ -141,7 +141,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw ZaiTokenStoreError.keychainStatus(addStatus) @@ -161,7 +161,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() diff --git a/Sources/CodexBarCLI/CLICardsBriefRenderer.swift b/Sources/CodexBarCLI/CLICardsBriefRenderer.swift new file mode 100644 index 0000000000..db6fdc681e --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsBriefRenderer.swift @@ -0,0 +1,590 @@ +import CodexBarCore +import Foundation + +struct CLICardsBriefRow: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let sourceLabel: String + let planBadge: String? + let accountLabel: String? + let metricLabel: String? + let usedPercent: Double? + let resetLabel: String? + let resetAt: Date? +} + +private struct CLICardsBriefColumns { + let provider: Int + let usage: Int + let reset: Int +} + +enum CLICardsBriefRenderer { + private static let warningUsedThreshold = 85.0 + private static let tableBorderOverhead = 10 + private static let providerColumnMin = 20 + private static let providerColumnFloor = 8 + private static let providerColumnMax = 34 + private static let usageColumnMin = 22 + private static let usageColumnFloor = 9 + private static let usageColumnWidth = 28 + private static let usageBarMaxWidth = 22 + private static let resetColumnMin = 8 + private static let resetColumnFloor = 5 + private static let resetColumnMax = 10 + + static func makeRows(cards: [CLICardModel]) -> [CLICardsBriefRow] { + cards.map { card in + let metric = card.metrics.first + let usedPercent = metric.map { max(0, min(100, 100 - $0.remainingPercent)) } + let resetLabel = Self.briefResetLabel(metric?.resetText) + return CLICardsBriefRow( + provider: card.provider, + providerName: card.title, + sourceLabel: card.sourceLabel, + planBadge: card.planBadge, + accountLabel: card.accountLine, + metricLabel: metric?.label, + usedPercent: usedPercent, + resetLabel: resetLabel, + resetAt: metric?.resetAt) + } + } + + static func render( + rows: [CLICardsBriefRow], + failures: [CLICardFailure], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool = false, + now: Date = Date()) -> String + { + guard !rows.isEmpty else { + return CLICardsRenderer.renderFailuresOnly(failures, useColor: useColor) + } + + var lines: [String] = [] + lines.append(Self.titleLine(now: now, terminalWidth: terminalWidth, useColor: useColor, enhanced: enhanced)) + lines.append(contentsOf: Self.summaryLines( + rows: rows, + now: now, + terminalWidth: terminalWidth, + useColor: useColor, + enhanced: enhanced)) + lines.append("") + lines.append(contentsOf: Self.tableLines( + rows: rows, + terminalWidth: terminalWidth, + useColor: useColor, + enhanced: enhanced)) + + let warningLines = Self.warningLines(rows: rows, terminalWidth: terminalWidth, useColor: useColor) + if !warningLines.isEmpty { + lines.append("") + lines.append(contentsOf: warningLines) + } + + if !failures.isEmpty { + lines.append("") + lines.append(CLICardsRenderer.renderFailureFooter(failures: failures, useColor: useColor)) + } + + return lines.joined(separator: "\n") + } + + private static func titleLine(now: Date, terminalWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let left: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedAccentBold("codexbar • AI Usage & Limits") + } else if useColor { + CLIRenderer.colorizeAccentBold("codexbar • AI Usage & Limits") + } else { + "codexbar • AI Usage & Limits" + } + let timestamp = Self.timestampString(now: now) + guard Self.visibleLength(left) + timestamp.count + 1 <= terminalWidth else { + if Self.visibleLength(left) <= terminalWidth { return left } + return Self.truncatePlain(TextParsing.stripANSICodes(left), width: terminalWidth) + } + let gap = max(1, terminalWidth - Self.visibleLength(left) - timestamp.count) + let right: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadableMuted(timestamp) + } else if useColor { + CLIRenderer.colorizeSubtle(timestamp) + } else { + timestamp + } + return left + String(repeating: " ", count: gap) + right + } + + private static func summaryLines( + rows: [CLICardsBriefRow], + now: Date, + terminalWidth: Int, + useColor: Bool, + enhanced: Bool) -> [String] + { + var parts: [String] = [] + if let nextReset = Self.nextResetSummary(rows: rows, now: now) { + parts.append("Next reset: \(nextReset)") + } + let text = parts.joined(separator: " • ") + guard !text.isEmpty else { return [] } + let lines = Self.wrapText( + text, + firstPrefix: "", + continuationPrefix: " ", + width: terminalWidth) + if useColor, enhanced { + return lines.map(CLIRenderer.colorizeEnhancedReadable) + } + if useColor { + return lines.map(CLIRenderer.colorizeReadable) + } + return lines + } + + private static func providerPlainLabel(_ row: CLICardsBriefRow) -> String { + if let account = row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty { + return "\(row.providerName) · \(account) · \(row.sourceLabel)" + } + if let plan = row.planBadge, !plan.isEmpty { + return "\(row.providerName) · \(row.sourceLabel) · \(plan)" + } + return "\(row.providerName) · \(row.sourceLabel)" + } + + private static func tableLines( + rows: [CLICardsBriefRow], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool) -> [String] + { + let columns = Self.tableColumnWidths( + rows: rows, + terminalWidth: terminalWidth) + + let top = Self.tableTop( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + let header = Self.tableHeaderRow( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + let divider = Self.tableDivider( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + + var lines = [top, header, divider] + for row in rows { + lines.append(Self.dataRow( + row: row, + columns: columns, + useColor: useColor, + enhanced: enhanced)) + } + lines.append(Self.tableBottom( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced)) + return lines + } + + private static func tableBorderLine(_ line: String, useColor: Bool, enhanced: Bool) -> String { + guard useColor else { return line } + if enhanced { + return CLIRenderer.colorizeEnhancedBorder(line) + } + return CLIRenderer.colorizeCardBorder(line) + } + + private static func tableHeaderRow( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let provider = Self.styledHeaderLabel("Provider", width: providerWidth, useColor: useColor, enhanced: enhanced) + let usage = Self.styledHeaderLabel("Usage", width: usageWidth, useColor: useColor, enhanced: enhanced) + let reset = Self.styledHeaderLabel( + "Reset", + width: resetWidth, + alignRight: true, + useColor: useColor, + enhanced: enhanced) + return "│ \(provider) │ \(usage) │ \(reset) │" + } + + private static func styledHeaderLabel( + _ text: String, + width: Int, + alignRight: Bool = false, + useColor: Bool, + enhanced: Bool) -> String + { + let styled: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(text) + } else if useColor { + CLIRenderer.colorizeReadable(text) + } else { + text + } + return Self.pad(styled, width: width, alignRight: alignRight) + } + + private static func tableTop( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "┌" + String(repeating: "─", count: providerWidth + 2) + + "┬" + String(repeating: "─", count: usageWidth + 2) + + "┬" + String(repeating: "─", count: resetWidth + 2) + "┐" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func tableBottom( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "└" + String(repeating: "─", count: providerWidth + 2) + + "┴" + String(repeating: "─", count: usageWidth + 2) + + "┴" + String(repeating: "─", count: resetWidth + 2) + "┘" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func tableDivider( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "├" + String(repeating: "─", count: providerWidth + 2) + + "┼" + String(repeating: "─", count: usageWidth + 2) + + "┼" + String(repeating: "─", count: resetWidth + 2) + "┤" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func styledProviderCell( + row: CLICardsBriefRow, + width: Int, + useColor: Bool, + enhanced: Bool) -> String + { + if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let fitted = Self.fitCell(Self.providerPlainLabel(row), width: width) + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedReadable(fitted) + } + return useColor ? CLIRenderer.colorizeReadable(fitted) : fitted + } + guard useColor else { + return self.plainProviderCell(row: row, width: width) + } + + let sourceVisibleWidth = row.sourceLabel.count + 2 + let prefixVisibleWidth = row.providerName.count + 1 + sourceVisibleWidth + let plan: String? = row.planBadge.flatMap { $0.isEmpty ? nil : $0 } + let planPrefix = " · " + let planWidth = plan.map { _ in max(0, width - prefixVisibleWidth - planPrefix.count) } ?? 0 + + let styled: String = if enhanced { + CLIRenderer.colorizeEnhancedAccentBold(row.providerName) + + " " + + CLIRenderer.colorizeEnhancedBadge(row.sourceLabel) + + Self.styledProviderPlan( + plan, + prefix: planPrefix, + width: planWidth, + useColor: useColor, + enhanced: enhanced) + } else { + CLIRenderer.colorizeReadable(row.providerName) + + " " + + CLIRenderer.colorizeCardBadge(row.sourceLabel) + + Self.styledProviderPlan( + plan, + prefix: planPrefix, + width: planWidth, + useColor: useColor, + enhanced: enhanced) + } + return Self.fitCell(styled, width: width) + } + + private static func styledProviderPlan( + _ plan: String?, + prefix: String, + width: Int, + useColor: Bool, + enhanced: Bool) -> String + { + guard let plan, width > 0 else { return "" } + let text = prefix + Self.truncatePlain(plan, width: width) + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedReadableMuted(text) + } + if useColor { + return CLIRenderer.colorizeReadableMuted(text) + } + return text + } + + private static func plainProviderCell(row: CLICardsBriefRow, width: Int) -> String { + if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return self.fitCell(self.providerPlainLabel(row), width: width) + } + guard + let plan = row.planBadge, + !plan.isEmpty + else { + return self.fitCell(self.providerPlainLabel(row), width: width) + } + + let prefix = "\(row.providerName) · \(row.sourceLabel) · " + let planWidth = max(0, width - prefix.count) + if planWidth > 0 { + return Self.pad(prefix + Self.truncatePlain(plan, width: planWidth), width: width) + } + return Self.fitCell("\(row.providerName) · \(row.sourceLabel)", width: width) + } + + private static func styledResetCell(_ text: String, width: Int, useColor: Bool, enhanced: Bool) -> String { + let styled: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadableMuted(text) + } else if useColor { + CLIRenderer.colorizeReadableMuted(text) + } else { + text + } + return Self.fitCell(styled, width: width, alignRight: true) + } + + private static func dataRow( + row: CLICardsBriefRow, + columns: CLICardsBriefColumns, + useColor: Bool, + enhanced: Bool) -> String + { + let provider = Self.styledProviderCell( + row: row, + width: columns.provider, + useColor: useColor, + enhanced: enhanced) + let usage: String + if let used = row.usedPercent { + let percent = String(format: "%.0f%%", used.rounded()) + let barWidth = max(4, min(Self.usageBarMaxWidth, columns.usage - Self.visibleLength(percent) - 1)) + let bar: String = if useColor, enhanced { + CLIRenderer.gradientUsedBar(usedPercent: used, width: barWidth) + } else { + CLIRenderer.cardUsedBar(usedPercent: used, width: barWidth, useColor: useColor) + } + let coloredPercent: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedUsedPercent(percent, usedPercent: used) + } else { + CLIRenderer.colorizeCardUsedPercent(percent, usedPercent: used, useColor: useColor) + } + usage = Self.pad("\(coloredPercent) \(bar)", width: columns.usage) + } else { + usage = Self.pad("—", width: columns.usage) + } + let reset = Self.styledResetCell( + row.resetLabel ?? "—", + width: columns.reset, + useColor: useColor, + enhanced: enhanced) + return "│ \(provider) │ \(usage) │ \(reset) │" + } + + private static func warningLines( + rows: [CLICardsBriefRow], + terminalWidth: Int, + useColor: Bool) -> [String] + { + let warnings = rows.compactMap { row -> String? in + guard let used = row.usedPercent, used >= Self.warningUsedThreshold else { return nil } + let label = row.metricLabel ?? "Usage" + return "\(row.providerName) \(label): \(Int(used.rounded()))% used" + } + guard !warnings.isEmpty else { return [] } + let lines = Self.wrapText( + warnings.joined(separator: "; "), + firstPrefix: "⚠ Warnings: ", + continuationPrefix: " ", + width: terminalWidth) + return useColor ? lines.map(CLIRenderer.colorizeWarning) : lines + } + + private static func wrapText( + _ text: String, + firstPrefix: String, + continuationPrefix: String, + width: Int) -> [String] + { + let lineWidth = max(16, width) + var lines: [String] = [] + var line = firstPrefix + var hasContent = false + for word in text.split(separator: " ").map(String.init) { + let separator = hasContent ? " " : "" + if line.count + separator.count + word.count <= lineWidth { + line += separator + word + hasContent = true + continue + } + if hasContent { + lines.append(line) + } else if !line.isEmpty { + lines.append(Self.truncatePlain(line, width: lineWidth)) + } + let available = max(1, lineWidth - continuationPrefix.count) + line = continuationPrefix + Self.truncatePlain(word, width: available) + hasContent = true + } + if hasContent { + lines.append(line) + } + return lines + } + + private static func nextResetSummary(rows: [CLICardsBriefRow], now: Date) -> String? { + guard let (row, label, _) = rows.compactMap({ row -> (CLICardsBriefRow, String, Date)? in + guard let reset = row.resetLabel, !reset.isEmpty, reset != "—" else { return nil } + let sortDate = row.resetAt ?? Self.resetSortDate(reset, now: now) + guard let sortDate else { return nil } + return (row, reset, sortDate) + }).min(by: { $0.2 < $1.2 }) + else { return nil } + let separator = Self.resetDurationMinutes(label) == nil ? " · " : " in " + return "\(row.providerName)\(separator)\(label)" + } + + private static func resetSortDate(_ label: String, now: Date) -> Date? { + guard let minutes = resetDurationMinutes(label) else { return nil } + return now.addingTimeInterval(TimeInterval(minutes * 60)) + } + + private static func resetDurationMinutes(_ label: String) -> Int? { + var minutes = 0 + var matched = false + if let match = label.range(of: #"(\d+)d"#, options: .regularExpression) { + matched = true + minutes += (Int(label[match].dropLast()) ?? 0) * 24 * 60 + } + if let match = label.range(of: #"(\d+)h"#, options: .regularExpression) { + matched = true + minutes += (Int(label[match].dropLast()) ?? 0) * 60 + } + if let match = label.range(of: #"(\d+)m"#, options: .regularExpression) { + matched = true + minutes += Int(label[match].dropLast()) ?? 0 + } + return matched ? minutes : nil + } + + private static func tableColumnWidths( + rows: [CLICardsBriefRow], + terminalWidth: Int) -> CLICardsBriefColumns + { + let providerContent = rows.map { Self.providerPlainLabel($0).count }.max() ?? Self.providerColumnMin + let resetContent = rows.compactMap(\.resetLabel).map(\.count).max() ?? 6 + + var providerWidth = min(Self.providerColumnMax, max(Self.providerColumnMin, providerContent)) + var usageWidth = Self.usageColumnWidth + var resetWidth = min(Self.resetColumnMax, max(Self.resetColumnMin, resetContent)) + + while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth { + if usageWidth > Self.usageColumnMin { + usageWidth -= 1 + } else if providerWidth > Self.providerColumnMin { + providerWidth -= 1 + } else if resetWidth > Self.resetColumnMin { + resetWidth -= 1 + } else { + break + } + } + + while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth { + if providerWidth > Self.providerColumnFloor { + providerWidth -= 1 + } else if usageWidth > Self.usageColumnFloor { + usageWidth -= 1 + } else if resetWidth > Self.resetColumnFloor { + resetWidth -= 1 + } else { + break + } + } + + return CLICardsBriefColumns(provider: providerWidth, usage: usageWidth, reset: resetWidth) + } + + private static func briefResetLabel(_ resetText: String?) -> String? { + guard var text = resetText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + if text.hasPrefix("⏳ ") { + text = String(text.dropFirst(2)) + } + if text.hasPrefix("Resets in ") { + text = String(text.dropFirst("Resets in ".count)) + } else if text.hasPrefix("Resets ") { + text = String(text.dropFirst("Resets ".count)) + } + return text.isEmpty ? nil : text + } + + private static func timestampString(now: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:mm zzz" + return formatter.string(from: now) + } + + private static func pad(_ text: String, width: Int, alignRight: Bool = false) -> String { + let visible = Self.visibleLength(text) + if visible >= width { return text } + let padding = String(repeating: " ", count: width - visible) + return alignRight ? padding + text : text + padding + } + + private static func fitCell(_ text: String, width: Int, alignRight: Bool = false) -> String { + let visible = Self.visibleLength(text) + if visible <= width { + return Self.pad(text, width: width, alignRight: alignRight) + } + let plain = TextParsing.stripANSICodes(text) + let clipped = Self.truncatePlain(plain, width: width) + return alignRight ? Self.pad(clipped, width: width, alignRight: true) : clipped + } + + private static func truncatePlain(_ text: String, width: Int) -> String { + guard width > 0 else { return "" } + if text.count <= width { return text } + guard width > 1 else { return String(text.prefix(width)) } + return String(text.prefix(width - 1)) + "…" + } + + private static func visibleLength(_ text: String) -> Int { + TextParsing.stripANSICodes(text).count + } +} diff --git a/Sources/CodexBarCLI/CLICardsCommand.swift b/Sources/CodexBarCLI/CLICardsCommand.swift new file mode 100644 index 0000000000..d800e57adc --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsCommand.swift @@ -0,0 +1,212 @@ +import CodexBarCore +import Commander +import Foundation + +struct CardsOptions: CommanderParsable { + private static let sourceHelp: String = { + #if os(macOS) + "Data source: auto | web | cli | oauth | api (auto behavior is provider-specific)" + #else + "Data source: auto | web | cli | oauth | api (web/auto are macOS only for web-capable providers)" + #endif + }() + + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option( + name: .long("provider"), + help: ProviderHelp.optionHelp) + var provider: ProviderSelection? + + @Option(name: .long("account"), help: "Token account label to use (from config.json)") + var account: String? + + @Option(name: .long("account-index"), help: "Token account index (1-based)") + var accountIndex: Int? + + @Flag(name: .long("all-accounts"), help: "Fetch all token accounts, or all visible Codex accounts") + var allAccounts: Bool = false + + @Flag(name: .long("no-credits"), help: "Skip Codex credits line") + var noCredits: Bool = false + + @Flag(name: .long("no-color"), help: "Disable ANSI colors in text output") + var noColor: Bool = false + + @Flag(name: .long("status"), help: "Fetch and include provider status") + var status: Bool = false + + @Flag(name: .long("web"), help: "Alias for --source web") + var web: Bool = false + + @Option(name: .long("source"), help: Self.sourceHelp) + var source: String? + + @Option(name: .long("web-timeout"), help: "Web fetch timeout (seconds; source=auto or web)") + var webTimeout: Double? + + @Flag(name: .long("web-debug-dump-html"), help: "Dump HTML snapshots to /tmp when Codex dashboard data is missing") + var webDebugDumpHtml: Bool = false + + @Flag(name: .long("antigravity-plan-debug"), help: "Emit Antigravity planInfo fields (debug)") + var antigravityPlanDebug: Bool = false + + @Flag(name: .long("augment-debug"), help: "Emit Augment API responses (debug)") + var augmentDebug: Bool = false + + @Flag(name: .long("brief"), help: "Compact table layout instead of the card grid") + var brief: Bool = false +} + +extension CodexBarCLI { + static func runCards(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let config = Self.loadConfig(output: output) + let provider = Self.decodeProvider(from: values, config: config) + let includeCredits = !values.flags.contains("noCredits") + let includeStatus = values.flags.contains("status") + let sourceModeRaw = values.options["source"]?.last + let parsedSourceMode = Self.decodeSourceMode(from: values) + if sourceModeRaw != nil, parsedSourceMode == nil { + Self.exit( + code: .failure, + message: "Error: --source must be auto|web|cli|oauth|api.", + output: output, + kind: .args) + } + let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug") + let augmentDebug = values.flags.contains("augmentDebug") + let webDebugDumpHTML = values.flags.contains("webDebugDumpHtml") + let webTimeout: TimeInterval + do { + webTimeout = try Self.decodeWebTimeout(from: values) ?? 60 + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args) + } + let verbose = values.flags.contains("verbose") + let noColor = values.flags.contains("noColor") + let useColor = Self.shouldUseColor(noColor: noColor, format: .text) + let brief = values.flags.contains("brief") + let resetStyle = Self.resetTimeDisplayStyleFromDefaults() + let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults() + let providerList = provider.asList + + let tokenSelection: TokenAccountCLISelection + do { + tokenSelection = try Self.decodeTokenAccountSelection(from: values) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args) + } + + if tokenSelection.allAccounts, tokenSelection.label != nil || tokenSelection.index != nil { + Self.exit( + code: .failure, + message: "Error: --all-accounts cannot be combined with --account or --account-index.", + output: output, + kind: .args) + } + + if tokenSelection.usesOverride { + guard providerList.count == 1 else { + Self.exit( + code: .failure, + message: "Error: account selection requires a single provider.", + output: output, + kind: .args) + } + let supportsAllCodexAccounts = providerList[0] == .codex + && tokenSelection.allAccounts + && tokenSelection.label == nil + && tokenSelection.index == nil + guard supportsAllCodexAccounts || TokenAccountSupportCatalog.support(for: providerList[0]) != nil else { + Self.exit( + code: .failure, + message: "Error: \(providerList[0].rawValue) does not support token accounts.", + output: output, + kind: .args) + } + } + + let browserDetection = BrowserDetection() + let fetcher = UsageFetcher() + let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection) + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: tokenSelection, + config: config, + verbose: verbose) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config) + } + + var cards: [CLICardModel] = [] + var failures: [CLICardFailure] = [] + var exitCode: ExitCode = .success + let command = UsageCommandContext( + format: .text, + includeCredits: includeCredits, + sourceModeOverride: parsedSourceMode, + antigravityPlanDebug: antigravityPlanDebug, + augmentDebug: augmentDebug, + webDebugDumpHTML: webDebugDumpHTML, + webTimeout: webTimeout, + verbose: verbose, + useColor: useColor, + resetStyle: resetStyle, + weeklyWorkDays: weeklyWorkDays, + jsonOnly: output.jsonOnly, + includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], + fetcher: fetcher, + claudeFetcher: claudeFetcher, + browserDetection: browserDetection, + cardsLayout: true) + + for provider in providerList { + let status = includeStatus ? await Self.fetchStatus(for: provider) : nil + let result = await ProviderInteractionContext.$current.withValue(.background) { + await Self.fetchUsageOutputs( + provider: provider, + status: status, + tokenContext: tokenContext, + command: command) + } + if result.exitCode != .success { + exitCode = result.exitCode + } + cards.append(contentsOf: result.cards) + failures.append(contentsOf: result.cardFailures) + } + + let rendered: String + let enhanced = CLITerminalCapabilities.supportsEnhancedCards(useColor: useColor) + if brief { + let rows = CLICardsBriefRenderer.makeRows(cards: cards) + rendered = CLICardsBriefRenderer.render( + rows: rows, + failures: failures, + terminalWidth: CLICardsRenderer.terminalColumnCount(), + useColor: useColor, + enhanced: enhanced) + } else { + rendered = CLICardsRenderer.render( + cards: cards, + failures: failures, + terminalWidth: CLICardsRenderer.terminalColumnCount(), + useColor: useColor, + enhanced: enhanced) + } + if !rendered.isEmpty { + print(rendered) + } + + Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) + } +} diff --git a/Sources/CodexBarCLI/CLICardsRenderer.swift b/Sources/CodexBarCLI/CLICardsRenderer.swift new file mode 100644 index 0000000000..5ac6474dc0 --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsRenderer.swift @@ -0,0 +1,529 @@ +import CodexBarCore +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#endif + +struct CLICardMetric: Sendable, Equatable { + let label: String + let remainingPercent: Double + let resetText: String? + let resetAt: Date? + let detailText: String? + + init( + label: String, + remainingPercent: Double, + resetText: String?, + resetAt: Date? = nil, + detailText: String? = nil) + { + self.label = label + self.remainingPercent = remainingPercent + self.resetText = resetText + self.resetAt = resetAt + self.detailText = detailText + } +} + +struct CLICardModel: Sendable, Equatable { + let provider: UsageProvider + let title: String + let sourceLabel: String + let planBadge: String? + let accountLine: String? + let infoLines: [String] + let metrics: [CLICardMetric] + let extraLines: [String] + let statusLine: String? +} + +struct CLICardFailure: Sendable, Equatable { + let provider: UsageProvider + let accountLabel: String? + let message: String +} + +struct CLICardBuildInput: Sendable { + let provider: UsageProvider + let snapshot: UsageSnapshot + let credits: CreditsSnapshot? + let source: String + let status: ProviderStatusPayload? + let notes: [String] + let useColor: Bool + let resetStyle: ResetTimeDisplayStyle + let weeklyWorkDays: Int? + let now: Date +} + +enum CLICardsRenderer { + static let minCardWidth = 38 + static let maxCardWidth = 42 + static let cardGap = 2 + + static func terminalColumnCount() -> Int { + if let value = terminalColumnCountFromTTY(), value > 0 { + return value + } + if let columns = ProcessInfo.processInfo.environment["COLUMNS"], + let value = Int(columns.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 + { + return value + } + return 80 + } + + private static func terminalColumnCountFromTTY(fileDescriptor: Int32 = STDOUT_FILENO) -> Int? { + guard isatty(fileDescriptor) == 1 else { return nil } + var windowSize = winsize(ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0) + guard ioctl(fileDescriptor, UInt(TIOCGWINSZ), &windowSize) == 0 else { return nil } + let columns = Int(windowSize.ws_col) + return columns > 0 ? columns : nil + } + + static func columnCount(terminalWidth: Int, minCardWidth: Int = Self.minCardWidth) -> Int { + let usable = max(minCardWidth, terminalWidth) + return max(1, (usable + Self.cardGap) / (minCardWidth + Self.cardGap)) + } + + static func cardWidth(terminalWidth: Int, columns: Int) -> Int { + let totalGaps = (columns - 1) * Self.cardGap + let availableWidth = max(1, (terminalWidth - totalGaps) / columns) + return min(Self.maxCardWidth, availableWidth) + } + + static func makeCard(_ input: CLICardBuildInput) -> CLICardModel { + let provider = input.provider + let snapshot = input.snapshot + let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let context = RenderContext( + header: displayName, + status: input.status, + useColor: input.useColor, + resetStyle: input.resetStyle, + weeklyWorkDays: input.weeklyWorkDays, + notes: input.notes) + let infoLines = CLIRenderer.collectCardInfoLines( + provider: provider, + snapshot: snapshot, + credits: input.credits, + notes: input.notes, + useColor: input.useColor, + now: input.now) + let metrics = CLIRenderer.collectCardMetrics( + provider: provider, + snapshot: snapshot, + resetStyle: input.resetStyle, + now: input.now) + let extraLines = CLIRenderer.collectCardExtraLines( + provider: provider, + snapshot: snapshot, + credits: input.credits, + context: context, + now: input.now) + let statusLine: String? + if let status = input.status { + let line = "Status: \(status.indicator.label)\(status.descriptionSuffix)" + statusLine = CLIRenderer.colorizeStatusLine(line, indicator: status.indicator, useColor: input.useColor) + } else { + statusLine = nil + } + return CLICardModel( + provider: provider, + title: displayName, + sourceLabel: Self.normalizedSourceLabel(input.source), + planBadge: CLIRenderer.planBadgeText(provider: provider, snapshot: snapshot), + accountLine: snapshot.accountEmail(for: provider), + infoLines: infoLines, + metrics: metrics, + extraLines: extraLines, + statusLine: statusLine) + } + + static func render( + cards: [CLICardModel], + failures: [CLICardFailure], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool = false) -> String + { + guard !cards.isEmpty else { + return self.renderFailuresOnly(failures, useColor: useColor) + } + + let columns = Self.columnCount(terminalWidth: terminalWidth) + let width = Self.cardWidth(terminalWidth: terminalWidth, columns: columns) + var chunks: [String] = [] + + for rowStart in stride(from: 0, to: cards.count, by: columns) { + let rowCards = Array(cards[rowStart.. String in + if lineIndex < lines.count - 1 { + return lines[lineIndex] + } + if lineIndex == rowHeight - 1, let bottom = lines.last { + return bottom + } + return Self.emptyCardLine(width: width, useColor: useColor, enhanced: enhanced) + } + chunks.append(parts.joined(separator: String(repeating: " ", count: Self.cardGap))) + } + if rowStart + columns < cards.count { + chunks.append("") + } + } + + if !failures.isEmpty { + if !chunks.isEmpty { + chunks.append("") + } + chunks.append(Self.renderFailureFooter(failures: failures, useColor: useColor)) + } + + return chunks.joined(separator: "\n") + } + + static func renderCard(_ card: CLICardModel, width: Int, useColor: Bool, enhanced: Bool = false) -> [String] { + let innerWidth = max(12, width - 4) + var lines: [String] = [] + lines.append(Self.boxLine(kind: .top, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + lines.append(Self.headerLine(card: card, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + + if let account = card.accountLine?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty { + let accountText = "@ \(account)" + lines.append(Self.contentLine( + accountText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + + lines.append(Self.separatorLine(innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + + for infoLine in card.infoLines { + lines.append(Self.detailLine( + infoLine, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + } + + if !card.metrics.isEmpty, !card.infoLines.isEmpty { + lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + for (index, metric) in card.metrics.enumerated() { + if index > 0 { + lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + lines.append(Self.metricLabelLine( + metric: metric, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + lines.append(Self.metricBarLine( + metric: metric, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + if let resetText = metric.resetText { + lines.append(Self.contentLine( + resetText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + if let detailText = metric.detailText { + lines.append(Self.contentLine( + detailText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + } + + for extraLine in card.extraLines { + lines.append(Self.detailLine(extraLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + if let statusLine = card.statusLine { + lines.append(Self.contentLine(statusLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + lines.append(Self.boxLine(kind: .bottom, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + return lines + } + + private enum BoxLineKind { + case top + case bottom + } + + private enum ContentStyle: Equatable { + case normal + case subtle + case border + } + + private static func boxLine(kind: BoxLineKind, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let chars = switch kind { + case .top: ("╭", "╮") + case .bottom: ("╰", "╯") + } + let line = chars.0 + String(repeating: "─", count: innerWidth + 2) + chars.1 + return Self.styleBorder(line, useColor: useColor, enhanced: enhanced) + } + + private static func headerLine(card: CLICardModel, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let title: String + let badge: String + if useColor, enhanced { + title = CLIRenderer.colorizeEnhancedAccentBold(card.title) + badge = CLIRenderer.colorizeEnhancedBadge(card.sourceLabel) + } else if useColor { + title = CLIRenderer.colorizeAccentBold(card.title) + badge = CLIRenderer.colorizeCardBadge(card.sourceLabel) + } else { + title = card.title + badge = "[\(card.sourceLabel)]" + } + let left = "\(title) \(badge)" + let leftVisible = Self.visibleLength(left) + let rawPlanText = card.planBadge.map { "PLAN \($0)" } ?? "" + let maxPlanWidth = max(0, innerWidth - leftVisible - 1) + let planText = maxPlanWidth >= 8 ? Self.truncatePlain(rawPlanText, width: maxPlanWidth) : "" + let planVisible = Self.visibleLength(planText) + let gap = max(1, innerWidth - leftVisible - planVisible) + let plan = Self.planPill(text: planText, useColor: useColor, enhanced: enhanced) + let content = left + String(repeating: " ", count: gap) + plan + return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func planPill(text: String, useColor: Bool, enhanced: Bool) -> String { + guard !text.isEmpty else { return "" } + let pieces = text.split(separator: " ", maxSplits: 1).map(String.init) + guard pieces.count == 2 else { + return useColor ? CLIRenderer.colorizeCardPlanBox(text) : text + } + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedPlanLabel(pieces[0]) + + " " + + CLIRenderer.colorizeEnhancedPlanValue(pieces[1]) + } + if useColor { + return CLIRenderer.colorizeCardPlanBox(pieces[0]) + + " " + + CLIRenderer.colorizeWarning(pieces[1]) + } + return text + } + + private static func separatorLine(innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + self.sideBorder( + String(repeating: "─", count: innerWidth), + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + contentStyle: .border) + } + + private static func metricLabelLine( + metric: CLICardMetric, + innerWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let percentText = UsageFormatter.usageLine( + remaining: metric.remainingPercent, + used: 100 - metric.remainingPercent, + showUsed: false) + let coloredPercent: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedRemainingPercent(percentText, remainingPercent: metric.remainingPercent) + } else { + CLIRenderer.colorizeCardPercent( + percentText, + remainingPercent: metric.remainingPercent, + useColor: useColor) + } + let label: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(metric.label) + } else if useColor { + CLIRenderer.colorizeReadable(metric.label) + } else { + metric.label + } + let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(coloredPercent)) + let content = label + String(repeating: " ", count: gap) + coloredPercent + return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func metricBarLine( + metric: CLICardMetric, + innerWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let barWidth = max(4, innerWidth - 4) + let bar: String = if useColor, enhanced { + CLIRenderer.gradientRemainingTrackBar(remainingPercent: metric.remainingPercent, width: barWidth) + } else { + CLIRenderer.cardBlockBar( + remainingPercent: metric.remainingPercent, + width: barWidth, + useColor: useColor) + } + return Self.sideBorder("[ \(bar) ]", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func detailLine(_ content: String, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let normalized = Self.normalizeGlyphs(content) + let plain = TextParsing.stripANSICodes(normalized) + let parts = plain.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { + return Self.contentLine(normalized, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + let rawLabel = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + ":" + let rawValue = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + let label: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(rawLabel) + } else if useColor { + CLIRenderer.colorizeReadable(rawLabel) + } else { + rawLabel + } + let value: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedGood(rawValue) + } else if useColor { + CLIRenderer.colorizeAccent(rawValue) + } else { + rawValue + } + let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(value)) + let line = label + String(repeating: " ", count: gap) + value + return Self.sideBorder(line, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func contentLine( + _ content: String, + innerWidth: Int, + useColor: Bool, + enhanced: Bool, + style: ContentStyle = .normal) -> String + { + let normalized = Self.normalizeGlyphs(content) + let stripped = TextParsing.stripANSICodes(normalized) + let clipped = stripped.count <= innerWidth + ? normalized + : (innerWidth <= 1 ? String(stripped.prefix(innerWidth)) : String(stripped.prefix(innerWidth - 1)) + "…") + let display: String = if style == .subtle, useColor, enhanced { + CLIRenderer.colorizeEnhancedSubtle(TextParsing.stripANSICodes(clipped)) + } else if style == .subtle, useColor { + CLIRenderer.colorizeSubtle(TextParsing.stripANSICodes(clipped)) + } else { + clipped + } + return Self.sideBorder(display, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func sideBorder( + _ content: String, + innerWidth: Int, + useColor: Bool, + enhanced: Bool, + contentStyle: ContentStyle = .normal) -> String + { + let fitted = Self.fitContent(content, width: innerWidth) + let padding = max(0, innerWidth - Self.visibleLength(fitted)) + let padded = fitted + String(repeating: " ", count: padding) + let visible = "│ \(padded) │" + guard useColor else { return visible } + let left = Self.styleBorder("│ ", useColor: useColor, enhanced: enhanced) + let right = Self.styleBorder(" │", useColor: useColor, enhanced: enhanced) + let styledContent: String = if contentStyle == .border { + Self.styleBorder(padded, useColor: useColor, enhanced: enhanced) + } else { + padded + } + return left + styledContent + right + } + + private static func styleBorder(_ text: String, useColor: Bool, enhanced: Bool) -> String { + guard useColor else { return text } + if enhanced { + return CLIRenderer.colorizeEnhancedBorder(text) + } + return CLIRenderer.colorizeCardBorder(text) + } + + private static func emptyCardLine(width: Int, useColor: Bool, enhanced: Bool) -> String { + let innerWidth = max(12, width - 4) + return Self.sideBorder("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func visibleLength(_ text: String) -> Int { + TextParsing.stripANSICodes(self.normalizeGlyphs(text)).count + } + + private static func truncatePlain(_ text: String, width: Int) -> String { + guard width > 0 else { return "" } + guard text.count > width else { return text } + if width <= 1 { return String(text.prefix(width)) } + return String(text.prefix(width - 1)) + "…" + } + + private static func fitContent(_ text: String, width: Int) -> String { + guard self.visibleLength(text) > width else { return text } + return self.truncatePlain(TextParsing.stripANSICodes(text), width: width) + } + + private static func normalizeGlyphs(_ text: String) -> String { + text + .replacingOccurrences(of: "👤", with: "@") + .replacingOccurrences(of: "⏳ Resets in ", with: "Reset in ") + .replacingOccurrences(of: "⏳ Resets ", with: "Reset ") + .replacingOccurrences(of: "⏳ ", with: "Reset ") + } + + static func renderFailureFooter(failures: [CLICardFailure], useColor: Bool) -> String { + var lines = ["Failed providers:"] + for failure in failures { + let name = ProviderDescriptorRegistry.descriptor(for: failure.provider).metadata.displayName + if let account = failure.accountLabel, !account.isEmpty { + lines.append(" - \(name) (\(account)): \(failure.message)") + } else { + lines.append(" - \(name): \(failure.message)") + } + } + let text = lines.joined(separator: "\n") + guard useColor else { return text } + return CLIRenderer.colorizeError(text) + } + + static func renderFailuresOnly(_ failures: [CLICardFailure], useColor: Bool) -> String { + guard !failures.isEmpty else { return "" } + return self.renderFailureFooter(failures: failures, useColor: useColor) + } + + private static func normalizedSourceLabel(_ source: String) -> String { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "auto" } + if trimmed.contains("oauth") { return "oauth" } + if trimmed.contains("web") || trimmed.contains("openai-web") { return "web" } + if trimmed.contains("api") { return "api" } + if trimmed.contains("cli") { return "cli" } + return trimmed + } +} diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 7f63b24129..488b99e2d1 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -32,13 +32,24 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) + let groupBy = Self.decodeCostGroupBy(from: values) + if groupBy == .project { + let unsupportedProjectProviders = providers.filter { $0 != .codex } + if !unsupportedProjectProviders.isEmpty, !output.jsonOnly { + let names = unsupportedProjectProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n") + } + } let fetcher = CostUsageFetcher() var sections: [String] = [] var payload: [CostPayload] = [] var exitCode: ExitCode = .success - for provider in providers { + for provider in providers where groupBy != .project || provider == .codex || format == .json { do { // Cost usage is local-only; it does not require web/CLI provider fetches. let snapshot = try await fetcher.loadTokenSnapshot( @@ -48,7 +59,11 @@ extension CodexBarCLI { refreshPricingInBackground: false) switch format { case .text: - sections.append(Self.renderCostText(provider: provider, snapshot: snapshot, useColor: useColor)) + sections.append(Self.renderCostText( + provider: provider, + snapshot: snapshot, + groupBy: groupBy, + useColor: useColor)) case .json: payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) } @@ -76,13 +91,22 @@ extension CodexBarCLI { Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) } + enum CostGroupBy: String { + case none + case project + } + static func renderCostText( provider: UsageProvider, snapshot: CostUsageTokenSnapshot, + groupBy: CostGroupBy = .none, useColor: Bool) -> String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) + if groupBy == .project, provider == .codex { + return Self.renderProjectCostText(header: header, snapshot: snapshot) + } let todayCost = snapshot.sessionCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" @@ -102,6 +126,39 @@ extension CodexBarCLI { return [header, todayLine, monthLine, hintLine].joined(separator: "\n") } + private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") + var lines = [header, "Projects (\(historyLabel)):"] + guard !snapshot.projects.isEmpty else { + lines.append("—") + lines.append(UsageFormatter.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + for project in snapshot.projects { + let cost = project.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let tokens = project.totalTokens.map { UsageFormatter.tokenCountString($0) } + let summary = tokens.map { "\(cost) · \($0) tokens" } ?? cost + lines.append("\(project.name): \(summary)") + if let path = project.path { + lines.append(" \(path)") + } + for source in project.sources { + let sourceCost = source.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) } + let sourceSummary = sourceTokens.map { "\(sourceCost) · \($0) tokens" } ?? sourceCost + lines.append(" - \(source.name): \(sourceSummary)") + if let path = source.path { + lines.append(" \(path)") + } + } + } + lines.append(UsageFormatter.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + private static func costHeaderLine(_ header: String, useColor: Bool) -> String { guard useColor else { return header } return "\u{001B}[1;36m\(header)\u{001B}[0m" @@ -116,23 +173,27 @@ extension CodexBarCLI { snapshot: CostUsageTokenSnapshot?, error: Error?) -> CostPayload { - let daily = snapshot?.daily.map { entry in - CostDailyEntryPayload( - date: entry.date, - inputTokens: entry.inputTokens, - outputTokens: entry.outputTokens, - cacheReadTokens: entry.cacheReadTokens, - cacheCreationTokens: entry.cacheCreationTokens, - totalTokens: entry.totalTokens, - costUSD: entry.costUSD, - modelsUsed: entry.modelsUsed, - modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload( - modelName: breakdown.modelName, - costUSD: breakdown.costUSD, - totalTokens: breakdown.totalTokens) - }) - } ?? [] + let daily = snapshot?.daily.map(Self.costDailyPayload(from:)) ?? [] + let projects = provider == .codex + ? snapshot?.projects.map { project in + CostProjectPayload( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostUSD: project.totalCostUSD, + daily: project.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: project.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:)), + sources: project.sources.map { source in + CostProjectSourcePayload( + name: source.name, + path: source.path, + totalTokens: source.totalTokens, + totalCostUSD: source.totalCostUSD, + daily: source.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: source.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:))) + }) + } ?? [] + : [] return CostPayload( provider: provider.rawValue, @@ -145,10 +206,33 @@ extension CodexBarCLI { last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, daily: daily, + projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), error: error.map { Self.makeErrorPayload($0) }) } + private static func costDailyPayload(from entry: CostUsageDailyReport.Entry) -> CostDailyEntryPayload { + CostDailyEntryPayload( + date: entry.date, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + totalTokens: entry.totalTokens, + costUSD: entry.costUSD, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns?.map(self.costModelBreakdownPayload(from:))) + } + + private static func costModelBreakdownPayload( + from breakdown: CostUsageDailyReport.ModelBreakdown) -> CostModelBreakdownPayload + { + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) + } + private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? { let entries = snapshot.daily guard !entries.isEmpty else { @@ -218,6 +302,13 @@ extension CodexBarCLI { else { return 30 } return max(1, min(365, parsed)) } + + private static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { + guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return .none } + return CostGroupBy(rawValue: raw.lowercased()) ?? .none + } } struct CostOptions: CommanderParsable { @@ -255,6 +346,9 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? + + @Option(name: .long("group-by"), help: "Group text output by: project") + var groupBy: String? } struct CostPayload: Encodable { @@ -268,6 +362,7 @@ struct CostPayload: Encodable { let last30DaysTokens: Int? let last30DaysCostUSD: Double? let daily: [CostDailyEntryPayload] + let projects: [CostProjectPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? @@ -282,6 +377,7 @@ struct CostPayload: Encodable { last30DaysTokens: Int?, last30DaysCostUSD: Double?, daily: [CostDailyEntryPayload], + projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, error: ProviderErrorPayload?) { @@ -295,6 +391,7 @@ struct CostPayload: Encodable { self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD self.daily = daily + self.projects = projects self.totals = totals self.error = error } @@ -336,6 +433,62 @@ struct CostModelBreakdownPayload: Encodable { } } +struct CostProjectPayload: Encodable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + let sources: [CostProjectSourcePayload] + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + case sources + } + + init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostDailyEntryPayload], + modelBreakdowns: [CostModelBreakdownPayload]?, + sources: [CostProjectSourcePayload] = []) + { + self.name = name + self.path = path + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + self.sources = sources + } +} + +struct CostProjectSourcePayload: Encodable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + } +} + struct CostTotalsPayload: Encodable { let totalInputTokens: Int? let totalOutputTokens: Int? diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index 4591a7d3aa..cd82634f4d 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -255,6 +255,8 @@ extension CodexBarCLI { KimiK2SettingsReader.apiKey(environment: environment) != nil case .llmproxy: LLMProxySettingsReader.apiKey(environment: environment) != nil + case .clawrouter: + ClawRouterSettingsReader.apiKey(environment: environment) != nil case .moonshot: MoonshotSettingsReader.apiKey(environment: environment) != nil case .ollama: diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index ce2cc281b5..37a5d6a105 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -34,6 +34,12 @@ enum CodexBarCLI { let invocation = try program.resolve(argv: argv) Self.bootstrapLogging(path: invocation.path, values: invocation.parsedValues) switch invocation.path { + case ["cards"]: + let signalMonitor = CLITerminationSignalMonitor { signalNumber in + CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) + } + defer { signalMonitor.cancel() } + await self.runCards(invocation.parsedValues) case ["usage"]: let signalMonitor = CLITerminationSignalMonitor { signalNumber in CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) @@ -42,6 +48,10 @@ enum CodexBarCLI { await self.runUsage(invocation.parsedValues) case ["cost"]: await self.runCost(invocation.parsedValues) + case ["sessions", "list"]: + await self.runSessions(invocation.parsedValues) + case ["sessions", "focus"]: + await self.runSessionsFocus(invocation.parsedValues) case ["serve"]: await self.runServe(invocation.parsedValues) case ["config", "validate"]: @@ -79,8 +89,11 @@ enum CodexBarCLI { } private static func commandDescriptors() -> [CommandDescriptor] { + let cardsSignature = CommandSignature.describe(CardsOptions()) let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) + let sessionsSignature = CommandSignature.describe(SessionsOptions()) + let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions()) let serveSignature = CommandSignature.describe(ServeOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) @@ -89,6 +102,11 @@ enum CodexBarCLI { let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) return [ + CommandDescriptor( + name: "cards", + abstract: "Print usage as a terminal card grid", + discussion: nil, + signature: cardsSignature), CommandDescriptor( name: "usage", abstract: "Print usage as text or JSON", @@ -99,6 +117,24 @@ enum CodexBarCLI { abstract: "Print local cost usage as text or JSON", discussion: nil, signature: costSignature), + CommandDescriptor( + name: "sessions", + abstract: "List live Codex and Claude Code sessions", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "list", + abstract: "List live Codex and Claude Code sessions", + discussion: nil, + signature: sessionsSignature), + CommandDescriptor( + name: "focus", + abstract: "Focus the window for a session", + discussion: nil, + signature: sessionsFocusSignature), + ], + defaultSubcommandName: "list"), CommandDescriptor( name: "serve", abstract: "Serve usage and cost JSON over localhost HTTP", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 072677339b..8b299dc1ef 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -2,6 +2,42 @@ import CodexBarCore import Foundation extension CodexBarCLI { + static func cardsHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar cards [--json-output] [--log-level ] [-v|--verbose] + [--provider \(ProviderHelp.list)] + [--account